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';