diff --git a/.changeset/30062-declared-override-mapping-removal-remedies.md b/.changeset/30062-declared-override-mapping-removal-remedies.md
new file mode 100644
index 00000000000..0c13d5c7c9f
--- /dev/null
+++ b/.changeset/30062-declared-override-mapping-removal-remedies.md
@@ -0,0 +1,16 @@
+---
+'@objectstack/metadata-protocol': minor
+'@object-ui/app-shell': minor
+---
+
+fix(metadata-protocol): a mapping override is served, a pair's removal of its own override of any overridable type brings the base's item back, and an excluded type's `overrides` refusal names a remedy that works for it (#30062)
+
+Clause-②: no — ADR-0146 D2 says an item in the customization package replaces the base's item and removing it brings the base's back, and ADR-0146's revert ("Restore package original") is deleting the overriding item from `.custom` so the base's document is served again. The 403 on removing a pair's own doc or mapping override, and the unserved mapping override, refused what that text already admits. A removal that names the base still keeps today's answer.
+Metadata-protocol: semantic
+
+- **A mapping override in `.custom` is served.** A mapping item that declares `overrides: { package: '', … }` in the base's customization package now replaces the base's mapping for the by-name read (no `?package`, `?package=`), both lists and the layered read, as page, app, dataset, doc and book already did. The import door (`@objectstack/rest`'s `import-mapping.ts`) resolves a named mapping through that package-less by-name read, so it now gets the pair's mapping.
+- **Removing a pair's own override works for every overridable type.** `DELETE` of a declared override the customization package holds (named with `?package=.custom`) was admitted only for a flow; a doc or mapping override answered `403 NOT_OVERRIDABLE` on the environment kernel. The removal now admits the pair's row for every type `isOverridableMetadataType` names. The row is deleted and the base's item is served again under every address. A removal that names the base (`?package=`) is unchanged: the pair's row stays and its override stays served.
+- **An excluded type's refusal names a remedy that works for it.** A save of `overrides` on a type that takes no override now cites that type's own record (ADR-0146 D6 for the security catalog and code-only types, D1 for a datasource, D9 for a behavioural switch, D10 for a field, ADR-0126 §3 for one-shot types, D2 for the rest) and prescribes a door that exists for that type: a new item under a new name where the type may be created at runtime, the documented switch route for a sharing rule, Setup › Queues for a queue, Setup › Datasources for a datasource, an object extension for a field, and a new version of the base for a type defined only in package source. The error code and status are unchanged.
+- **`@object-ui/app-shell`: the All metadata tile agrees with the Customize door.** On a read-only package, a type's tile reads 可定制 (Customizable) exactly where Studio offers a Customize door for it (flow and app included, whose overlay flag is off) and 只读 (Read-only) elsewhere (doc and mapping, which have no door yet). It no longer reads the overlay flag.
+
+The label precedence of a translated override (card item 3) is not in this change.
diff --git a/content/docs/api/metadata-api.mdx b/content/docs/api/metadata-api.mdx
index 219051e0db2..5aef4d4a82c 100644
--- a/content/docs/api/metadata-api.mdx
+++ b/content/docs/api/metadata-api.mdx
@@ -105,7 +105,10 @@ declaring `overrides: { package: 'hotcrm', basedOn: '3.2.0' }` (`basedOn` is the
`manifest.version` of `hotcrm` it was taken from, recorded and never judged). Only the pair declares an override: an item of any other
package that depends on `hotcrm` and bears a `hotcrm` item's name is refused at save
and publish, and its fix is a new name. A permission set, position or capability is
-never overridden; it is copied under a new name. What the reads then serve is in
+never overridden: a permission set or position is copied under a new name, and a
+capability changes only with a new version of the package that declares it. An
+`overrides` on a type that takes none is refused `403 NOT_OVERRIDABLE`, and the
+sentence names that type's own route. What the reads then serve is in
[Metadata lifecycle](/docs/concepts/metadata-lifecycle#customizing-an-installed-package-the-paired-custom-override-adr-0146).
`POST /packages/hotcrm/ensure-customization` creates the pair when absent (Studio calls
it on the first change), and `GET /packages/hotcrm/customizations` lists its overrides,
diff --git a/content/docs/concepts/metadata-lifecycle.mdx b/content/docs/concepts/metadata-lifecycle.mdx
index d6e2f253a0c..1ae0423260a 100644
--- a/content/docs/concepts/metadata-lifecycle.mdx
+++ b/content/docs/concepts/metadata-lifecycle.mdx
@@ -128,10 +128,15 @@ document **whole** for every read that serves the base's name
override, `effective` = the override), the list scoped to the base and the
environment-wide list all serve the override, stamped `_packageId: 'hotcrm'`. The
declaration counts only from the pair — the stored row's own `package_id` must be
- `hotcrm.custom` — and today only for a `view`, `dashboard`, `report`, `email_template`,
- `translation` or `flow`: the rest of `OVERRIDABLE_METADATA_TYPES` (`@objectstack/spec/kernel`)
- — `page`, `app`, `dataset`, `doc`, `book`, `mapping` and `action` — waits for
- its doors and is not served yet. A declaration from any other package is ignored.
+ `hotcrm.custom` — and for every type of `OVERRIDABLE_METADATA_TYPES` (`@objectstack/spec/kernel`)
+ but `action`: a `view`, `dashboard`, `report`, `email_template`, `translation`, `flow`,
+ `page`, `app`, `dataset`, `doc`, `book` or `mapping`. An `action` override waits for
+ ADR-0146 D9's conditions and is not served yet. A declaration from any other package is ignored.
+ The import door and the connector pull read a named `mapping` through the by-name read,
+ so they run the pair's mapping.
+- **Removing the override brings the base's back.** `DELETE /meta/:type/:name?package=hotcrm.custom`
+ ("Restore package original") deletes the pair's row, for every type above, and every read
+ then serves the base's document again. A `DELETE` naming `hotcrm` removes nothing of the pair's.
- **A new locale is not an override.** An `email_template` the pair saves at a locale the
base does not ship is the pair's own member, served under `hotcrm.custom`, and carries no
`overrides`; declaring one there is refused (`403 NOT_OVERRIDABLE` at the save, publish
diff --git a/packages/metadata-protocol/src/protocol-diff-delete-load.ts b/packages/metadata-protocol/src/protocol-diff-delete-load.ts
index 3dd7a33cf5e..a157b3461b3 100644
--- a/packages/metadata-protocol/src/protocol-diff-delete-load.ts
+++ b/packages/metadata-protocol/src/protocol-diff-delete-load.ts
@@ -401,12 +401,12 @@ export class ProtocolDiffDeleteLoadFacet {
// here, but a stored row under its name is, as repair.
const originGatedRefusal = host.originGatedRemovalRefusal(request.type, request.name);
// [#28412, ADR-0146 D9 item 1] A removal naming the shipping package's
- // pair, of a type whose D9 conditions are delivered (`flow` only —
- // every other open type keeps main's verdict): it removes
- // the row the override is stored in, and the code package's item is
- // served — and armed — again. Admitted past the sealed-removal refusal
- // and told to the store as a runtime-only removal, as the declared
- // save is ({@link isPairRowRemoval}).
+ // pair, of a type whose declared override the gate admits ([#30062]
+ // every open type, no longer `flow` alone): it removes the row the
+ // override is stored in, and the code package's item is served (a
+ // flow's armed) again. Admitted past the sealed-removal refusal and
+ // told to the store as a runtime-only removal, as the declared save
+ // is ({@link isPairRowRemoval}).
const pairRowRemoval = isPairRowRemoval(
request.type, host.packagedArtifactOwner({ type: request.type, name: request.name }), request.packageId,
);
@@ -755,10 +755,10 @@ export class ProtocolDiffDeleteLoadFacet {
// #5927 — was an inline `this.isArtifactBacked(...)` call
// with these exact arguments; now reads the method-scoped
// binding the receipts share. Same fact, one call fewer.
- // [#28412] The pair's row of a type whose D9 conditions
- // are delivered (`flow` only, {@link isPairRowRemoval})
+ // [#28412] The pair's row of a declared override
+ // ({@link isPairRowRemoval}; [#30062] every open type)
// is removed as the declared save wrote it: runtime-only.
- // Every other type keeps the intent main gave it.
+ // Every other removal keeps the intent main gave it.
intent: artifactBacked && !pairRowRemoval
? 'override-artifact'
: 'runtime-only',
diff --git a/packages/metadata-protocol/src/protocol-lock-state.ts b/packages/metadata-protocol/src/protocol-lock-state.ts
index 421ddb1e6d2..5d13d7967a7 100644
--- a/packages/metadata-protocol/src/protocol-lock-state.ts
+++ b/packages/metadata-protocol/src/protocol-lock-state.ts
@@ -150,8 +150,8 @@ export function declinesStoredRow(
if (typeof name !== 'string' || name === '') return false;
if (this.isDeclaredCodeDatasource(type, name)) return true;
if (!this.isUnservedSealedOverlay(type, name)) return false;
- // [#29613, ADR-0146 D8 item 2] …except, for a page, app, dataset, doc or
- // book, the shipping package's declared override: judged as the flow's
+ // [#29613, ADR-0146 D8 item 2] …except, for a page, app, dataset, doc,
+ // book or [#30062] mapping, the shipping package's declared override: judged as the flow's
// above, on the row's own `package_id` and body, against the package
// that ships the name. The reads then serve it from its row, as they
// serve a dashboard's. Every other sealed row of the name stays declined.
diff --git a/packages/metadata-protocol/src/protocol-package-bindings.sealed-override.test.ts b/packages/metadata-protocol/src/protocol-package-bindings.sealed-override.test.ts
index 4db880c19f1..0dfa5e85247 100644
--- a/packages/metadata-protocol/src/protocol-package-bindings.sealed-override.test.ts
+++ b/packages/metadata-protocol/src/protocol-package-bindings.sealed-override.test.ts
@@ -15,7 +15,8 @@
import { describe, expect, it } from 'vitest';
import { declinesBorrowedEnvelope, servesSealedOverrideRow } from './protocol-package-bindings.js';
-const ROW_SERVED = ['page', 'app', 'dataset', 'doc', 'book'] as const;
+/** [#30062] `mapping` the sixth: its reads were measured and its published override was served by none. */
+const ROW_SERVED = ['page', 'app', 'dataset', 'doc', 'book', 'mapping'] as const;
/** The base's code artifact the hydrator's lookup falls back to for a pair's row. */
const BASE_ARTIFACT = { _packageId: 'com.example.showcase' };
@@ -30,7 +31,7 @@ describe('[#29613] the sealed row-served override types', () => {
expect(servesSealedOverrideRow('apps')).toBe(true);
});
- it.each(['mapping', 'action', 'flow', 'object', 'permission', 'view', 'dashboard', 'report', 'email_template'])(
+ it.each(['action', 'flow', 'object', 'permission', 'view', 'dashboard', 'report', 'email_template'])(
'%s: not a sealed row-served override type',
(type) => {
expect(servesSealedOverrideRow(type)).toBe(false);
diff --git a/packages/metadata-protocol/src/protocol-package-bindings.ts b/packages/metadata-protocol/src/protocol-package-bindings.ts
index 4b9fe938e69..55ed7333301 100644
--- a/packages/metadata-protocol/src/protocol-package-bindings.ts
+++ b/packages/metadata-protocol/src/protocol-package-bindings.ts
@@ -307,9 +307,9 @@ function boundPackageOf(packageId: unknown): string | undefined {
* (#22057, `anotherPackageShips`).
*
* ⛔ A closed list, judged on the canonical type. [#29613] It includes the
- * five {@link SEALED_ROW_SERVED_OVERRIDE_TYPES} since their reads serve a
- * `.custom` pair's declared override from its row, as these three types'
- * reads do. `flow` registers with no artifact envelope at all (Regime C,
+ * {@link SEALED_ROW_SERVED_OVERRIDE_TYPES} ([#30062] `mapping` the sixth)
+ * since their reads serve a `.custom` pair's declared override from its row,
+ * as these three types' reads do. `flow` registers with no artifact envelope at all (Regime C,
* #20913), and the boot pull arms its declared override from the bare slot.
*/
const BORROWED_ENVELOPE_DECLINED_TYPES: ReadonlySet = new Set([
@@ -322,16 +322,19 @@ const BORROWED_ENVELOPE_DECLINED_TYPES: ReadonlySet = new Set([
'dataset',
'doc',
'book',
+ // [#30062] An import mapping's declared override, served from its row as the five above.
+ 'mapping',
]);
/**
* [#29613, ADR-0146 D8 item 2] The SEALED types (ADR-0131 D6: a registry
* entry with `allowEnvironmentOverlay: false`) whose stored row the reads
* serve when it is the shipping package's declared `.custom` override
- * ({@link isServedOverrideRowOf}): `page`, `app`, `dataset`, `doc` and
- * `book`, the overridable types of ADR-0146 D2 that open no environment
- * overlay. Every other stored row of a name a managed package ships stays
- * unserved for them ({@link isUnservedSealedOverlay}).
+ * ({@link isServedOverrideRowOf}): `page`, `app`, `dataset`, `doc`, `book`
+ * and [#30062] `mapping`, the overridable types of ADR-0146 D2 that open no
+ * environment overlay and wait for no D9 condition. Every other stored row of
+ * a name a managed package ships stays unserved for them
+ * ({@link isUnservedSealedOverlay}).
*
* Before, the reads declined the override's row with the rest, and the
* override was served only from the registry's bare slot, where the hydrator
@@ -340,14 +343,19 @@ const BORROWED_ENVELOPE_DECLINED_TYPES: ReadonlySet = new Set([
* instead, while the lists answered the override, and the layered read's
* effective layer and the pair's per-package layer never saw it.
*
- * ⛔ A closed list, judged on the canonical type. `mapping` is overridable and
- * sealed too, and is not here: no card has measured its reads. `action` and
- * `flow` wait for ADR-0146 D9's conditions (`flow` has its own admission,
+ * [#30062] `mapping` joined once its reads were measured: before, a pair's
+ * mapping override saved and published, and every read (the by-name read the
+ * import door resolves a mapping through, `@objectstack/rest`'s
+ * `import-mapping.ts`, included) answered the base's body, so a published
+ * override did nothing.
+ *
+ * ⛔ A closed list, judged on the canonical type. `action` and `flow` wait for
+ * ADR-0146 D9's conditions (`flow` has its own admission,
* `declinesStoredRow`'s first branch). Every type here is also in
* {@link BORROWED_ENVELOPE_DECLINED_TYPES}: the bare entry served no reader
* the row does not serve now.
*/
-const SEALED_ROW_SERVED_OVERRIDE_TYPES: ReadonlySet = new Set(['page', 'app', 'dataset', 'doc', 'book']);
+const SEALED_ROW_SERVED_OVERRIDE_TYPES: ReadonlySet = new Set(['page', 'app', 'dataset', 'doc', 'book', 'mapping']);
/** [#29613] Whether `type` is one of {@link SEALED_ROW_SERVED_OVERRIDE_TYPES}, folded to the canonical singular. */
function servesSealedOverrideRow(type: string): boolean {
diff --git a/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts b/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts
index 5956fab0741..7c958aa1395 100644
--- a/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts
+++ b/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts
@@ -36,7 +36,13 @@
*/
import type { ObjectStackProtocolImplementation } from './protocol.js';
-import { isOriginGatedType, managedItemSealedSentence, regimeCDeclaredSaveSentence } from './packaged-base-regime.js';
+import {
+ isOriginGatedType,
+ managedItemSealedSentence,
+ packagedBaseRegimePrescription,
+ packagedBaseRegimeRow,
+ regimeCDeclaredSaveSentence,
+} from './packaged-base-regime.js';
import { SysMetadataRepository } from './sys-metadata-repository.js';
import { OBJECT_EXTENSION_TYPE } from './protocol-object-extension-item.js';
import { customizationPairEnsureRefusal, type PairRegistry } from './protocol-customization-package.js';
@@ -44,9 +50,11 @@ import { overlayMemberField, overlayMemberOfBody, overlayMemberValue } from './m
import {
customizationBasePackageId,
customizationPackageId,
+ DEFAULT_METADATA_TYPE_REGISTRY,
getOverrideTypeClassification,
isCustomizationPairOf,
OverrideDeclarationSchema,
+ type OverrideClassificationBasis,
} from '@objectstack/spec/kernel';
import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared';
import { expandViewContainer, isAggregatedViewContainer } from '@objectstack/spec';
@@ -526,20 +534,32 @@ function isOpenOverrideType(singular: string): boolean {
/**
* [#28412, ADR-0146 D9 item 1] Whether a removal addressed to `packageId`
- * removes the shipping package's PAIR row of a type whose D9 conditions are
- * delivered ({@link CONDITIONS_DELIVERED}: `flow` today) — the row the pair's
+ * removes the shipping package's PAIR row of a type whose declared override
+ * the gate admits today ({@link isOpenOverrideType}) — the row the pair's
* declared override is stored in. Such a removal is admitted past the
* sealed-removal refusal: it removes no managed definition, and the code
- * package's flow is what every read serves and what the automation engine
- * arms once the row is gone — the narrowing direction the #6960 ruling names.
- * ⛔ Only the pair, named: a removal naming no package, or any other package,
- * keeps the sealed refusal it had. ⛔ Only a delivered type: every other open
- * type (`view`, `mapping`, …) keeps main's delete verdict — its own removal
- * carve-out or its sealed refusal — untouched by this card.
+ * package's item is what every read serves (for a flow, what the automation
+ * engine arms) once the row is gone — the narrowing direction the #6960
+ * ruling names, and ADR-0146's revert ("Restore package original" deletes the
+ * overriding item from `.custom`, so the base's document is served again).
+ *
+ * [#30062] Every open type, no longer `flow` alone. A `doc` or `mapping`
+ * opens no overlay channel and is no `supportsOverlay` type either, so the
+ * sealed-removal refusal answered a pair's own removal of its declared
+ * override with 403 and the override stayed served: a customization the
+ * customer could not undo. The other open types already passed (their own
+ * overlay channel or the #6960 carve-out); the removal is now told to the
+ * store as runtime-only for all of them, as the declared save wrote the row.
+ *
+ * ⛔ Only the pair, named: a removal naming no package, the base, or any
+ * other package, keeps the refusal it had — a base's own item is never
+ * removed. ⛔ Only an open type: `action` (its D9 conditions are not
+ * delivered, so no pair holds a declared action override) and every excluded
+ * type keep main's delete verdict.
*/
export function isPairRowRemoval(type: string, basePackageId: unknown, packageId: unknown): boolean {
const singular = PLURAL_TO_SINGULAR[type] ?? type;
- if (!CONDITIONS_DELIVERED.has(singular) || !isOpenOverrideType(singular)) return false;
+ if (!isOpenOverrideType(singular)) return false;
if (typeof basePackageId !== 'string') return false;
if (typeof packageId !== 'string' || packageId.length === 0) return false;
const pair = customizationPackageId(basePackageId);
@@ -656,6 +676,89 @@ function declarationOf(item: unknown): { present: boolean; base: string | undefi
return { present: true, base: declared.data.package };
}
+/**
+ * [#30062] The decision record each excluded type's basis rests on: the one
+ * the refusal of a declared override of that type cites. Before, every
+ * excluded type cited ADR-0146 D6, the security catalog's paragraph, whatever
+ * excluded it.
+ */
+const EXCLUSION_BASIS_CITATION: Readonly> = {
+ adr_0146_d2_listed: 'ADR-0146 D2',
+ adr_0146_d6_security_catalog: 'ADR-0146 D6',
+ adr_0146_d10_extended: 'ADR-0146 D10',
+ adr_0126_code_only: 'ADR-0146 D6',
+ not_package_shipped: 'ADR-0146 D2',
+ not_runtime_creatable: 'ADR-0146 D2',
+ adr_0126_one_shot: 'ADR-0126 §3',
+ setup_owned_connection: 'ADR-0146 D1',
+ behavioural_switch_first: 'ADR-0146 D9',
+};
+
+/**
+ * [#30062] The Setup page an organization authors its OWN item of an
+ * excluded type on, for a type no metadata door creates and no ADR-0126
+ * regime row routes: a queue is a `sys_queue` record (ADR-0142 D1), listed at
+ * Setup › Queues (`@objectstack/plugin-sharing`'s navigation contribution).
+ */
+const SETUP_AUTHORED_DOORS: Readonly> = {
+ queue: 'Setup › Queues',
+};
+
+/** `type`'s registry entry admits a runtime create (`allowRuntimeCreate`), read on the canonical type. */
+function admitsRuntimeCreate(type: string): boolean {
+ return DEFAULT_METADATA_TYPE_REGISTRY.some((e) => e.type === type && e.allowRuntimeCreate === true);
+}
+
+/**
+ * [#30062] The remedy a declared override of an excluded `type` is told: one
+ * that works for that type, read from the facts that decide whether it does,
+ * in this order, never from a sentence shared by every excluded type:
+ *
+ * 1. a Setup-owned connection (`datasource`, ADR-0146 D1): Setup › Datasources;
+ * a `field`: its object's extension in the pair (ADR-0146 D10);
+ * 2. a type the metadata door creates (`allowRuntimeCreate`): the customer's
+ * own item under a new name, "new similar" (D6's remedy for its catalog);
+ * 3. a type with an ADR-0126 Regime C row: that row's sanctioned path, the
+ * sentence every other door of the type names (`sharing_rule`: an
+ * organization rule, `POST /sharing/rules`, which Setup › Sharing Rules
+ * lists, or the switch);
+ * 4. a type an organization authors as a Setup record ({@link SETUP_AUTHORED_DOORS});
+ * 5. else the type is defined only in package source: it changes with a new
+ * version of the package that ships it.
+ *
+ * Before, every excluded type was told to "create your own TYPE under a new
+ * name", and for a type with no runtime create (a sharing rule, a queue, a
+ * code-only type) that create answered 403 `NOT_CREATABLE`.
+ */
+function excludedTypeRemedy(type: string, base: string | undefined): string {
+ if (getOverrideTypeClassification(type)?.basis === 'setup_owned_connection') {
+ return `Remove \`overrides\`: ${articleFor(type)} ${type} is the environment's own connection and does not travel in a package. `
+ + 'Add your own under a new name in Setup › Datasources.';
+ }
+ if (type === 'field') {
+ // ADR-0146 D10: a base field changes through its object's extension, never on its own.
+ return 'Remove `overrides` and change the field through an object extension in the base\'s paired .custom package '
+ + `(ADR-0146 D10): an \`object_extension\` item saved through ${OBJECT_EXTENSION_ITEM_DOOR} adds a field, or changes a base field within the display-property whitelist.`;
+ }
+ if (admitsRuntimeCreate(type)) {
+ return `Remove \`overrides\` and create your own ${type} under a new name ("new similar").`;
+ }
+ if (packagedBaseRegimeRow(type)?.regime === 'C') {
+ return `Remove \`overrides\`. ${packagedBaseRegimePrescription(type)}`;
+ }
+ const setup = Object.prototype.hasOwnProperty.call(SETUP_AUTHORED_DOORS, type) ? SETUP_AUTHORED_DOORS[type] : undefined;
+ if (setup !== undefined) {
+ return `Remove \`overrides\` and add your organization's own ${type} under a new name in ${setup}.`;
+ }
+ const shipper = base === undefined ? 'the package that ships it' : `'${base}'`;
+ return `Remove \`overrides\`: ${articleFor(type)} ${type} is defined only in package source, so it changes only with a new version of ${shipper}.`;
+}
+
+/** The indefinite article for a type name, by its first letter: "an api", "a permission". */
+function articleFor(type: string): 'a' | 'an' {
+ return /^[aeiou]/.test(type) ? 'an' : 'a';
+}
+
/**
* THE override write gate's rule, as a pure verdict — the one judgement the
* publish doors (`saveMetaItem`, `publishMetaItem`, `publishPackageDrafts`),
@@ -709,9 +812,11 @@ export function judgeOverrideWrite(
+ `object it extends and is stored under that object's name. Remove \`overrides\` and edit the object_extension '${name}' itself.`);
}
if (row?.disposition !== 'overridable') {
+ // [#30062] The citation is the type's own basis, and the remedy one that works for the type.
+ const cite = row === undefined ? 'ADR-0146 D2' : EXCLUSION_BASIS_CITATION[row.basis];
return refuse('declared_type_not_overridable',
- `${item} carries \`overrides\`, but a ${type} takes no override (ADR-0146 D6). `
- + `Remove \`overrides\` and create your own ${type} under a new name ("new similar").`);
+ `${item} carries \`overrides\`, but ${articleFor(type)} ${type} takes no override (${cite}). `
+ + excludedTypeRemedy(type, declaration.base));
}
// `action` is classified overridable and waits for D9's conditions
// (`isOpenOverrideType`): "action customization is not yet available".
diff --git a/packages/metadata-protocol/src/protocol-registry-hydration.ts b/packages/metadata-protocol/src/protocol-registry-hydration.ts
index ded6dba6fb9..04d79dd37d3 100644
--- a/packages/metadata-protocol/src/protocol-registry-hydration.ts
+++ b/packages/metadata-protocol/src/protocol-registry-hydration.ts
@@ -279,8 +279,8 @@ export class ProtocolRegistryHydrationFacet {
* after, measured for each reader. What changes is the registry-direct
* read of the name, which answers the shipping package's own item.
*
- * ⛔ Those three types, and [#29613] `page`, `app`, `dataset`, `doc` and
- * `book`, whose reads serve a pair's declared override from its row since
+ * ⛔ Those three types, and [#29613] `page`, `app`, `dataset`, `doc`,
+ * `book` and [#30062] `mapping`, whose reads serve a pair's declared override from its row since
* {@link declinesStoredRow} admits it. A row of a package that ships its own
* artifact of the name keeps its bare entry (#4624). `flow` registers with
* no artifact envelope (below).
@@ -441,7 +441,7 @@ export class ProtocolRegistryHydrationFacet {
// bound to a package that ships no artifact of its name, under a name
// another package ships, is not registered under the bare name, where
// it would wear that other package's envelope. Its reads answer it from
- // its row. ⛔ Those three types, and [#29613] page, app, dataset, doc and book.
+ // its row. ⛔ Those three types, and [#29613] page, app, dataset, doc, book and [#30062] mapping.
if (declinesBorrowedEnvelope(canonicalType, options.packageId, artifact)) return true;
// [#16702] Say what this row IS before the artifact envelope is grafted
// on top of it. Every body reaching this hydrator came out of a
diff --git a/packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts b/packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts
index 14ea8513ca4..36e4a4a20f2 100644
--- a/packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts
+++ b/packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts
@@ -28,7 +28,9 @@
*/
import { afterEach, describe, expect, it } from 'vitest';
import { assertEngineDeleteDispatch, assertEngineFindOnePredicate, assertEngineUpdateDispatch } from '@objectstack/metadata-core';
+import { DEFAULT_METADATA_TYPE_REGISTRY, OVERRIDE_TYPE_CLASSIFICATION } from '@objectstack/spec/kernel';
import { ObjectStackProtocolImplementation } from './protocol.js';
+import { judgeOverrideWrite } from './protocol-packaged-base-refusal.js';
import { resetEnvWritableMetadataTypes } from './sys-metadata-repository.js';
const PACKAGE_ID = 'com.example.pkg';
@@ -924,3 +926,74 @@ describe('the override write gate — only the base\'s paired .custom declares a
.toEqual(['dashboard/custom_board:active', 'page/crm_home:active']);
});
});
+
+/**
+ * [#30062] A declared override of a type that takes none is told a remedy that
+ * works for THAT type, and cites the record that excluded it. Before, every
+ * excluded type read "… takes no override (ADR-0146 D6). Remove `overrides` and
+ * create your own TYPE under a new name": a `sharing_rule`'s or a `queue`'s
+ * create then answered 403 NOT_CREATABLE, a code-only type has no create at
+ * all, the D6 citation was the security catalog's for types outside it, and an
+ * `api` read "a api".
+ *
+ * Judged on the pure verdict every door asks (`judgeOverrideWrite`), for every
+ * excluded type of the closed classification, so a type classified later is
+ * held to the rule the day it lands.
+ */
+describe('[#30062] each excluded type\'s refusal names a remedy that works for it', () => {
+ const BASE_ID = 'com.example.base';
+ const PAIR_ID = `${BASE_ID}.custom`;
+ const CITATION: Record = {
+ adr_0146_d6_security_catalog: 'ADR-0146 D6',
+ adr_0126_code_only: 'ADR-0146 D6',
+ not_package_shipped: 'ADR-0146 D2',
+ not_runtime_creatable: 'ADR-0146 D2',
+ adr_0126_one_shot: 'ADR-0126 §3',
+ setup_owned_connection: 'ADR-0146 D1',
+ behavioural_switch_first: 'ADR-0146 D9',
+ };
+ const creatable = (type: string) => DEFAULT_METADATA_TYPE_REGISTRY.some((e) => e.type === type && e.allowRuntimeCreate === true);
+ // The extend-only object and its extension keep their own D10 sentences (pinned above).
+ const EXCLUDED = (Object.entries(OVERRIDE_TYPE_CLASSIFICATION) as Array<[string, { disposition: string; basis: string }]>)
+ .filter(([, row]) => row.disposition === 'excluded' && row.basis !== 'adr_0146_d10_extended');
+ const refusalOf = (type: string): string => {
+ const verdict = judgeOverrideWrite(
+ { type, name: 'their_item', packageId: PAIR_ID, item: { name: 'their_item', overrides: { package: BASE_ID, basedOn: '1.0.0' } } },
+ { dependencies: [BASE_ID], shippers: [BASE_ID] },
+ );
+ expect(verdict, type).toMatchObject({ verdict: 'refuse', rule: 'declared_type_not_overridable' });
+ return (verdict as { message: string }).message;
+ };
+
+ it('enumerates every excluded type of the classification', () => {
+ expect(EXCLUDED.length).toBeGreaterThanOrEqual(17);
+ });
+
+ for (const [type, row] of EXCLUDED) {
+ it(`${type}: cites ${CITATION[row.basis]}, and names a create only where the type has one`, () => {
+ const message = refusalOf(type);
+ expect(message).toContain(`takes no override (${CITATION[row.basis]})`);
+ expect(message).not.toMatch(/\ba [aeiou]/);
+ if (creatable(type) && row.basis !== 'setup_owned_connection') {
+ expect(message).toContain(`create your own ${type} under a new name`);
+ } else {
+ expect(message).not.toContain('create your own');
+ }
+ });
+ }
+
+ it('a sharing rule names its organization rule and its switch, a queue its Setup page, a datasource Setup', () => {
+ expect(refusalOf('sharing_rule')).toContain('POST /api/v1/sharing/rules');
+ expect(refusalOf('sharing_rule')).toContain('/security/_activation/sharing_rule/:name');
+ expect(refusalOf('queue')).toContain('in Setup › Queues');
+ expect(refusalOf('datasource')).toContain('Setup › Datasources');
+ expect(refusalOf('field')).toContain('`object_extension` item saved through');
+ });
+
+ it('a code-only type names a new version of the base that ships it', () => {
+ for (const type of ['job', 'api', 'agent', 'capability', 'picklist', 'assignment_rule']) {
+ expect(refusalOf(type), type).toContain(`changes only with a new version of '${BASE_ID}'`);
+ }
+ expect(refusalOf('api')).toContain('but an api takes no override');
+ });
+});
diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts
index 8ea5370d2cf..8d55d60c9ed 100644
--- a/packages/metadata-protocol/src/protocol.ts
+++ b/packages/metadata-protocol/src/protocol.ts
@@ -7877,7 +7877,7 @@ export class ObjectStackProtocolImplementation implements
* written through the `OS_METADATA_WRITABLE` hatch before the seal; they
* stay at rest, untouched, and the boot report names them per type with
* the two remedies ({@link reportSealedOverlayRows}). [#29613] Except,
- * for a `page`, `app`, `dataset`, `doc` or `book`, the shipping
+ * for a `page`, `app`, `dataset`, `doc`, `book` or [#30062] `mapping`, the shipping
* package's declared `.custom` override (`row`, below): ADR-0146 D8
* item 2 serves it, as a dashboard's.
*
diff --git a/packages/objectql/src/protocol-custom-override-bare-slot-envelope.test.ts b/packages/objectql/src/protocol-custom-override-bare-slot-envelope.test.ts
index b0a3b0428a3..2808974d8ff 100644
--- a/packages/objectql/src/protocol-custom-override-bare-slot-envelope.test.ts
+++ b/packages/objectql/src/protocol-custom-override-bare-slot-envelope.test.ts
@@ -38,6 +38,10 @@
*
* ## [#29613] The second describe: page, app, dataset, doc and book
*
+ * [#30062] …and mapping, measured the same way: before, a published mapping
+ * override was answered by no read (every reader served S), so an import that
+ * named the mapping ran the base's field mapping.
+ *
* A `.custom` pair's published override of a base's
* page, app, dataset, doc or book is served from its ROW, as a dashboard's
* is, and no longer from a copy in the registry's bare slot wearing the
@@ -359,6 +363,11 @@ const docBody = (label: string) => ({ name: 'showcase_index', label, content: '#
const bookBody = (label: string) => ({
name: 'showcase_manual', label, groups: [{ key: 'start', label: 'Start', pages: ['showcase_index'] }],
});
+/** [#30062] An import mapping: the import door resolves it through the by-name read (`@objectstack/rest`'s `import-mapping.ts`). */
+const mappingBody = (label: string) => ({
+ name: 'showcase_task_feed', label, sourceFormat: 'csv', targetObject: 'showcase_task',
+ fieldMapping: [{ source: 'Title', target: 'title' }], mode: 'insert',
+});
/** Per type: its body and the manifest collection that ships it. */
const SEALED_TYPES = [
@@ -367,6 +376,8 @@ const SEALED_TYPES = [
{ type: 'dataset', body: datasetBody, collection: 'datasets' },
{ type: 'doc', body: docBody, collection: 'docs' },
{ type: 'book', body: bookBody, collection: 'books' },
+ // [#30062] Before, a published mapping override was answered by no read: every reader served the base's body.
+ { type: 'mapping', body: mappingBody, collection: 'mappings' },
] as const;
function sealedBaseManifest(): Record {
@@ -489,3 +500,103 @@ describe('[#29613] a pair\'s published override of a sealed type is served from
});
}
});
+
+/**
+ * [#30062, ADR-0146 D2] "Removing it brings the base's back": a pair's removal
+ * of its own published override succeeds for every type whose declared
+ * override the gate admits and a pair can hold, and the base's item is served
+ * again by every read; a removal naming the base keeps its answer and leaves
+ * the override served (a base's own item is never removed).
+ *
+ * Before, `doc` and `mapping` answered 403 NOT_OVERRIDABLE (`refusePackagedBaseRemoval`,
+ * the pair-row admission being `flow`'s alone) and the override stayed served:
+ * a customization the customer could not undo. `flow` is pinned beside its own
+ * read in `protocol-flow-custom-override.test.ts`; `action` takes no declared
+ * override yet (ADR-0146 D9), so no pair holds one.
+ *
+ * Both topologies: an environment kernel asks the protocol's removal door, a
+ * host-config kernel only the repository's delete gate.
+ */
+const REMOVAL_TYPES = [
+ ...TYPES.map(({ type, name, body }) => ({ type, name, body: body as (label: string) => Record })),
+ ...SEALED_TYPES.map(({ type, body }) => ({ type, name: body(SHIPPED).name, body: body as (label: string) => Record })),
+ {
+ type: 'translation', name: 'showcase_zh_cn',
+ body: (label: string) => ({ name: 'showcase_zh_cn', label, locale: 'zh-CN', objects: { showcase_task: { label: '任务' } } }),
+ },
+] as const;
+
+function removalBaseManifest(): Record {
+ // The #29600 base and the #29613 sealed types in one package; the sealed dataset's body wins the shared name.
+ return { ...baseManifest(), ...sealedBaseManifest(), views: baseManifest().views, dashboards: baseManifest().dashboards, reports: baseManifest().reports, emailTemplates: baseManifest().emailTemplates };
+}
+
+async function bootRemoval(environmentId: string | undefined) {
+ const engine = new ObjectQL();
+ engine.registerDriver(makeMemoryDriver().driver, true);
+ await engine.init();
+ engine.registry.registerObject(sysMetadataObject, 'test-package');
+ for (const o of [SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) {
+ engine.registry.registerObject(o as any, 'test-package');
+ }
+ engine.registerApp(removalBaseManifest());
+ // The manifest door registers no translation artifact here; a code package's translation file does (the metadata plugin).
+ engine.registry.registerItem('translation', REMOVAL_TYPES[REMOVAL_TYPES.length - 1].body(SHIPPED), 'name', BASE);
+ const protocol: any = new ObjectStackProtocolImplementation(engine, () => new Map(), environmentId);
+ await protocol.ensureCustomizationPackage({ basePackageId: BASE });
+ return { engine, protocol };
+}
+
+/** The overridable types that open no overlay channel and are no `supportsOverlay` type: a removal naming the base is refused for them. */
+const SEALED_OUTSIDE_CARVE_OUT: ReadonlySet = new Set(['doc', 'mapping']);
+
+const activePairRows = async (engine: ObjectQL, type: string, name: string): Promise =>
+ ((await engine.find('sys_metadata', { where: { type, name, package_id: PAIR, state: 'active' }, context: { isSystem: true } })) as any[]).length;
+
+describe('[#30062] a pair\'s removal of its own override brings the base\'s item back, for every type a pair can hold', () => {
+ for (const environmentId of [undefined, 'env_1']) {
+ const kernel = environmentId ? 'environment' : 'host-config';
+ for (const { type, name, body } of REMOVAL_TYPES) {
+ it(`${type} (${kernel} kernel): the removal naming the pair succeeds and every read serves the base's item again`, async () => {
+ const { engine, protocol } = await bootRemoval(environmentId);
+ await publishOverride(protocol, type, name, body(EDITED));
+ expect(await byName(protocol, type, name, BASE), `${type} served before the removal`).toBe(EDITED);
+
+ const removed = await protocol.deleteMetaItem({ type, name, packageId: PAIR });
+ expect(removed?.success, `the ${type} removal`).toBe(true);
+ expect(await activePairRows(engine, type, name), `the pair's ${type} row`).toBe(0);
+
+ expect(await byName(protocol, type, name), `${type} by name, no package`).toBe(SHIPPED);
+ expect(await byName(protocol, type, name, BASE), `${type} by name, the base`).toBe(SHIPPED);
+ expect(await effectiveList(protocol, type, name), `${type} list, no package`).toEqual([SHIPPED]);
+ expect((await protocol.getMetaItemLayered({ type, name, packageId: BASE }))?.effective?.label, `${type} layered, the base`).toBe(SHIPPED);
+ expect(await perPackage(protocol, type, name, PAIR), `the pair's ${type} layer`).toEqual([]);
+ });
+
+ it(`${type} (${kernel} kernel): a removal naming the base keeps main's answer, removes nothing, and the override stays served`, async () => {
+ const { engine, protocol } = await boot2(environmentId, type, name, body);
+ const outcome = await protocol.deleteMetaItem({ type, name, packageId: BASE }).then(
+ (r: any) => ({ success: r?.success, reset: r?.reset }),
+ (e: any) => ({ code: e?.code, status: e?.status }),
+ );
+ // Main's answer, unchanged: the sealed removal refusal where the protocol's door
+ // asks it of a type outside the #6960 carve-out, else a receipt that removed nothing.
+ expect(outcome, `the ${type} removal naming the base`).toEqual(
+ environmentId !== undefined && SEALED_OUTSIDE_CARVE_OUT.has(type)
+ ? { code: 'NOT_OVERRIDABLE', status: 403 }
+ : { success: true, reset: false },
+ );
+ expect(await activePairRows(engine, type, name), `the pair's ${type} row`).toBe(1);
+ expect(await byName(protocol, type, name, BASE), `${type} by name, the base`).toBe(EDITED);
+ expect((await protocol.getMetaItemLayered({ type, name, packageId: BASE }))?.code?.label, `the base's own ${type}`).toBe(SHIPPED);
+ });
+ }
+ }
+});
+
+/** A removal boot with the pair's override already published. */
+async function boot2(environmentId: string | undefined, type: string, name: string, body: (label: string) => Record) {
+ const booted = await bootRemoval(environmentId);
+ await publishOverride(booted.protocol, type, name, body(EDITED));
+ return booted;
+}
diff --git a/packages/objectql/src/protocol-flow-custom-override.test.ts b/packages/objectql/src/protocol-flow-custom-override.test.ts
index 2cc16c6a5a0..90e1dd14ac2 100644
--- a/packages/objectql/src/protocol-flow-custom-override.test.ts
+++ b/packages/objectql/src/protocol-flow-custom-override.test.ts
@@ -296,14 +296,17 @@ describe('[#28412] a declared .custom flow override is the packaged flow, at eve
});
/**
- * [#28412, contract review 6559358939] The pair-row removal is admitted for
- * `flow` alone — the one type whose ADR-0146 D9 conditions this change
- * delivers. Every other open override type keeps the removal verdict and the
- * receipt intent it has on main: a `mapping` (no overlay channel, outside the
- * #6960 carve-out) is still refused, and a `view` (overlay-capable) is still
- * removed as an `override-artifact` delete.
+ * [#28412, contract review 6559358939] The pair-row removal was admitted for
+ * `flow` alone, the one type whose ADR-0146 D9 conditions that change
+ * delivered. [#30062] It is admitted for every open type now: a pair's
+ * `mapping` (no overlay channel, outside the #6960 carve-out) answered 403
+ * and its override could not be removed, and every pair row of a declared
+ * override is removed as the declared save wrote it, runtime-only (a `view`
+ * was removed as an `override-artifact` delete before). The per-type removal
+ * through the publish door, and a removal naming the base, are pinned in
+ * `protocol-custom-override-bare-slot-envelope.test.ts`.
*/
-describe('[#28412] the pair-row removal is flow-only: other open types keep main\'s verdict', () => {
+describe('[#28412, #30062] the pair-row removal admits every open type', () => {
const storePairRow = (engine: ObjectQL, type: string, name: string, body: Record) =>
engine.insert('sys_metadata', {
type, name, organization_id: null, package_id: PAIR,
@@ -314,17 +317,17 @@ describe('[#28412] the pair-row removal is flow-only: other open types keep main
for (const environmentId of [undefined, 'env_1']) {
const kernel = environmentId ? 'environment' : 'host-config';
- it(`a mapping's pair-row delete naming the pair still answers 403 NOT_OVERRIDABLE, and the row stays (${kernel} kernel)`, async () => {
+ it(`a mapping's pair-row delete naming the pair removes the row (${kernel} kernel)`, async () => {
const { protocol, engine } = await boot(environmentId);
await install(protocol, PAIR);
await storePairRow(engine, 'mapping', MAPPING, { name: MAPPING, label: 'Ours', sourceFormat: 'csv', targetObject: 'showcase_task', fieldMapping: [] });
- await expect(protocol.deleteMetaItem({ type: 'mapping', name: MAPPING, packageId: PAIR } as any))
- .rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 });
- expect((await rowsOf(engine, 'mapping', MAPPING)).map((r) => r.package_id)).toEqual([PAIR]);
+ const removed: any = await protocol.deleteMetaItem({ type: 'mapping', name: MAPPING, packageId: PAIR } as any);
+ expect(removed?.success).toBe(true);
+ expect((await rowsOf(engine, 'mapping', MAPPING)).filter((r) => r.state === 'active')).toEqual([]);
});
}
- it('a view\'s pair-row delete naming the pair is removed as an override-artifact delete, as on main', async () => {
+ it('a view\'s pair-row delete naming the pair is removed as the declared save wrote it: runtime-only', async () => {
const { protocol, engine } = await boot();
await install(protocol, PAIR);
await storePairRow(engine, 'view', VIEW, {
@@ -335,7 +338,7 @@ describe('[#28412] the pair-row removal is flow-only: other open types keep main
try {
await protocol.deleteMetaItem({ type: 'view', name: VIEW, packageId: PAIR } as any);
expect(removal).toHaveBeenCalledTimes(1);
- expect(removal.mock.calls[0][1]).toMatchObject({ intent: 'override-artifact', packageId: PAIR });
+ expect(removal.mock.calls[0][1]).toMatchObject({ intent: 'runtime-only', packageId: PAIR });
} finally {
removal.mockRestore();
}
diff --git a/ui/packages/app-shell/src/views/metadata-admin/DirectoryPage.stateVocabulary-29705.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/DirectoryPage.stateVocabulary-29705.test.tsx
index cc6c5f1bfe6..2cd99092aea 100644
--- a/ui/packages/app-shell/src/views/metadata-admin/DirectoryPage.stateVocabulary-29705.test.tsx
+++ b/ui/packages/app-shell/src/views/metadata-admin/DirectoryPage.stateVocabulary-29705.test.tsx
@@ -89,7 +89,10 @@ vi.stubGlobal(
);
import { MetadataDirectoryPage } from './DirectoryPage';
-import { PageShell, TYPE_STATE_WORD_KEY, typeStateWord, type TypeStateWord } from './PageShell';
+import { PageShell, TYPE_STATE_WORD_KEY, studioOffersCustomize, typeStateWord, type TypeStateWord } from './PageShell';
+import { OVERRIDABLE_METADATA_TYPES } from '@objectstack/spec/kernel';
+import { STUDIO_CUSTOMIZE_TYPES } from '../studio-design/StudioCustomizeControls';
+import { AREA_CUSTOMIZE_EDITOR_TYPES, AREA_FORM_EDITOR_TYPES } from '../studio-design/metadataArea';
afterEach(() => {
cleanup();
@@ -117,20 +120,21 @@ async function tileWord(type: string): Promise {
}
describe('All metadata tiles: one state word, in the package context (objectstack#29705)', () => {
- it('a read-only package: overlay types can be customized, never 可写; the create-only and closed types are read-only', async () => {
+ it('a read-only package: the types Studio customizes read 可定制, never 可写; the closed types are read-only', async () => {
renderDirectory(true);
- for (const type of ['dashboard', 'report', 'view']) {
+ // objectstack#30062: a page reads 可定制 since its Customize door opened (objectstack#30063),
+ // although its overlay flag is off: the tile follows the door, not the flag.
+ for (const type of ['dashboard', 'report', 'view', 'page']) {
expect(await tileWord(type)).toBe('可定制');
}
- expect(await tileWord('page')).toBe('只读');
expect(await tileWord('api')).toBe('只读');
// The hatch opens only the items no package ships; a read-only package ships them all.
expect(await tileWord('agent')).toBe('只读');
expect(screen.queryByText('可写')).toBeNull();
expect(screen.queryByText('仅可新建')).toBeNull();
// The filter hides what the words mark read-only and counts the rest.
- expect(screen.getByRole('button', { name: '隐藏只读 (3)' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: '隐藏只读 (4)' })).toBeInTheDocument();
});
it('CONTROL — a writable package: an overlay type is 可写, a create-only type 可新增', async () => {
@@ -172,14 +176,18 @@ describe('the list header draws the tile word (objectstack#29705)', () => {
);
}
- it('a read-only package: the view list header reads 可定制, the page list header 只读', () => {
+ it('a read-only package: the view and page list headers read 可定制, the api list header 只读', () => {
renderHeader('view', true);
expect(within(document.querySelector('h1')!.parentElement!).getByText('可定制')).toHaveAttribute(
'title',
ZH['engine.studio.customize.availableTitle'],
);
cleanup();
+ // objectstack#30062: the page's Customize door (objectstack#30063) moves its header with it.
renderHeader('page', true);
+ expect(within(document.querySelector('h1')!.parentElement!).getByText('可定制')).toBeInTheDocument();
+ cleanup();
+ renderHeader('api', true);
expect(within(document.querySelector('h1')!.parentElement!).getByText('只读')).toBeInTheDocument();
});
@@ -244,9 +252,10 @@ describe('the state vocabulary census (objectstack#29705)', () => {
it('typeStateWord: the mapping in every context', () => {
const [dashboard, , , page, api, agent] = ENTRIES;
+ // objectstack#30062: a page has a Customize door (objectstack#30063), so it reads customizable.
expect([dashboard, page, api, agent].map((e) => typeStateWord(e, true))).toEqual([
'customizable',
- 'readOnly',
+ 'customizable',
'readOnly',
'readOnly',
]);
@@ -394,3 +403,60 @@ describe('one word per state, the second pass (objectstack#29729)', () => {
expect(EN['perm.badge.package']).toBe('Package');
});
});
+
+/**
+ * objectstack#30062 — the tile and the door agree for every type: on a
+ * read-only package a type's tile (and its list header) reads 可定制 exactly
+ * when Studio offers Customize on its items — the Interfaces pillar's header
+ * (`STUDIO_CUSTOMIZE_TYPES`, a page since objectstack#30063), the All metadata
+ * area's form editor (`AREA_FORM_EDITOR_TYPES`) or customize editor
+ * (`AREA_CUSTOMIZE_EDITOR_TYPES`, a dataset since objectstack#30064), or the
+ * flow header — whatever the type's overlay flag says. Before, a tile read
+ * `allowEnvironmentOverlay`, false for a flow, an app, a page and a dataset,
+ * so their tiles read 只读 while their doors offered 定制.
+ *
+ * ⚠ A door opened later moves its type here with it: these rows derive from
+ * the door lists, and a type with a hard-coded word above moves in the change
+ * that opens its door.
+ */
+describe('the tile and the Customize door agree for every type (objectstack#30062)', () => {
+ const DOORS = new Set([...STUDIO_CUSTOMIZE_TYPES, ...AREA_FORM_EDITOR_TYPES, ...AREA_CUSTOMIZE_EDITOR_TYPES, 'flow']);
+
+ it.each([...OVERRIDABLE_METADATA_TYPES, 'permission', 'object', 'api'])(
+ '%s: 可定制 on a read-only package exactly when a door offers Customize, whatever its overlay flag',
+ (type) => {
+ const offered = DOORS.has(type) && OVERRIDABLE_METADATA_TYPES.includes(type as never);
+ expect(studioOffersCustomize(type)).toBe(offered);
+ for (const overlay of [true, false]) {
+ expect(typeStateWord(entry(type, overlay, true), true), `${type}, allowEnvironmentOverlay ${overlay}`).toBe(
+ offered ? 'customizable' : 'readOnly',
+ );
+ }
+ },
+ );
+
+ it('the flow and the app, whose overlay flag is off, read 可定制 as their headers offer 定制', () => {
+ expect(typeStateWord(entry('flow', false, true), true)).toBe('customizable');
+ expect(typeStateWord(entry('app', false, true), true)).toBe('customizable');
+ // The page and dataset doors (objectstack#30063, objectstack#30064) move their tiles too.
+ expect(typeStateWord(entry('page', false, true), true)).toBe('customizable');
+ expect(typeStateWord(entry('dataset', false, true), true)).toBe('customizable');
+ // CONTROL: a type with no door stays 只读 (a doc, a mapping), and a writable package keeps its words.
+ expect(typeStateWord(entry('doc', false, true), true)).toBe('readOnly');
+ expect(typeStateWord(entry('mapping', false, true), true)).toBe('readOnly');
+ expect(typeStateWord(entry('flow', false, true), false)).toBe('addable');
+ });
+
+ it("a read-only package's flow list header reads 可定制", () => {
+ render(
+
+
+
+
+
+
+ ,
+ );
+ expect(within(document.querySelector('h1')!.parentElement!).getByText('可定制')).toBeInTheDocument();
+ });
+});
diff --git a/ui/packages/app-shell/src/views/metadata-admin/PageShell.tsx b/ui/packages/app-shell/src/views/metadata-admin/PageShell.tsx
index c1b85a6aed7..0d3d0440e47 100644
--- a/ui/packages/app-shell/src/views/metadata-admin/PageShell.tsx
+++ b/ui/packages/app-shell/src/views/metadata-admin/PageShell.tsx
@@ -24,6 +24,8 @@ import type { RichMetadataTypeEntry } from './useMetadata.js';
import { useMetadataLocale, t, translateMetadataType, translateMetadataDomain } from './i18n.js';
import { environmentScopeSuffix } from './catalog-scope.js';
import { useMetadataAreaHost } from './metadataAreaHost.js';
+import { isStudioCustomizeType } from '../studio-design/StudioCustomizeControls.js';
+import { AREA_CUSTOMIZE_EDITOR_TYPES, AREA_FORM_EDITOR_TYPES } from '../studio-design/metadataArea.js';
export interface PageShellProps {
/** The type entry from `/meta/types` (or a synthesized stub). */
@@ -71,8 +73,8 @@ export interface PageShellProps {
*
* - `readOnly` — nothing is written here: the type opens no runtime door, or
* the package is read-only and the type takes no customization;
- * - `customizable` — a read-only package's overlay type that the spec's
- * classification lets an override replace: a change is saved in the
+ * - `customizable` — a read-only package's type whose items Studio offers
+ * Customize on ({@link studioOffersCustomize}): a change is saved in the
* deployment's customization package (ADR-0146 D2, D3);
* - `writable` — an overlay type of a package this screen writes to;
* - `addable` — a create-only type of a package this screen writes to: new
@@ -93,16 +95,36 @@ export const TYPE_STATE_WORD_KEY: Readonly> = Obje
addable: 'engine.studio.customize.addable',
});
+/**
+ * objectstack#30062 — whether Studio offers Customize on a read-only base's
+ * item of `type`, read from the doors that offer it, never from the type's
+ * overlay flag: the Interfaces pillar's header ({@link isStudioCustomizeType}),
+ * the All metadata area's form editor ({@link AREA_FORM_EDITOR_TYPES}) and
+ * its customize editor on a read-only package ({@link AREA_CUSTOMIZE_EDITOR_TYPES},
+ * a dataset, objectstack#30064), and the Automations pillar's flow header
+ * (objectstack#28570). A door added to one of those lists moves its type's
+ * tile with it.
+ *
+ * Before, a tile read `allowEnvironmentOverlay`, which is false for a flow and
+ * an app, so their tiles read 只读 on a read-only package while their headers
+ * offered 定制.
+ */
+export function studioOffersCustomize(type: string | undefined): boolean {
+ if (!type || !isOverridableMetadataType(type)) return false;
+ return (
+ isStudioCustomizeType(type) ||
+ AREA_FORM_EDITOR_TYPES.includes(type) ||
+ AREA_CUSTOMIZE_EDITOR_TYPES.includes(type) ||
+ type === 'flow'
+ );
+}
+
/** The {@link TypeStateWord} of `entry`'s items, on a package that is read-only or not. */
export function typeStateWord(
entry: Pick | undefined,
packageReadOnly: boolean,
): TypeStateWord {
- if (packageReadOnly) {
- return entry?.allowEnvironmentOverlay && entry.overrideSource !== 'env' && isOverridableMetadataType(entry.type)
- ? 'customizable'
- : 'readOnly';
- }
+ if (packageReadOnly) return studioOffersCustomize(entry?.type) ? 'customizable' : 'readOnly';
if (entry?.allowEnvironmentOverlay) return 'writable';
if (entry?.allowRuntimeCreate) return 'addable';
return 'readOnly';