diff --git a/.changeset/28576-packageless-overlay-write-closed.md b/.changeset/28576-packageless-overlay-write-closed.md new file mode 100644 index 00000000000..d795b3d7ca1 --- /dev/null +++ b/.changeset/28576-packageless-overlay-write-closed.md @@ -0,0 +1,52 @@ +--- +'@objectstack/metadata-protocol': minor +'@objectstack/spec': patch +'@objectstack/skills': patch +'@object-ui/app-shell': patch +--- + +fix(metadata-protocol)!: a write naming no package onto an installed package's view, dashboard, report, translation or email template is refused where `os migrate packageless-overlays` can move its row, naming the base's `.custom` pair and the command + +Clause-②: yes (narrowing) — the metadata save, publish, delete and rollback doors refuse a write that names no package onto an item of the five overlay types that one installed read-only package ships, where the ceremony would classify the row (or the first write's body) `movable` and its move can succeed; they accepted it before as a package-unbound Setup row. Every row the ceremony lists and does not move keeps that write. One write is widened: a rollback of the `.custom` pair's own declared row of an item its base ships (#28644 item 1) is judged as the pair's declared override, where the store sealed it as an artifact override before, on every type the pair can override (a page or flow pair row included). + +Metadata-protocol: narrowed (the `allowEnvironmentOverlay` flag of `view`, `dashboard`, `report`, `translation` and `email_template` no longer opens a package-less write onto a packaged item the ceremony can move; it stays the predicate the migration ceremony, the read of an unmigrated row and every row the ceremony leaves use) + +Skills-docs: updated here — `skills/objectstack-platform/SKILL.md`'s ADR-0146 paragraph said "a package-unbound Setup row still wins over the base's until D11"; it now says such a row from before D11 wins over the base's until `os migrate packageless-overlays` moves it, and that its package-less save, reset and rollback are refused once the command can move it. To stay under the bundle's token ceiling, the same paragraph drops "the base's is unchanged" after the export sentence (which "replaces the base's document whole" already implies) and the aside that the other overridable types wait for their doors (the sentence already names the six types the read honours today). + + + +**BREAKING** write narrowing at the metadata doors (`@objectstack/metadata-protocol`), shipped as `minor` during the launch window. + +**Upgrade, then run the ceremony.** The command ships in this release. On each deployment with Setup customizations of a package's items, upgrade, run `os migrate packageless-overlays` (preview) with the flags and environment the deployment boots with (`--preset` and `--dev` mean what they mean to `os serve`), then `os migrate packageless-overlays --apply`. Nothing breaks at the upgrade: between the upgrade and `--apply`, every customization is served as before, and the package-less save, reset, publish and rollback of one the command can move are refused, naming the command. Done when the preview lists no movable row. + +The preview lists five dispositions; only `movable` rows are moved, and every other row keeps its package-less write: + +- `movable`: `--apply` moves it into the pair. A move that ends `failed` keeps the row as it was; fix what the error names and re-run. When the error names the pair (it cannot be ensured, or it refuses the row) or the base's missing version, the row also keeps its package-less write and may be left. +- `multi_shipped` (several packages ship the name): delete it naming no package, then save it into the pair of the package it customizes if it is still wanted (the pair's override is then served at that base's address, `?package=`; a read naming no package is not pinned to it while several packages ship the name). +- `no_pair` (the base's id forms no pair): nothing to do. +- `foreign_declaration` (its `overrides` names another package): remove the key with a save naming no package, then re-run: the move declares the right override. (A package-less save that keeps a corrected `overrides` is refused by the override write gate.) +- `shadowed` (the pair already holds the item; the pair's row is served when it declares the override, otherwise this row still is): nothing to do to keep the pair's declared override; or restore the package original (`DELETE …?package=.custom`) and re-run to move this one. + +### Migration: FROM → TO + +FROM, a customization of the installed package `com.acme.crm`'s view written with no package: + +```http +PUT /api/v1/meta/view/account_list +{ "name": "account_list", "label": "My accounts" } +``` + +TO, the same customization written into the base's paired `.custom`, declaring what it overrides: + +```http +PUT /api/v1/meta/view/account_list?package=com.acme.crm.custom +{ "name": "account_list", "label": "My accounts", "overrides": { "package": "com.acme.crm", "basedOn": "1.4.0" } } +``` + +To restore the package original, FROM `DELETE /api/v1/meta/view/account_list` TO `DELETE /api/v1/meta/view/account_list?package=com.acme.crm.custom`. `basedOn` is the base's installed `manifest.version`. The pair is created by `os migrate packageless-overlays --apply`, or by Studio's Customize. + +- **Now refused.** A save (draft or publish mode), a publish, a delete of the active item, or a rollback whose restored row is package-less, with no `?package=` (or an empty one), of a `view`, `dashboard`, `report`, `translation` or `email_template` that exactly one installed read-only package ships, where the item's stored package-less row in that state (or, with none, the saved body) is one the ceremony classifies `movable`, the pair can be ensured (the base has a namespace and a SemVer version), and the override write gate admits that row into the pair. This covers an unmigrated `movable` row, and the first package-less write of an item the pair already holds (a moved one). Each answers `403 NOT_OVERRIDABLE`, writes nothing, and its message names the item, the shipping package, the pair (`?package=.custom`), the `overrides` declaration and `os migrate packageless-overlays`; a rollback's message says to save a version from before the move into the pair, declaring it. The metadata item page shows that message as sent when its URL holds no `?package=`. The refusal comes before every gate that reads a stored row's lock, so it is the same with or without `overrides` in the body. +- **Still open.** Every write naming `?package=.custom` (the pair's save, publish, delete and rollback, judged by the override write gate as before); a rollback of the pair's own declared row, which is judged as the pair's override; a write of a name no installed package ships (environment metadata); and every package-less write the ceremony would not move, as before this release: a name several read-only packages ship (`multi_shipped`); a base whose id forms no pair (`no_pair`); a base whose pair cannot be ensured (no namespace, or a version that is not SemVer); a stored package-less row whose `overrides` names another package (`foreign_declaration`) or beside the pair's row of the item (`shadowed`; its package-less writes are addressed to the pair's row as before, so its package-less delete restores the package original); and an `email_template` locale the base does not ship (the pair refuses it, #28777; the ceremony's move of it is #28904). Discarding a draft stays open. +- **Unchanged.** A package-less row stored before this release is still served exactly as before, by every read. The ceremony still moves it: it writes into the pair, never package-less. +- **Studio.** The read-only item notice no longer links to the package-less Setup overlay (`@object-ui/app-shell`). +- **Removed with it.** #28413's interim allowance, by which a package-less save that landed on a moved row kept that row's `overrides` block, is gone: such a save is refused like any other. diff --git a/packages/cli/src/commands/migrate/packageless-overlays.ts b/packages/cli/src/commands/migrate/packageless-overlays.ts index 485dea35dcb..8e227be0d8e 100644 --- a/packages/cli/src/commands/migrate/packageless-overlays.ts +++ b/packages/cli/src/commands/migrate/packageless-overlays.ts @@ -58,17 +58,23 @@ function reasonOf(row: CensusRow): string { switch (row.disposition) { case 'multi_shipped': return `several packages ship this name (${row.shippers.join(', ')}), so it overlays none of them for certain. ` - + 'Left as it is: move it by hand into the pair of the package it customizes, or delete it.'; + + 'Left as it is: delete it (a delete naming no package removes it while no package holds a row of ' + + 'this name), then, to keep the customization, save it into the pair of the package it customizes ' + + '(served then at that package\'s address, ?package=).'; case 'no_pair': return `'${row.base}' has no paired .custom package id, so there is nowhere to move it. Left as it is.`; case 'foreign_declaration': return `its body already carries an \`overrides\` key that does not name '${row.base}'. Left as it is: ` - + 'correct or remove the key, then re-run.'; + + 'remove the key with a save naming no package, then re-run: the move declares the right override.'; case 'shadowed': - return `'${row.pair}' already ${row.shadowDeclares ? 'overrides this item (a customization made in Studio)' : 'holds a row of this name'}, ` - + 'and that row is the one served — this row is never merged into it. Two ways out: keep the Studio ' - + `customization and delete this package-less row (id ${row.id}), or use "Restore package original" in ` - + `Studio to delete '${row.pair}''s row and re-run this command to move this one.`; + return (row.shadowDeclares + ? `'${row.pair}' already overrides this item (a customization made in Studio), and that row is the one ` + + 'served — this row is never merged into it. Left as it is: to keep the Studio customization, nothing ' + + 'is owed (this row is not served while that one stands); to make this row the customization instead, ' + : `'${row.pair}' already holds a row of this name that declares no override, so this row is still the one ` + + 'served, and the ceremony does not move it beside that row. Left as it is: to have it moved, ') + + `use "Restore package original" in Studio to delete '${row.pair}''s row, then re-run this command to ` + + 'move this one.'; default: return ''; } diff --git a/packages/metadata-protocol/src/migrations/overlay-index.ts b/packages/metadata-protocol/src/migrations/overlay-index.ts index c2b66fee61f..26b5c0a4607 100644 --- a/packages/metadata-protocol/src/migrations/overlay-index.ts +++ b/packages/metadata-protocol/src/migrations/overlay-index.ts @@ -501,6 +501,11 @@ function overlayDiscriminatorOf(type: string): { field: string; canonical: strin return ITEM_KEY_DISCRIMINATORS[PLURAL_TO_SINGULAR[type] ?? type]; } +/** The body field that names `type`'s bundle member (`email_template`: `locale`), or `undefined` for a type with none. */ +export function overlayMemberField(type: string): string | undefined { + return overlayDiscriminatorOf(type)?.field; +} + /** * The value {@link OVERLAY_MEMBER_COLUMN} holds for the member a caller names. * diff --git a/packages/metadata-protocol/src/protocol-customization-package.ts b/packages/metadata-protocol/src/protocol-customization-package.ts index 5032552e303..7dd1df75227 100644 --- a/packages/metadata-protocol/src/protocol-customization-package.ts +++ b/packages/metadata-protocol/src/protocol-customization-package.ts @@ -128,7 +128,7 @@ export interface CustomizationPackageHost { } /** The registry slice the door and the predicate read. */ -interface PairRegistry { +export interface PairRegistry { getPackage(id: string): PairPackageRow | undefined; getNamespaceOwners?(namespace: string): string[]; } @@ -289,34 +289,41 @@ export async function ensureCustomizationPackage( } } -/** One judgement of the registry, and the create when the pair is absent. */ -async function ensurePairOnce( - this: CustomizationPackageHost, +/** + * [ADR-0146 D11, #28576] Why the ensure door would refuse to ensure `baseId`'s + * pair on this registry now, or `undefined` when it would answer the existing + * pair or create it. ONE reading of the door's registry-decidable refusals: + * {@link ensurePairOnce} throws the error this answers, and the close of the + * package-less write (`packagelessOverlayWriteRefusal`) leaves a name's + * package-less write open where it answers one — `os migrate + * packageless-overlays` could not move such a row (its step 1 fails). Pure + * over the registry; it writes nothing. + */ +export function customizationPairEnsureRefusal( + engine: ObjectStackProtocolImplementation['engine'], registry: PairRegistry, baseId: string, pairId: string, -): Promise { +): Error | undefined { const base = registry.getPackage(baseId); if (!base) { - throw refusal(404, `Package '${baseId}' not found: a paired customization package is ensured for an installed ` + return refusal(404, `Package '${baseId}' not found: a paired customization package is ensured for an installed ` + 'base.'); } // ADR-0070 D2's one predicate. A writable base is the customer's own and // takes their changes directly; the pair exists for a base the customer // cannot author into. - if (isWritablePackage(this.engine, baseId)) { - throw refusal(409, `Package '${baseId}' is writable: customize it directly. A paired customization package ` + if (isWritablePackage(engine, baseId)) { + return refusal(409, `Package '${baseId}' is writable: customize it directly. A paired customization package ` + `('${pairId}') holds the changes to a read-only base — one provided by code or an installed app ` + '(ADR-0146 D3).'); } const namespace = customizationNamespace(base.manifest?.namespace); if (!namespace.ok) { - throw refusal(409, `Cannot ensure '${pairId}': ${noPairNamespaceClause(baseId, base.manifest?.namespace, namespace.reason)} ` + return refusal(409, `Cannot ensure '${pairId}': ${noPairNamespaceClause(baseId, base.manifest?.namespace, namespace.reason)} ` + '(ADR-0146 D3).'); } - - // Idempotent: an existing pair is returned unchanged, or refused naming - // what it lacks — ⛔ never adopted, never rewritten. + // An existing pair is returned unchanged, or refused naming what it lacks. const existing = registry.getPackage(pairId); if (existing) { const lacks: string[] = []; @@ -326,24 +333,16 @@ async function ensurePairOnce( } const declaredRange = declaredRangeOn(existing.manifest, baseId); if (declaredRange === undefined) lacks.push(`a dependency on '${baseId}' in manifest.dependencies`); - if (lacks.length > 0 || declaredRange === undefined) { - throw refusal(409, `Package '${pairId}' is installed but is not the paired customization package of ` + if (lacks.length > 0) { + return refusal(409, `Package '${pairId}' is installed but is not the paired customization package of ` + `'${baseId}': it lacks ${lacks.join(', and ')}. It is not adopted or rewritten — delete it, or ` + 'install a manifest over it that declares both, then ensure the pair again (ADR-0146 D3).'); } - return { - basePackageId: baseId, - packageId: pairId, - namespace: namespace.value, - dependencyRange: declaredRange, - created: false, - writable: isWritablePackage(this.engine, pairId), - }; + return undefined; } - const range = customizationDependencyRange(base.manifest?.version); if (!range.ok) { - throw refusal(409, `Cannot ensure '${pairId}': package '${baseId}' is at version ` + return refusal(409, `Cannot ensure '${pairId}': package '${baseId}' is at version ` + `'${String(base.manifest?.version)}', which is not a SemVer 2.0.0 version, so the pair's dependency ` + 'range — the major of the base\'s version — cannot be declared (ADR-0146 D3).'); } @@ -352,12 +351,44 @@ async function ensurePairOnce( // would read. const owner = registry.getNamespaceOwners?.(namespace.value)?.find((id) => id !== pairId); if (owner !== undefined) { - throw refusal(422, `Cannot ensure '${pairId}': its namespace '${namespace.value}' is already owned by package ` + return refusal(422, `Cannot ensure '${pairId}': its namespace '${namespace.value}' is already owned by package ` + `'${owner}'. The pair's namespace is the base's followed by '_custom' and has no alternative; uninstall ` + `'${owner}' or move it off that namespace, then ensure the pair again (ADR-0146 D3).`, 'NAMESPACE_CONFLICT'); } + return undefined; +} +/** One judgement of the registry, and the create when the pair is absent. */ +async function ensurePairOnce( + this: CustomizationPackageHost, + registry: PairRegistry, + baseId: string, + pairId: string, +): Promise { + // Every refusal the registry decides, read once ({@link customizationPairEnsureRefusal}). + const refused = customizationPairEnsureRefusal(this.engine, registry, baseId, pairId); + if (refused) throw refused; + const base = registry.getPackage(baseId)!; + const namespace = customizationNamespace(base.manifest?.namespace); + if (!namespace.ok) throw new Error('unreachable: the namespace was judged above'); + + // Idempotent: an existing pair is returned unchanged — ⛔ never adopted, + // never rewritten (its refusal is above). + const existing = registry.getPackage(pairId); + if (existing) { + return { + basePackageId: baseId, + packageId: pairId, + namespace: namespace.value, + dependencyRange: declaredRangeOn(existing.manifest, baseId)!, + created: false, + writable: isWritablePackage(this.engine, pairId), + }; + } + + const range = customizationDependencyRange(base.manifest?.version); + if (!range.ok) throw new Error('unreachable: the version was judged above'); const baseName = typeof base.manifest?.name === 'string' && base.manifest.name !== '' ? base.manifest.name : baseId; const manifest = { id: pairId, diff --git a/packages/metadata-protocol/src/protocol-environment-rows.ts b/packages/metadata-protocol/src/protocol-environment-rows.ts index 343ebc5f831..5c30656f05b 100644 --- a/packages/metadata-protocol/src/protocol-environment-rows.ts +++ b/packages/metadata-protocol/src/protocol-environment-rows.ts @@ -241,6 +241,24 @@ export function shippingPackageIds(shipped: readonly unknown[]): ReadonlySet boolean, +): string[] { + return [...shippingPackageIds(shipped)].filter((id) => !isWritablePackage(id)).sort(); +} + /** * [#28409, ADR-0146 D8 items 1–2] The one code package that ships a name, or * `undefined` when none or several do: the case in which diff --git a/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts b/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts index 7cb9b02e329..e1ee7c89799 100644 --- a/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts +++ b/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts @@ -208,6 +208,58 @@ function sealedDeclareOrRenameClause(type: string, basePackageId: unknown, write return `Declare an override from '${pair.value}' or rename it to customize it`; } +// ───────────────────────────────────────────────────────────────────────── +// ADR-0146 D11 — the package-less write onto a packaged item is closed +// ───────────────────────────────────────────────────────────────────────── + +/** The write a {@link packagelessOverlayWriteRefusal} judges: the four package-less writes ADR-0146 D11 closes. */ +export type PackagelessWriteOperation = 'save' | 'publish' | 'delete' | 'rollback'; + +/** + * The refusal `ObjectStackProtocolImplementation#packagelessOverlayWriteRefusal` + * throws; the method's docblock there is its contract. + * + * [ADR-0146 D11] A write naming no package (`packageId` absent, `null` or + * empty) onto an item of one of the five overlay types that one read-only code + * package ships, whose row `os migrate packageless-overlays` would move into + * the base's pair (`packagelessCloseSubject`, the ceremony's own reading), is + * refused `403 NOT_OVERRIDABLE` — the code and status the sealed write already + * answers, so no door gains a code. Every row the ceremony leaves keeps its + * package-less write, so the sentence's two doors are always open: the pair + * (`?package=.custom`), where the customization is a declared override, + * and the ceremony, which moves a row stored before the close there. Such a + * row is still served until it is moved (the read path is not this + * function's); only its package-less write is refused. + * + * The fix first, then the rule and the ceremony: the REST door bounds a + * refusal's text (`CLIENT_MESSAGE_MAX`, 500 characters, `@objectstack/types`), + * and the metadata item page shows that text as sent. + */ +export function packagelessOverlayWriteRefusal( + request: { type: string; name: string }, + closed: { base: string; pair: string }, + operation: PackagelessWriteOperation, +): Error { + const type = PLURAL_TO_SINGULAR[request.type] ?? request.type; + const item = `'${type}/${request.name}'`; + const shipped = `ships in '${closed.base}'`; + const pair = `'${closed.pair}' (?package=${closed.pair})`; + const declared = `overrides: { package: '${closed.base}', basedOn }`; + const rule = 'A write naming no package is closed where `os migrate packageless-overlays` can move its row ' + + `(ADR-0146 D11); a package-less row stored before it is served until that command moves it into '${closed.pair}'.`; + const message = operation === 'rollback' + ? `${item} ${shipped}, and a rollback of its package-less row is closed (ADR-0146 D11): run ` + + `\`os migrate packageless-overlays\` to move the row into ${pair}; to restore a version from before ` + + `the move, save that version's body there, declaring ${declared}.` + : operation === 'delete' + ? `${item} ${shipped}: remove its customization from ${pair}. ${rule}` + : `${item} ${shipped}: ${operation === 'publish' ? 'publish' : 'save'} it into ${pair}, declaring ${declared}. ${rule}`; + const err = new Error(message); + (err as any).code = 'NOT_OVERRIDABLE'; + (err as any).status = 403; + return err; +} + // ───────────────────────────────────────────────────────────────────────── // ADR-0146 D2 — the override write gate (ruling A on decision card #28390) // ───────────────────────────────────────────────────────────────────────── diff --git a/packages/metadata-protocol/src/protocol-packageless-overlay-migration.ts b/packages/metadata-protocol/src/protocol-packageless-overlay-migration.ts index 083d33d4f6f..517eb068aa7 100644 --- a/packages/metadata-protocol/src/protocol-packageless-overlay-migration.ts +++ b/packages/metadata-protocol/src/protocol-packageless-overlay-migration.ts @@ -88,9 +88,11 @@ import { } from '@objectstack/spec/kernel'; import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL } from '@objectstack/spec/shared'; import { storedRowDocument } from './item-lock.js'; -import { OVERLAY_MEMBER_COLUMN, overlayMemberValue } from './migrations/overlay-index.js'; +import { OVERLAY_MEMBER_COLUMN, overlayMemberField, overlayMemberOfBody, overlayMemberValue } from './migrations/overlay-index.js'; import type { ObjectStackProtocolImplementation } from './protocol.js'; -import { shippingPackageIds } from './protocol-environment-rows.js'; +import { customizationPairEnsureRefusal, type PairRegistry } from './protocol-customization-package.js'; +import type { OverrideWriteSubject, OverrideWriteVerdict } from './protocol-packaged-base-refusal.js'; +import { readOnlyShippingPackages } from './protocol-environment-rows.js'; import { isServedOverrideRowOf } from './served-override.js'; import { SysMetadataRepository } from './sys-metadata-repository.js'; @@ -258,6 +260,37 @@ function baseVersionOf(host: Pick, ba return typeof version === 'string' && version !== '' ? version : undefined; } +/** + * [#28576] How the census classifies ONE package-less row of a name some + * read-only code package ships (module header, "The population"), from the + * shippers ({@link readOnlyShippingPackages}), the row's body and the pair's + * row of the same item, in either state (`shadowOf`). The one classification: + * {@link listPackagelessOverlays} lists by it, and the close of the + * package-less write ({@link packagelessCloseSubject}) refuses only a row it + * answers `movable` — ⛔ never a second copy. + */ +export function classifyPackagelessRow( + shippers: readonly string[], + body: Record | undefined, + shadowOf: (pair: string) => unknown, +): Pick { + if (shippers.length > 1) return { disposition: 'multi_shipped' }; + const base = shippers[0]!; + const pairId = customizationPackageId(base); + if (!pairId.ok) return { base, disposition: 'no_pair' }; + const pair = pairId.value; + const declared = (body as { overrides?: unknown } | undefined)?.overrides; + if (declared !== undefined && (declared === null || typeof declared !== 'object' + || (declared as { package?: unknown }).package !== base)) { + return { base, pair, disposition: 'foreign_declaration' }; + } + const shadow = shadowOf(pair); + if (shadow !== undefined) { + return { base, pair, disposition: 'shadowed', shadowDeclares: isServedOverrideRowOf(shadow, base) }; + } + return { base, pair, disposition: 'movable' }; +} + /** * The census (module header, "The population"): every package-less overlay * row classified, and every pair row whose declaration lacks `basedOn`. @@ -298,9 +331,7 @@ export async function listPackagelessOverlays( // The read-only code packages that ship the name: a writable package's // item is edited in place (ADR-0070) and has no pair (ADR-0146 D3), and // the pair itself is writable — the write gate's own narrowing. - const shippers = [...shippingPackageIds(this.shippedArtifactsOf(type, name))] - .filter((id) => !this.isWritablePackage(id)) - .sort(); + const shippers = readOnlyShippingPackages(this.shippedArtifactsOf(type, name), (id) => this.isWritablePackage(id)); if (shippers.length === 0) { environmentRows += 1; continue; @@ -314,29 +345,8 @@ export async function listPackagelessOverlays( ...(member !== undefined ? { member } : {}), shippers, }; - if (shippers.length > 1) { - listed.push({ ...common, disposition: 'multi_shipped' }); - continue; - } - const base = shippers[0]!; - const pairId = customizationPackageId(base); - if (!pairId.ok) { - listed.push({ ...common, base, disposition: 'no_pair' }); - continue; - } - const pair = pairId.value; - const declared = (bodyOf(row) as { overrides?: unknown } | undefined)?.overrides; - if (declared !== undefined && (declared === null || typeof declared !== 'object' - || (declared as { package?: unknown }).package !== base)) { - listed.push({ ...common, base, pair, disposition: 'foreign_declaration' }); - continue; - } - const shadow = shadowOf(pair, type, name, member, state); - if (shadow !== undefined) { - listed.push({ ...common, base, pair, disposition: 'shadowed', shadowDeclares: isServedOverrideRowOf(shadow, base) }); - continue; - } - listed.push({ ...common, base, pair, disposition: 'movable' }); + const classified = classifyPackagelessRow(shippers, bodyOf(row), (pair) => shadowOf(pair, type, name, member, state)); + listed.push({ ...common, ...classified }); } // Every type a declaration applies to, not only the five the move acts on. @@ -526,71 +536,112 @@ export async function migratePackagelessOverlays( } // ───────────────────────────────────────────────────────────────────────── -// Until #28576 closes the package-less write: a package-less save landing on -// a migrated row keeps that row's declaration (N-16a, Wanted 4) +// [#28576] The close reads this ceremony: only what it would move is closed // ───────────────────────────────────────────────────────────────────────── -/** The class members {@link packagelessPairLanding} reads. */ -export interface PackagelessPairLandingHost { - readonly overlayTypes: ReadonlySet; - readonly packagelessAddress: ObjectStackProtocolImplementation['packagelessAddress']; - readonly packagelessDraftAddress: ObjectStackProtocolImplementation['packagelessDraftAddress']; +/** The class members {@link packagelessCloseSubject} reads, typed as the class declares them. */ +export interface PackagelessCloseHost { + readonly engine: ObjectStackProtocolImplementation['engine']; readonly findEnvironmentRows: ObjectStackProtocolImplementation['findEnvironmentRows']; + readonly rethrowUnlessMetadataStoreUnprovisioned: ObjectStackProtocolImplementation['rethrowUnlessMetadataStoreUnprovisioned']; + /** The read-only code packages that ship a name of an overlay type ([] for any other type). */ + readonly overlayBaseShippers: (type: string, name: string) => string[]; + /** The override write gate, as the ceremony's step 2 meets it through the save door. */ + readonly overrideWriteVerdict: (request: OverrideWriteSubject) => OverrideWriteVerdict; +} + +/** The package-less write {@link packagelessCloseSubject} judges, and the row it would write. */ +export interface PackagelessWriteSubject { + readonly type: string; + readonly name: string; + /** The package the write names; a non-empty string means the write is not package-less. */ + readonly packageId?: string | null; + /** The row's state: a draft save or a publish's draft is `draft`, everything else `active`. */ + readonly state: 'active' | 'draft'; + /** The bundle member as a caller names it (absent: the canonical one). */ + readonly member?: string; + /** A save's body: the row a first write of the name would store. */ + readonly item?: unknown; } /** - * The declared override a package-less save of an overlay type lands on: when - * the save's own address — {@link ObjectStackProtocolImplementation.packagelessAddress} - * for a publish-mode save, {@link ObjectStackProtocolImplementation.packagelessDraftAddress}'s - * `into` for a draft — is a base's paired `.custom`, and that pair's row - * of the item (the draft's own row first for a draft save, then the active - * row) is the served override of the base ({@link isServedOverrideRowOf}), - * answers the pair, the base and the row's `overrides` block. Otherwise - * `undefined`: nothing changes for the save. + * [ADR-0146 D11, #28576 ruling 2] The base and pair a package-less write is + * closed onto, or `null` when it stays open. A package-less write is closed + * exactly where `os migrate packageless-overlays` would move its row and the + * move can succeed — so every customization the close reaches has a door + * after the upgrade (the ceremony, then `?package=.custom`), and every + * one the ceremony leaves keeps the package-less write it has on `main`: * - * Why the save asks it. After `os migrate packageless-overlays` moves Setup's - * customization into the pair, Setup still saves and resets it naming no - * package, and the package-less address lands that save on the pair's row - * (sole binding). Setup's form sends the item as it read it — its `overrides` - * key included, or not, depending on the editor — and a pair row saved without - * its declaration is refused by the override write gate as an undeclared same - * name (ADR-0146 D2, N-09). So the save keeps the row's declaration when the - * body carries none, and judges a body that carries one as a write into the - * pair, which is where it lands. #28576 closes the package-less write; this - * goes with it. + * - the row the write addresses is the stored package-less row of that item, + * member and state, or — when there is none — the row a save would store + * (its body), or none at all (a delete or rollback of an unstored item); + * - it is classified by the census's own {@link classifyPackagelessRow}: + * only `movable` is closed (`multi_shipped`, `no_pair`, + * `foreign_declaration` and `shadowed` stay open, and so does a name no + * read-only package ships). `shadowed` applies to a stored row only: a + * first write has nothing in the pair to be shadowed by; + * - its move must be able to succeed, read from the steps the ceremony + * takes: the ensure door would ensure the pair + * ({@link customizationPairEnsureRefusal}), the base records a version + * (`basedOn`), and the override write gate admits the declared body into + * the pair (an `email_template` member at a locale the base does not ship + * is refused there, #28777, and stays open: #28904). + * + * Registry reads first; the one store read (the item's rows) only for a name + * the registry has not already left open. */ -export async function packagelessPairLanding( - this: PackagelessPairLandingHost, - request: { type: string; name: string; mode?: string }, - member: string | undefined, -): Promise<{ pair: string; base: string; declaration: Record } | undefined> { +export async function packagelessCloseSubject( + this: PackagelessCloseHost, + request: PackagelessWriteSubject, +): Promise<{ base: string; pair: string } | null> { + if (typeof request.packageId === 'string' && request.packageId.length > 0) return null; const type = PLURAL_TO_SINGULAR[request.type] ?? request.type; - if (!this.overlayTypes.has(type)) return undefined; - const draft = request.mode === 'draft'; - let into: string | null; - if (draft) { - const address = await this.packagelessDraftAddress(type, request.name, member); - if (address.kind === 'ambiguous') return undefined; - into = address.into; - } else { - const address = await this.packagelessAddress(type, request.name, member, 'active'); - into = address.candidates.length === 1 ? address.packageId : null; - } - const base = typeof into === 'string' ? customizationBasePackageId(into) : null; - if (base === null || into === null) return undefined; - const rows = await this.findEnvironmentRows('sys_metadata', { - where: { type, name: request.name, package_id: into }, - context: SYSTEM, - }); - // The member as the row stores it: `null` (absent here) for the canonical one. - const storedMember = overlayMemberValue(type, member) ?? undefined; - const at = (state: 'active' | 'draft') => - (rows ?? []).find((row) => row?.state === state && memberOf(row) === storedMember); - for (const row of draft ? [at('draft'), at('active')] : [at('active')]) { - if (row === undefined || !isServedOverrideRowOf(row, base)) continue; - const declaration = (bodyOf(row) as { overrides?: unknown } | undefined)?.overrides; - if (declaration === null || typeof declaration !== 'object' || Array.isArray(declaration)) continue; - return { pair: into, base, declaration: { ...(declaration as Record) } }; + const shippers = this.overlayBaseShippers(request.type, request.name); + if (shippers.length !== 1) return null; // environment metadata, or `multi_shipped` + const base = shippers[0]!; + const pairId = customizationPackageId(base); + if (!pairId.ok) return null; // `no_pair` + const pair = pairId.value; + const registry = (this.engine as { registry?: PairRegistry } | undefined)?.registry; + if (!registry || typeof registry.getPackage !== 'function') return null; + if (customizationPairEnsureRefusal(this.engine, registry, base, pair) !== undefined) return null; // step 1 fails + const basedOn = baseVersionOf(this, base); + if (basedOn === undefined) return null; // no `basedOn` to record + + // The item's stored rows, both spellings, every package: the census's read. + const spellings = [...new Set([type, SINGULAR_TO_PLURAL[type] ?? type])]; + let rows: any[] = []; + try { + rows = (await this.findEnvironmentRows('sys_metadata', { + where: { type: { $in: spellings }, name: request.name }, + context: SYSTEM, + })) ?? []; + } catch (error) { + this.rethrowUnlessMetadataStoreUnprovisioned(error, 'sys_metadata'); } - return undefined; + const member = request.item !== undefined && request.member === undefined + ? overlayMemberOfBody(type, request.item) + : overlayMemberValue(type, request.member); + const sameMember = (row: any) => member === undefined || ((row?.[OVERLAY_MEMBER_COLUMN] ?? null) === member); + const ofItem = rows.filter((row) => sameMember(row) && stateOf(row) !== undefined); + const stored = ofItem.find((row) => isUnbound(row) && stateOf(row) === request.state); + const storedBody = stored === undefined ? undefined : bodyOf(stored); + const writtenBody = request.item !== null && typeof request.item === 'object' && !Array.isArray(request.item) + ? (request.item as Record) : undefined; + const body = stored !== undefined ? storedBody : writtenBody; + const classified = classifyPackagelessRow(shippers, body, (p) => (stored === undefined + ? undefined + : ofItem.find((row) => row.package_id === p && stateOf(row) === request.state) + ?? ofItem.find((row) => row.package_id === p))); + if (classified.disposition !== 'movable') return null; + + // Step 2's gate: the declared body, into the pair. + const disc = member === undefined ? undefined : overlayMemberField(type); + const declaredBody = { + ...(body ?? { name: request.name, ...(disc && member !== null ? { [disc]: member } : {}) }), + overrides: { package: base, basedOn }, + }; + const verdict = this.overrideWriteVerdict({ type, name: request.name, packageId: pair, item: declaredBody }); + if (verdict.verdict === 'refuse') return null; + return { base, pair }; } diff --git a/packages/metadata-protocol/src/protocol.package-door-before-gates.test.ts b/packages/metadata-protocol/src/protocol.package-door-before-gates.test.ts index a6226a4d961..dbfe1d183c4 100644 --- a/packages/metadata-protocol/src/protocol.package-door-before-gates.test.ts +++ b/packages/metadata-protocol/src/protocol.package-door-before-gates.test.ts @@ -50,7 +50,12 @@ import { resetEnvWritableMetadataTypes } from './sys-metadata-repository.js'; * declares no dependencies, as an authored base does. */ const INSTALLED_PACKAGES: ReadonlySet = new Set(['com.example.pkg']); -const installedPackage = (id: string) => (INSTALLED_PACKAGES.has(id) ? { manifest: { id } } : undefined); +// [#28576] With a namespace and a SemVer version, so a base this file ships +// from could have its pair ensured: the close of the package-less write +// applies to its items (the ceremony could move their rows). +const installedPackage = (id: string) => (INSTALLED_PACKAGES.has(id) + ? { manifest: { id, namespace: 'pkg', version: '1.0.0' } } + : undefined); const PACKAGE_ID = 'com.example.pkg'; @@ -285,12 +290,16 @@ describe('[#22220] controls: the gates still judge what the door does not refuse } }); - it('a packaged item of a type that allows overlays (view) with a body the spec parse refuses → 422 INVALID_METADATA (both kernels)', async () => { + it('[#28576, ADR-0146 D11] a packaged item of a type that allows overlays (view), saved naming no package, with a body the spec parse refuses → 403 NOT_OVERRIDABLE (both kernels)', async () => { + // Was a control (422 INVALID_METADATA): the overlay type's package-less + // write reached the gates. That write onto a packaged item is closed + // now, so the door answers first, whatever the body. for (const [kernel, environmentId] of KERNELS) { const result = await probe(environmentId, { type: 'view', name: 'pkg_view', item: specRefused({ name: 'pkg_view', label: 'Pkg' }), }); - expect(result.envelope, kernel).toEqual(INVALID_METADATA); + expect(result.envelope, kernel).toEqual(NOT_OVERRIDABLE); + expect(result.gateReached, kernel).toBe(false); expect(result.persistedRows, kernel).toBe(0); } }); 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 e8795858102..14ea8513ca4 100644 --- a/packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts +++ b/packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts @@ -491,9 +491,12 @@ describe('the override write gate — only the base\'s paired .custom declares a const PARTNER = 'com.acme.partner'; const OTHER_PAIR = 'com.zeta.custom'; const UNRELATED = 'com.other'; - const MANIFESTS: Record; version: string }> = { - [BASE]: { dependencies: {}, version: '3.2.0' }, - [PAIR]: { dependencies: { [BASE]: '^3' }, version: '0.1.0' }, + // [#28576] The base and its pair declare the namespaces the ensure door + // reads, so the pair can be ensured and the package-less write the close + // judges is one `os migrate packageless-overlays` could move. + const MANIFESTS: Record; version: string; namespace?: string }> = { + [BASE]: { dependencies: {}, version: '3.2.0', namespace: 'acme' }, + [PAIR]: { dependencies: { [BASE]: '^3' }, version: '0.1.0', namespace: 'acme_custom' }, [PARTNER]: { dependencies: { [BASE]: '^3' }, version: '1.0.0' }, // A pair of ANOTHER base that also depends on this one: the former // "second declarer" — not the base's pair, so it may not declare. @@ -669,10 +672,13 @@ describe('the override write gate — only the base\'s paired .custom declares a expect(stored()).toEqual([{ type: 'view', name: 'task.crm_list', package_id: UNRELATED, state: 'active' }]); }); - it('control: a package-less overlay row is not this gate\'s question (the migration card closes it)', async () => { + it('[#28576, ADR-0146 D11] a package-less save is not this gate\'s question: the close refuses it first, naming the pair, and nothing is stored', async () => { const { protocol, stored } = gateProtocol(); - await protocol.saveMetaItem({ type: 'view', name: 'task.crm_list', item: body('view', 'task.crm_list') }); - expect(stored()).toEqual([{ type: 'view', name: 'task.crm_list', package_id: null, state: 'active' }]); + const e = await thrown(protocol.saveMetaItem({ type: 'view', name: 'task.crm_list', item: body('view', 'task.crm_list') })); + expect(shape(e)).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 }); + expect(String((e as Error).message)).toContain(`'${PAIR}' (?package=${PAIR})`); + expect(String((e as Error).message)).toContain('os migrate packageless-overlays'); + expect(stored()).toEqual([]); }); // ── the admit ──────────────────────────────────────────────────────────── diff --git a/packages/metadata-protocol/src/protocol.packageless-save-address.test.ts b/packages/metadata-protocol/src/protocol.packageless-save-address.test.ts index 52cd0e78f8e..02f7e1e9001 100644 --- a/packages/metadata-protocol/src/protocol.packageless-save-address.test.ts +++ b/packages/metadata-protocol/src/protocol.packageless-save-address.test.ts @@ -35,9 +35,10 @@ * - CONTROL: a package-less row minted beside a package's row before the fix * stays as it is, and a server-stated rewrite of that row (the * stored-metadata migration passes `packageId: null`) still writes it; - * - an item a code package ships, with no stored row: the package-less save - * writes the package-unbound overlay and the read serves it, as before; with - * an overlay row already bound to the code package, it writes that row; + * - [#28576, ADR-0146 D11] an item a code package ships: the package-less save + * is refused naming the base's pair and the migration ceremony, with no + * stored row or with an overlay row bound to the code package, and writes + * nothing; * - the draft path: a package-less draft save still inherits the package * (its own suite, `sys-metadata-repository.draft-package-inherit.test.ts`, * holds the rest). @@ -158,9 +159,13 @@ function makeProtocol(artifacts: Array<{ type: string; item: Record (id === INSTALLED_READ_ONLY ? { manifest: { id, scope: 'cloud' } } - : [WRITABLE, OTHER_WRITABLE, 'com.qa.thirdapp', CODE_PACKAGE].includes(id) ? { manifest: { id } } : undefined), + : id === CODE_PACKAGE ? { manifest: { id, namespace: 'showcase', version: '0.1.0' } } + : [WRITABLE, OTHER_WRITABLE, 'com.qa.thirdapp'].includes(id) ? { manifest: { id } } : undefined), isPackageDisabled: () => false, applyNavContributions: (app: unknown) => app, }, @@ -419,21 +424,30 @@ describe('CONTROL: what the resolution leaves alone', () => { }); }); -describe('an item a CODE package ships', () => { - it('with no stored row: the package-less save writes the package-unbound overlay, and the read serves it', async () => { +describe('an item a CODE package ships: the package-less write is closed (ADR-0146 D11)', () => { + const PAIR = `${CODE_PACKAGE}.custom`; + const closed = (e: unknown) => { + expect(resolveThrownHttpError(e)).toMatchObject({ status: 403, code: 'NOT_OVERRIDABLE' }); + expect(String((e as Error).message)).toContain(`'${PAIR}'`); + expect(String((e as Error).message)).toContain('os migrate packageless-overlays'); + // The REST door cuts a refusal's text at 500 characters + // (`CLIENT_MESSAGE_MAX`, `@objectstack/types`), and Setup shows it as + // sent: the whole sentence fits, the ceremony included. + expect(String((e as Error).message).length).toBeLessThan(500); + }; + + it('with no stored row: the package-less save is refused naming the pair, and nothing is written', async () => { const name = 'showcase_task_grid'; const { protocol, rows } = makeProtocol([packagedView(name)]); - const saved = await protocol.saveMetaItem({ type: 'view', name, item: viewBody(name, 'Overlaid') }); + const refused = await protocol.saveMetaItem({ type: 'view', name, item: viewBody(name, 'Overlaid') }).then(() => undefined, (e: unknown) => e); - expect(saved.message).toBe( - `Saved customization overlay (env-wide, state=active) — type=view, name=${name} [seq=${saved.seq}]`, - ); - expect(rows('view', name).map((r) => r.package_id ?? null)).toEqual([null]); - expect((await protocol.getMetaItem({ type: 'view', name })).item.label).toBe('Overlaid'); + closed(refused); + expect(rows('view', name)).toEqual([]); + expect((await protocol.getMetaItem({ type: 'view', name })).item.label).not.toBe('Overlaid'); }); - it('with an overlay row bound to the code package: the package-less save writes that row', async () => { + it('with an overlay row bound to the code package: the package-less save is refused, and that row is untouched', async () => { const name = 'showcase_task_grid'; // [#26708] The code package ships the object its view binds: a save // naming the (installed) package is judged against its closure. @@ -441,15 +455,14 @@ describe('an item a CODE package ships', () => { packagedView(name), { type: 'object', item: { name: 'task', _packageId: CODE_PACKAGE, fields: { name: { type: 'text' } } } }, ]); - // The overlay a save naming the package writes (ADR-0005: an overlay of - // a code-shipped item names the package it customizes). + // A save naming the package names a package: not the write D11 closes. await protocol.saveMetaItem({ type: 'view', name, item: viewBody(name, 'Named'), packageId: CODE_PACKAGE }); - const saved = await protocol.saveMetaItem({ type: 'view', name, item: viewBody(name, 'Unnamed') }); + const refused = await protocol.saveMetaItem({ type: 'view', name, item: viewBody(name, 'Unnamed') }).then(() => undefined, (e: unknown) => e); - expect(saved.message).toContain(`package=${CODE_PACKAGE}`); + closed(refused); expect(rows('view', name).map((r) => r.package_id ?? null)).toEqual([CODE_PACKAGE]); - expect((await protocol.getMetaItem({ type: 'view', name })).item.label).toBe('Unnamed'); + expect((await protocol.getMetaItem({ type: 'view', name })).item.label).toBe('Named'); }); }); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 9c54267af1e..063d48a6fd1 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -183,18 +183,19 @@ import { deriveNamespaceFromPackageId } from '@objectstack/spec/kernel'; // [#28409, ADR-0146 D3 / D8 item 2] The pair's id, the one spelling N-08 owns, // and the one predicate that says a stored row is the served override of a // base's name. -import { customizationPackageId } from '@objectstack/spec/kernel'; +import { customizationPackageId, isCustomizationPairOf } from '@objectstack/spec/kernel'; import { isServedOverrideRowOf, type ServedOverrideRow } from './served-override.js'; import { bindStoredRow } from './stored-row-binding.js'; import { incomingTakesStoredFlowSlot } from './stored-flow-slot.js'; import { listServedOverrides, type ServedOverrideEntry } from './protocol-served-overrides.js'; // [#28413, ADR-0146 D11] The package-less overlay migration (`os migrate -// packageless-overlays`), and the save's landing on a migrated row. +// packageless-overlays`). import { listPackagelessOverlays, migratePackagelessOverlays, - packagelessPairLanding, + packagelessCloseSubject, type PackagelessOverlayCensus, + type PackagelessWriteSubject, type PackagelessOverlayMigration, } from './protocol-packageless-overlay-migration.js'; // [#19417] The package-id declaration and its remedy text, imported to be @@ -403,9 +404,10 @@ import { originGatedRemovalRefusal, overrideWriteRefusalError, overrideWriteVerdict, + packagelessOverlayWriteRefusal, refusePackagedBaseOverride, } from './protocol-packaged-base-refusal.js'; -import type { OverrideWriteSubject, OverrideWriteVerdict } from './protocol-packaged-base-refusal.js'; +import type { OverrideWriteSubject, OverrideWriteVerdict, PackagelessWriteOperation } from './protocol-packaged-base-refusal.js'; // [ADR-0146 D10, #28564] The `__c` extension-field reservation's one judge. import { extensionFieldSuffixRefusalError, @@ -480,7 +482,7 @@ import { refuseUnmintableMetaType } from './protocol-unmintable-meta-type.js'; // [#25467 cut 33] The deployment's SDUI component manifest, and the load-time page `requires` report against it. import { reportPageRequiresAbsentAtLoad, resolveSduiManifest } from './protocol-sdui-manifest.js'; // [#25467 cut 35] The environment's own `sys_metadata` rows: the family read, the one-row read, the served row and the package-less address. -import { baseAddressServesMember, findEnvironmentRow, findEnvironmentRows, findServedOverlayRow, packagelessAddress, packagelessContentPackage, shippingPackageIds, soleShippingPackage } from './protocol-environment-rows.js'; +import { baseAddressServesMember, findEnvironmentRow, findEnvironmentRows, findServedOverlayRow, packagelessAddress, packagelessContentPackage, readOnlyShippingPackages, shippingPackageIds, soleShippingPackage } from './protocol-environment-rows.js'; // [#25467 cut 35] The stored rows of a collection, folded over the registered ones. import { foldStoredCollection } from './protocol-stored-collection-fold.js'; // [#25467 cut 35] The environment-wide package uninstall verb. @@ -10059,38 +10061,6 @@ export class ObjectStackProtocolImplementation implements return migratePackagelessOverlays.call(host, request); } - /** - * [#28413] Whether a save is a package-less AUTHORING save — the one - * {@link packagelessPairLanding} may find a pair row for: it names no - * package, is no runtime-source write or package duplicate, and carries a - * body object. - */ - private isPackagelessAuthoringSave(request: SaveMetaItemRequest): boolean { - return request.packageId == null - && request.source === undefined - && request.writeFace !== 'package-duplicate' - && request.item !== null && typeof request.item === 'object' && !Array.isArray(request.item); - } - - /** - * [#28413] The declared override a package-less save of an overlay type - * lands on, or `undefined` — see `packagelessPairLanding` in - * `protocol-packageless-overlay-migration.ts`. Asked by - * {@link saveMetaItem} before the override write gate. - */ - private packagelessPairLanding( - request: { type: string; name: string; mode?: string }, - member: string | undefined, - ): Promise<{ pair: string; base: string; declaration: Record } | undefined> { - const host: ThisParameterType = { - overlayTypes: ObjectStackProtocolImplementation.OVERLAY_ALLOWED_TYPES, - packagelessAddress: (...a) => this.packagelessAddress(...a), - packagelessDraftAddress: (...a) => this.packagelessDraftAddress(...a), - findEnvironmentRows: (...a) => this.findEnvironmentRows(...a), - }; - return packagelessPairLanding.call(host, request, member); - } - /** * The one body behind {@link getMetaItems}, * {@link getMetaItemsForExecution} and {@link getMetaItemsPerPackage}. @@ -16536,6 +16506,24 @@ export class ObjectStackProtocolImplementation implements && !ObjectStackProtocolImplementation.registryAllowsOverlay(type); } + /** + * [ADR-0146 D11] The bases of an item of one of the five overlay types + * ({@link registryAllowsOverlay}: the `allowEnvironmentOverlay` types): the + * READ-ONLY code packages that ship the name ({@link shippedArtifactsOf} + * less {@link isWritablePackage}), sorted — `[]` for every other type, and + * for a name no read-only package ships. The reading + * `os migrate packageless-overlays` classifies a package-less row by + * (`readOnlyShippingPackages`), so the close + * ({@link packagelessOverlayWriteRefusal}) and the ceremony agree on what + * is a packaged item. The flag no longer opens a package-less write onto + * one; it stays the predicate the migration reads and the read of a row not + * migrated yet honours. + */ + private overlayBaseShippers(type: string, name: string): string[] { + if (!ObjectStackProtocolImplementation.registryAllowsOverlay(type)) return []; + return readOnlyShippingPackages(this.shippedArtifactsOf(type, name), (id) => this.isWritablePackage(id)); + } + /** * [#7743] Is `(field, '.')` a field a code package ships? * @@ -17380,6 +17368,60 @@ export class ObjectStackProtocolImplementation implements return refusePackagedBaseOverride.call(host, request); } + /** + * [ADR-0146 D11, #28576 ruling 2] The package-less write onto a packaged + * item, closed where `os migrate packageless-overlays` would move it: the + * refusal of a save, a publish, a delete or a rollback that names no + * package, of an item of one of the five overlay types that ONE read-only + * code package ships ({@link overlayBaseShippers}), whose row the + * ceremony's census classifies `movable` and whose move can succeed + * (`packagelessCloseSubject` in `protocol-packageless-overlay-migration.ts`, + * the census's own classification and the ceremony's own steps). + * + * `null` — the write stays open, as on `main` — when it names a package + * (`?package=.custom` stays open, as does every other package's own + * door); when no read-only package ships the name (environment metadata); + * and for every row the ceremony lists and does not move: a name several + * packages ship (`multi_shipped`), a base with no pair id (`no_pair`), a + * stored row whose `overrides` names another package + * (`foreign_declaration`) or whose pair already holds the item + * (`shadowed`), and a move that would fail (the ensure door would refuse + * the pair, the base records no version, or the override write gate + * refuses the declared body — an `email_template` locale the base does not + * ship). Each such row keeps a write door after the upgrade: its own. + * + * The refusal names the base's paired `.custom` and the ceremony; a row + * stored before the close stays served until the ceremony moves it, and + * its package-less write is refused by the same sentence. Registry reads + * first; the one store read (the item's rows) only when the registry has + * not left the write open. + * + * Asked by {@link saveMetaItem} and {@link publishMetaItem} (authoring + * writes only: the server-stated rewrites name the row they rewrite), + * {@link deleteMetaItem}, and {@link rollbackMetaItem} (whose address is the + * restored row's own binding: package-less when that row is). The + * sentence is `packagelessOverlayWriteRefusal` in + * `protocol-packaged-base-refusal.ts`. + * + * Not the read envelope's `editable` / `deletable` ({@link packagedBaseRefusal} + * keeps its answer): those describe the item, which the pair's write still + * changes. + */ + private async packagelessOverlayWriteRefusal( + request: PackagelessWriteSubject, + operation: PackagelessWriteOperation, + ): Promise { + const host: ThisParameterType = { + engine: this.engine, + findEnvironmentRows: (...a) => this.findEnvironmentRows(...a), + rethrowUnlessMetadataStoreUnprovisioned: (...a) => this.rethrowUnlessMetadataStoreUnprovisioned(...a), + overlayBaseShippers: (...a) => this.overlayBaseShippers(...a), + overrideWriteVerdict: (...a) => this.overrideWriteVerdict(...a), + }; + const closed = await packagelessCloseSubject.call(host, request); + return closed ? packagelessOverlayWriteRefusal(request, closed, operation) : null; + } + /** * [ADR-0146 D2, ruling A on #28390] The override write gate's verdict on * one item written into one package: `admit` for the base's paired @@ -21559,32 +21601,41 @@ export class ObjectStackProtocolImplementation implements // or status changes (a view container's name collision keeps its // 400, a lock its ITEM_LOCKED). // - // [#28413] Until #28576 closes the package-less write: a package-less - // authoring save whose address is a pair row declaring the override of - // its base (a row `os migrate packageless-overlays` moved there) is the - // write into the pair it is — so Setup's save of a migrated item, which - // sends the item as it read it, is not refused. Carrying the - // declaration, it is judged here as the write into that pair; carrying - // none, the late re-judgement below keeps the row's `overrides` block - // (see `packagelessPairLanding`). The landing is read ONLY where the - // save would otherwise be refused, so no admitted save, and no refusal - // ranked before these, reads one row more (#5706's lock gate stays the - // first reader). - let overrideVerdict = this.overrideWriteVerdict(request); - if ( - overrideVerdict.verdict === 'refuse' - && overrideVerdict.rule === 'declared_outside_pair' - && this.isPackagelessAuthoringSave(request) - ) { - const landing = await this.packagelessPairLanding( - request, itemDiscriminator(PLURAL_TO_SINGULAR[request.type] ?? request.type, request.item), - ); - if (landing !== undefined) overrideVerdict = this.overrideWriteVerdict({ ...request, packageId: landing.pair }); + // [ADR-0146 D11] …and AHEAD of it, the package-less write onto a + // packaged item, closed: an authoring save naming no package, of an + // item a code package ships, on one of the five overlay types, whose + // row `os migrate packageless-overlays` would move (#28576 ruling 2: + // every row it leaves keeps this write), is refused naming the base's + // paired `.custom` and the ceremony + // ({@link packagelessOverlayWriteRefusal}). Ahead of the gate so the + // writer reads that fix, not the gate's "a row bound to no package + // carries `overrides`… rename" for the body Setup sends as it read + // it. Every other type's packaged item is sealed further down + // ({@link refusePackagedBaseOverride}). Registry reads first: the + // close's one store read (the item's rows) happens only for a name + // one read-only package ships with an ensurable pair, so #5706's lock + // gate stays the first store reader of every other name. The + // server-stated rewrites (`source` set, `package-duplicate`) name the + // row they rewrite and are not asked. + // + // This retires the migration window's allowance (a package-less save + // landing on a pair row the ceremony moved kept that row's + // declaration): such a save is this refusal now. + if (request.source === undefined && request.writeFace !== 'package-duplicate') { + const closed = await this.packagelessOverlayWriteRefusal({ + type: request.type, + name: request.name, + packageId: request.packageId, + state: request.mode === 'draft' ? 'draft' : 'active', + item: request.item, + }, 'save'); + if (closed) throw closed; } + const overrideVerdict = this.overrideWriteVerdict(request); if (overrideVerdict.verdict === 'refuse' && overrideVerdict.rule !== 'undeclared_same_name') { throw overrideWriteRefusalError(overrideVerdict); } - let declaredOverride = overrideVerdict.verdict === 'admit'; + const declaredOverride = overrideVerdict.verdict === 'admit'; if (request.source === undefined && request.writeFace !== 'package-duplicate') { const authored = await this.tenantAuthoredWriteRefusal({ type: request.type, @@ -22805,31 +22856,6 @@ export class ObjectStackProtocolImplementation implements lateOverrideVerdict = this.overrideWriteVerdict({ ...request, packageId: inheritedPackage }); } } - // [#28413] …and a package-less save carrying no declaration whose - // address is a pair row declaring the override (the early gate's - // comment above) keeps that row's `overrides` block: the body as - // persisted is the declared override it replaces, judged again. - if ( - lateOverrideVerdict.verdict === 'refuse' - && lateOverrideVerdict.rule === 'undeclared_same_name' - && namedNoPackage - // (its address may have named the pair on the request since: `namedNoPackage` is what it named) - && this.isPackagelessAuthoringSave({ ...request, packageId: null }) - && !('overrides' in (request.item as Record)) - ) { - const landing = await this.packagelessPairLanding( - request, itemDiscriminator(PLURAL_TO_SINGULAR[request.type] ?? request.type, request.item), - ); - if (landing !== undefined) { - const declared = { ...request, item: { ...(request.item as Record), overrides: landing.declaration } }; - const verdict = this.overrideWriteVerdict({ ...declared, packageId: landing.pair }); - if (verdict.verdict === 'admit') { - request = declared; - lateOverrideVerdict = verdict; - declaredOverride = true; - } - } - } if (lateOverrideVerdict.verdict === 'refuse') throw overrideWriteRefusalError(lateOverrideVerdict); const singularTypeForRepo = PLURAL_TO_SINGULAR[request.type] ?? request.type; const artifactBacked = this.isArtifactBacked(singularTypeForRepo, request.name); @@ -23987,6 +24013,20 @@ export class ObjectStackProtocolImplementation implements // residue drafts the refusal (rather than a promotion) is the ruled // direction, and `deleteMetaItem` stays open to clear them. this.refuseUngrammaticalMetaItemName(request); + // [ADR-0146 D11] A publish naming no package of a packaged item of an + // overlay type writes the active row a package-less save writes, and + // is closed with it ({@link packagelessOverlayWriteRefusal}): publish + // the draft by naming its package (`?package=.custom`). + { + const closed = await this.packagelessOverlayWriteRefusal({ + type: request.type, + name: request.name, + packageId: request.packageId, + state: 'draft', + ...memberOption(member), + }, 'publish'); + if (closed) throw closed; + } // [#8594] The refusal's own row is written HERE, by the route that owns // the (absent) transaction — see `promoteDraftForPublish`'s header. This // site has no transaction of its own, so recording it in the `catch` is @@ -28519,8 +28559,6 @@ export class ObjectStackProtocolImplementation implements // request was refused above. const repo = this.getOverlayRepo(); const artifactBacked = this.isArtifactBacked(singularType, request.name); - const intent: 'override-artifact' | 'runtime-only' = artifactBacked - ? 'override-artifact' : 'runtime-only'; const ref = { type: singularType, name: request.name, @@ -28539,6 +28577,43 @@ export class ObjectStackProtocolImplementation implements // already succeeded — the shape that ends in a `catch {}` swallowing a // real outage (#4867). const rollbackPackageId = await this.resolveOverlayPackageBinding(singularType, request.name, member); + // [ADR-0146 D11, D1] A rollback writes the row its binding names, so + // a rollback of a packaged item's package-less row (an overlay type's + // row the ceremony has not moved, or none stored, whose restore writes + // the package-unbound row) is the package-less write, closed + // ({@link packagelessOverlayWriteRefusal}): rollback, like source save + // and reset, is offered on the writable package — the base's paired + // `.custom`, where `os migrate packageless-overlays` moves the row. + { + const closed = await this.packagelessOverlayWriteRefusal( + { type: singularType, name: request.name, packageId: rollbackPackageId, state: 'active', ...memberOption(member) }, + 'rollback', + ); + if (closed) throw closed; + } + // [ADR-0146 D2] A rollback of the base's paired `.custom` row is the + // pair's declared override, as its save is: it edits no managed item, + // so the store is told a runtime-only write — never judged an artifact + // override from `isArtifactBacked` alone, which seals it on a type with + // no overlay channel (a page, a flow). The restored body is judged by + // the override write gate as that save is ({@link overrideWriteVerdict} + // into the pair): a version that declares no override is refused as + // the save is, with nothing written. + const owner = artifactBacked ? this.packagedArtifactOwner({ type: singularType, name: request.name }) : undefined; + const pairRowRollback = owner !== undefined && isCustomizationPairOf(rollbackPackageId, owner); + const intent: 'override-artifact' | 'runtime-only' = artifactBacked && !pairRowRollback + ? 'override-artifact' : 'runtime-only'; + const writeRestored = this.restoredBodyWriter(singularType, request.name, rollbackPackageId, 'protocol.rollbackMetaItem'); + const deriveRestoredBody = pairRowRollback + ? async (historyBody: unknown): Promise => { + const body = await writeRestored(historyBody); + const verdict = this.overrideWriteVerdict({ + type: singularType, name: request.name, packageId: rollbackPackageId, item: body, + }); + if (verdict.verdict === 'refuse') throw overrideWriteRefusalError(verdict); + return body; + } + : writeRestored; try { const result = await repo.restoreVersion(ref, request.toVersion, { // #4556 — NULL, not 'system', for an actor-less rollback. @@ -28552,7 +28627,8 @@ export class ObjectStackProtocolImplementation implements // rethrown below with nothing written; then [#20790] R2 — a rollback past the // credential move keeps the write-only channel's current // credential and stores none. See {@link restoredBodyWriter}. - deriveRestoredBody: this.restoredBodyWriter(singularType, request.name, rollbackPackageId, 'protocol.rollbackMetaItem'), + // [ADR-0146 D2] …and, for the pair's row, the override write gate. + deriveRestoredBody, // [#22877] The member whose lineage `toVersion` is taken from. ...memberOption(member), }); @@ -29141,6 +29217,30 @@ export class ObjectStackProtocolImplementation implements } } + // [ADR-0146 D11] The package-less removal of a packaged item's active + // row, closed with the package-less write + // ({@link packagelessOverlayWriteRefusal}): a delete naming no package, + // of an item a code package ships, on one of the five overlay types, + // whose row the ceremony would move (Setup's "Reset overlay"). Its + // customization lives in the base's paired `.custom` once moved, and + // `?package=.custom` removes it there. On every topology, as + // the save door's is, and ahead of the `_lock` gate's store read. + // + // ⛔ Not a draft discard: it changes nothing any read serves, and it is + // how a package-less draft stored before the close is cleared (the + // draft cannot be published package-less any more), including by the + // discard-all walk, which names the package each draft is listed under. + if (request.state !== 'draft') { + const closed = await this.packagelessOverlayWriteRefusal({ + type: request.type, + name: request.name, + packageId: request.packageId, + state: 'active', + ...memberOption(member), + }, 'delete'); + if (closed) throw closed; + } + // ADR-0010 L3 — lock blocks delete. [#21694] On EVERY topology, ranked // below the package removal door exactly as `saveMetaItem` ranks the // save gate below its package door (see the note there): on an diff --git a/packages/objectql/src/environment-overlay-email-locale-restart.test.ts b/packages/objectql/src/environment-overlay-email-locale-restart.test.ts index 1221edede7c..3af91d25523 100644 --- a/packages/objectql/src/environment-overlay-email-locale-restart.test.ts +++ b/packages/objectql/src/environment-overlay-email-locale-restart.test.ts @@ -26,6 +26,14 @@ * - a row stored before the member column existed (a second locale's body, * no member) is backfilled at boot, so it is answered as its own member. * + * [#28576, ADR-0146 D11] A write naming no package onto the package's + * template is closed, so the administrator writes here go into the base's + * pair (`inPair`, below), where the member semantics are the same row for + * row; a package-less row is laid down as a release before the close stored + * it (`seedPackagelessRow`), served until the ceremony moves it; a shipped + * member's package-less write is refused, and an unshipped member's, which + * the ceremony cannot move (#28904), stays open. + * * ## The harness * * `environment-overlay-five-types.test.ts`'s: the real `ObjectQL` engine and @@ -70,6 +78,8 @@ function mailManifest(): Record { // package's protection envelope (`_packageId`, `_provenance`), which a // hydrated overlay of them is grafted with. version: '1.0.0', + // [#28576] A pair is formed from the base's namespace (ADR-0146 D3). + namespace: 'acmemail', emailTemplates: [ template('en-US', 'PKG en'), template('zh-CN', 'PKG zh'), // [#22877 stage 2] A second template the package ships in `en-US` @@ -182,7 +192,59 @@ async function boot( engine, () => new Map(), kernel === 'environment' ? 'env_prod' : undefined, ); const load = await protocol.loadMetaFromDb(); - return { engine, protocol, load }; + await protocol.ensureCustomizationPackage({ basePackageId: PKG } as any); + return { engine, protocol: inPair(protocol), raw: protocol, load }; +} + +/** + * [#28576, ADR-0146 D11] A write naming no package onto the package's + * template is closed, so every administrator write in this file is the one + * that stays open: into the base's pair, `com.acme.mail.custom`, declaring + * what it overrides. This wrapper names the pair on each save, publish and + * delete that names no package, and adds the declaration to a saved body of a + * member the package ships (one it does not ship is the pair's own item) — + * the member semantics pinned below are the pair's, row for row, as they were + * the package-less row's. A write that names a package (the drafts into + * `PKG` below) is passed through untouched. + */ +const PAIR = 'com.acme.mail.custom'; +const DECLARATION = { package: PKG, basedOn: '1.0.0' }; +/** Whether the package ships this body's `(name, locale)` member. */ +const shipsMember = (item: any) => ((mailManifest().emailTemplates as any[]) ?? []) + .some((shipped) => shipped.name === (item?.name ?? NAME) && shipped.locale === item?.locale); +function inPair(protocol: ObjectStackProtocolImplementation): ObjectStackProtocolImplementation { + const named = (request: any) => request?.packageId !== undefined; + return new Proxy(protocol, { + get(target, key, receiver) { + const value = Reflect.get(target, key, receiver); + if (key === 'saveMetaItem') { + return (request: any) => (named(request) ? value.call(target, request) : value.call(target, { + ...request, + packageId: PAIR, + // [#28746] A member the package does not ship is the pair's + // own item, not an override: it declares none. + item: request.type !== 'email_template' || shipsMember(request.item) ? { ...request.item, overrides: DECLARATION } : request.item, + })); + } + if (key === 'deleteMetaItem' || key === 'publishMetaItem') { + return (request: any) => value.call(target, named(request) ? request : { ...request, packageId: PAIR }); + } + return typeof value === 'function' ? value.bind(target) : value; + }, + }); +} + +/** + * [#28576] A package-less row of the package's template as a release before + * the close stored it, written straight to the overlay store (the write door + * refuses that write now): such a row is still served by every read until + * `os migrate packageless-overlays` moves it (ADR-0146 D11). + */ +async function seedPackagelessRow(raw: ObjectStackProtocolImplementation, name: string, body: Record) { + const repo = (raw as any).getOverlayRepo(); + await repo.put({ type: 'email_template', name, org: 'env' }, body, { + parentVersion: null, actor: null, source: 'older-release', intent: 'override-artifact', packageId: null, + }); } const overlayRows = (stores: Map>>) => @@ -308,10 +370,15 @@ describe('two locales of one email template survive a restart (#22877)', () => { expect(await bareNameSubject(second.protocol)).toBe('PKG en'); expect(await servedSubjects(second.protocol)).toEqual({ 'en-US': 'PKG en', 'zh-CN': 'LEGACY zh' }); - // That locale's next save updates the backfilled row — no second row. - await second.protocol.saveMetaItem({ type: 'email_template', name: NAME, item: template('zh-CN', 'ADMIN zh') }); + // [#28576, ADR-0146 D11] That locale's next save naming no package is + // refused, naming the ceremony that moves the row; the backfilled row + // stays the one served — no second row. + const refused: any = await second.raw.saveMetaItem({ type: 'email_template', name: NAME, item: template('zh-CN', 'ADMIN zh') }) + .then(() => undefined, (e: unknown) => e); + expect(refused).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); + expect(String(refused?.message)).toContain('os migrate packageless-overlays'); expect(overlayRows(stores).map((r) => r.id)).toEqual(['legacy_zh']); - expect(await servedSubjects(second.protocol)).toEqual({ 'en-US': 'PKG en', 'zh-CN': 'ADMIN zh' }); + expect(await servedSubjects(second.protocol)).toEqual({ 'en-US': 'PKG en', 'zh-CN': 'LEGACY zh' }); }); }); @@ -704,9 +771,10 @@ async function bootWithOverlays( beforeHydration?: (engine: ObjectQL) => void, ) { const first = await boot(stores); + // [#28576] Package-less rows a release before the close stored: the + // hydration of an unmigrated row is what this section pins. for (const locale of ['en-US', 'zh-CN']) { - expect(await first.protocol.saveMetaItem({ type: T, name, item: { ...template(locale, `ADMIN ${name} ${locale}`), name } })) - .toMatchObject({ success: true }); + await seedPackagelessRow(first.raw, name, { ...template(locale, `ADMIN ${name} ${locale}`), name }); } return boot(stores, 'environment', beforeHydration); } @@ -787,39 +855,54 @@ describe('[#23074] the artifact envelope is stamped only on the member the packa }); /** - * A by-name reset of the `zh-CN` member of `name`, through the delete door, - * read back at once and after a restart: `after` is the `zh-CN` subject the - * registry and the by-name read then serve. Before the door, each of the - * registry's two heal verbs that the delete's heal + * A by-name reset of the `zh-CN` member of `name`, through the delete door + * naming no package, read back at once and after a restart. Before the + * door, each of the registry's two heal verbs that the delete's heal * (`restoreArtifactRegistryView`) calls is asked about the hydrated entry, * on a boot of its own. + * + * [#28576, ADR-0146 D11] `after` is the `zh-CN` subject the registry and + * the by-name read then serve, or `'closed'`: the ceremony moves a shipped + * member's unmigrated row, so its package-less reset is refused naming the + * ceremony and the row keeps serving its member. A member the base does + * not ship cannot be moved (the pair's save refuses it, #28777; #28904), + * so its package-less reset stays open, as before the close. */ - async function resetZh(name: string, verbs: { shadow: boolean; overlayEntry: boolean }, after: string | undefined) { + async function resetZh(name: string, verbs: { shadow: boolean; overlayEntry: boolean }, after: string | undefined | 'closed') { const shadowBoot = (await bootWithOverlays(new Map(), name)).engine; expect(shadowBoot.registry.removeRuntimeShadow(T, name, 'zh-CN'), 'removeRuntimeShadow').toBe(verbs.shadow); const overlayEntryBoot = (await bootWithOverlays(new Map(), name)).engine; expect(overlayEntryBoot.registry.removeOverlayEntry(T, name, 'zh-CN'), 'removeOverlayEntry').toBe(verbs.overlayEntry); const stores = new Map>>(); - const { engine, protocol } = await bootWithOverlays(stores, name); - expect(await protocol.deleteMetaItem({ type: T, name, member: 'zh-CN' })).toMatchObject({ success: true }); - expect(engine.registry.getItem(T, name, undefined, 'zh-CN')?.subject).toBe(after); + const { engine, protocol, raw } = await bootWithOverlays(stores, name); + const reset = raw.deleteMetaItem({ type: T, name, member: 'zh-CN' }); + let served = after; + if (after === 'closed') { + const refused: any = await reset.then(() => undefined, (e: unknown) => e); + expect(refused).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); + expect(String(refused?.message)).toContain('os migrate packageless-overlays'); + served = `ADMIN ${name} zh-CN`; + } else { + expect(await reset).toMatchObject({ success: true }); + } + expect(engine.registry.getItem(T, name, undefined, 'zh-CN')?.subject).toBe(served); expect(engine.registry.getItem(T, name, undefined, 'en-US')?.subject).toBe(`ADMIN ${name} en-US`); - expect(((await protocol.getMetaItem({ type: T, name, member: 'zh-CN' })) as any)?.item?.subject).toBe(after); + expect(((await protocol.getMetaItem({ type: T, name, member: 'zh-CN' })) as any)?.item?.subject).toBe(served); const restarted = await boot(stores); - expect(((await restarted.protocol.getMetaItem({ type: T, name, member: 'zh-CN' })) as any)?.item?.subject).toBe(after); + expect(((await restarted.protocol.getMetaItem({ type: T, name, member: 'zh-CN' })) as any)?.item?.subject).toBe(served); } - it('the reset (?member=): an unshipped member is retired by the overlay-entry tier, and nothing is left serving it', async () => { + it('the reset (?member=): an unshipped member is retired by the overlay-entry tier, and nothing is left serving it (its package-less reset stays open)', async () => { // No `zh-CN` artifact takes its place, so the shadow heal declines and // the overlay-entry retirement takes the tenant row. await resetZh(DIGEST, { shadow: false, overlayEntry: true }, undefined); }); - it('CONTROL the reset (?member=): a shipped member is un-shadowed onto its artifact', async () => { - // The shadow heal lifts the entry onto the `zh-CN` artifact, and the - // retirement refuses it as packaged code. - await resetZh(NAME, { shadow: true, overlayEntry: false }, 'PKG zh'); + it('CONTROL the reset (?member=): a shipped member\'s unmigrated row: its package-less reset door is closed', async () => { + // The shadow heal would lift the entry onto the `zh-CN` artifact, and + // the retirement refuses it as packaged code. + await resetZh(NAME, { shadow: true, overlayEntry: false }, 'closed'); }); it('a registry that answers another member\'s artifact: the graft keeps it off the body', async () => { diff --git a/packages/objectql/src/environment-overlay-five-types.test.ts b/packages/objectql/src/environment-overlay-five-types.test.ts index 68cae4ebbe9..d1f2e942929 100644 --- a/packages/objectql/src/environment-overlay-five-types.test.ts +++ b/packages/objectql/src/environment-overlay-five-types.test.ts @@ -1,10 +1,21 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * The five overlay-enabled metadata types keep their ENVIRONMENT overlay: a - * packaged item of each can be overlaid environment-wide, the overlay is stored - * with no organization, it is what the item's read then serves, and - * `GET /meta/types` tells Studio the type is writable. + * The five overlay-enabled metadata types keep their ENVIRONMENT customization: + * a packaged item of each is customized environment-wide, the customization is + * stored with no organization, it is what the item's read then serves, and + * `GET /meta/types` still carries the type's flag. + * + * ## Re-judged for the migrated state (#28576, ADR-0146 D11) + * + * Since D11 closed the package-less write, the flag no longer opens a write + * door: a save naming no package over the packaged item is refused + * `403 NOT_OVERRIDABLE`, naming the base's paired `.custom` and the + * `os migrate packageless-overlays` ceremony, and stores nothing. The + * customization is the same save naming the pair (`?package=.custom`), + * declaring the override. The flag stays the predicate the migration reads + * and the read of a row not migrated yet honours, so leg 2 below (a stored + * package-less row is served) is unchanged. * * ## Why this file exists (#22340, stage S1) * @@ -21,15 +32,17 @@ * * ## What is pinned, per type and on both topologies * - * 1. the write — `saveMetaItem` over the packaged item is accepted, stores - * exactly one row, the row names no organization, and the item's read + * 1. the write — `saveMetaItem` naming no package over the packaged item is + * refused naming the pair, and stores nothing; the same save into the + * pair, declaring the override, is accepted, stores exactly one row, + * bound to the pair, the row names no organization, and the item's read * serves the body that was written; * 2. the read of a stored overlay — a stored environment-wide row is served * by `getMetaItem`. The row is inserted through the engine, never a write * door, so this leg asks the read alone (the engine, not the driver, so * the served read's cache sees the write as it sees every production one): a row stored before a * flag change keeps being served whatever the flag then says; - * 3. the wire — `getMetaTypes` advertises the flag `true` for the type, from + * 3. the wire — `getMetaTypes` carries the flag `true` for the type, from * the registry and not from the `OS_METADATA_WRITABLE` hatch. * * And the identity: the registry types whose flag is `true` are exactly these @@ -85,7 +98,10 @@ import { ObjectStackProtocolImplementation, resetEnvWritableMetadataTypes } from import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject } from '@objectstack/metadata-core'; import { ObjectQL } from './engine.js'; -const PKG = 'com.acme.overlay_catalog'; +const PKG = 'com.acme.overlaycatalog'; +/** The base's paired customization package (ADR-0146 D3), where its customization lives. */ +const PAIR = 'com.acme.overlaycatalog.custom'; +const PKG_VERSION = '1.0.0'; const PACKAGED_LABEL = 'Packaged'; const OVERLAY_LABEL = 'Environment overlay'; @@ -135,6 +151,8 @@ function overlayCatalogManifest(): Record { return { id: PKG, name: 'overlay_catalog', + version: PKG_VERSION, + namespace: 'overlaycat', // [#24668] The object the view and the dataset bind to, shipped by the // package: the overlay save door refuses a list view bound to an object // that exists nowhere, and `probe_obj` was declared by nothing. @@ -333,7 +351,7 @@ afterEach(() => { resetEnvWritableMetadataTypes(); }); -describe('the five overlay-enabled types keep their environment overlay of a packaged item', () => { +describe('the five overlay-enabled types keep their environment customization of a packaged item, in its pair (ADR-0146 D11)', () => { it('the registry opens an overlay on exactly these five types', () => { const flagged = DEFAULT_METADATA_TYPE_REGISTRY .filter((entry) => entry.allowEnvironmentOverlay === true) @@ -343,19 +361,32 @@ describe('the five overlay-enabled types keep their environment overlay of a pac }); for (const topology of TOPOLOGIES) { - it.each(OVERLAY_ENABLED)(`${topology}: a save over the packaged %s is accepted, stored with no organization, and served back`, async (type) => { + it.each(OVERLAY_ENABLED)(`${topology}: a package-less save over the packaged %s is refused naming its pair; the same save into the pair is accepted, stored with no organization, and served back`, async (type) => { const { engine, protocol, rowsOf } = await boot(topology); const { name, body } = ITEMS[type]; - // Only an overlay if a package ships the item it lands on. + // Only a customization if a package ships the item it lands on. expect(engine.registry.getArtifactItem(type, name)?._packageId).toBe(PKG); - const result: any = await protocol + const refused: any = await protocol .saveMetaItem({ type, name, item: body(OVERLAY_LABEL) }) - .catch((e: any) => ({ refused: { code: e?.code, status: e?.status } })); + .then(() => undefined, (e: any) => ({ code: e?.code, status: e?.status, message: String(e?.message ?? '') })); + expect(refused).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); + expect(refused.message).toContain(`'${PAIR}'`); + expect(refused.message).toContain('os migrate packageless-overlays'); + expect(rowsOf(type, name)).toHaveLength(0); + + await (protocol as any).ensureCustomizationPackage({ basePackageId: PKG }); + const result: any = await protocol + .saveMetaItem({ + type, name, packageId: PAIR, + item: { ...body(OVERLAY_LABEL), overrides: { package: PKG, basedOn: PKG_VERSION } }, + }) + .catch((e: any) => ({ refused: { code: e?.code, status: e?.status, message: e?.message } })); expect(result).toMatchObject({ success: true }); const rows = rowsOf(type, name); expect(rows).toHaveLength(1); + expect(rows[0].package_id).toBe(PAIR); // NULL is how the repository writes "no organization" today; a // tenant-less row with no such column reads the same here. expect(rows[0].organization_id ?? null).toBeNull(); @@ -378,7 +409,7 @@ describe('the five overlay-enabled types keep their environment overlay of a pac }); } - it.each(OVERLAY_ENABLED)('GET /meta/types advertises %s as overlay-writable, from the registry', async (type) => { + it.each(OVERLAY_ENABLED)('GET /meta/types carries the overlay flag on %s, from the registry (the migration\'s and the unmigrated read\'s predicate)', async (type) => { const { protocol } = await boot('environment-scoped'); const result: any = await protocol.getMetaTypes(); diff --git a/packages/objectql/src/protocol-packageless-overlay-migration.test.ts b/packages/objectql/src/protocol-packageless-overlay-migration.test.ts index a38d38a0c43..a1773ec0f5b 100644 --- a/packages/objectql/src/protocol-packageless-overlay-migration.test.ts +++ b/packages/objectql/src/protocol-packageless-overlay-migration.test.ts @@ -4,9 +4,8 @@ * [#28413, ADR-0146 D11, N-16a] A package-less overlay row of a name a code * package X ships moves into X's paired `.custom` as a declared override — * `listPackagelessOverlays` / `migratePackagelessOverlays`, the protocol half - * of `os migrate packageless-overlays` — and, until #28576 closes the - * package-less write, a package-less save landing on the moved row keeps its - * declaration. + * of `os migrate packageless-overlays` — and, after it, the package-less + * write onto a packaged item closed (#28576, ADR-0146 D11). * * Pinned through the real protocol over the real `ObjectQL` engine and an * in-memory driver (the harness of `protocol-custom-override-effective-read.test.ts`): @@ -18,10 +17,13 @@ * - LEFT ALONE: a name no package ships, a name two packages ship, and a row * the pair already overrides (`shadowed`), each untouched; * - a stored pair declaration with no `basedOn` gains it (#28644's reading); - * - THE SAVE (Wanted 4): after the move, a package-less draft save and publish - * land on the pair's row with or without `overrides` in the body, and the - * item stays the declared override; a package-less DELETE removes the pair's - * row and X's document is served again; + * - THE CLOSE (#28576): after the move, a package-less save (draft or + * publish, with or without `overrides`), publish, DELETE and rollback are + * refused `403 NOT_OVERRIDABLE` naming the pair and the ceremony, and the + * pair's row is untouched; the same save and DELETE naming the pair + * succeed; a name no package ships keeps its package-less write; an + * unmigrated row is served, and its package-less write is refused; a + * rollback of the pair's declared row is the declared override (a page); * - ADOPT-ORPHANS (Wanted 5): adopting into a writable package skips the * shipped-name row and rebinds an environment one. * @@ -361,7 +363,7 @@ describe('[#28413] the census and the move', () => { /** * The state the move leaves, laid down without the ceremony (so these cases - * read the save door alone): the pair ensured through its door, and the + * read the write doors alone): the pair ensured through its door, and the * item's row bound to it, declaring the override. */ async function movedState(engine: ObjectQL, protocol: ObjectStackProtocolImplementation): Promise { @@ -369,46 +371,267 @@ async function movedState(engine: ObjectQL, protocol: ObjectStackProtocolImpleme await storeRow(engine, VIEW, 'showcase_task', UNBOUND_COLUMNS, PAIR, 'active', { overrides: { package: BASE, basedOn: '1.2.0' } }); } -describe('[#28413] until #28576: a package-less save of a moved item keeps its declaration', () => { +/** A write's refusal as an HTTP boundary relays it, or `undefined` when it was not refused. */ +const refusalOf = (write: Promise) => write.then( + () => undefined, + (e: any) => ({ code: e?.code, status: e?.status, message: String(e?.message ?? '') }), +); + +/** The close's refusal: the sealed write's code and status, naming the pair and the ceremony (ADR-0146 D11). */ +const expectClosed = (refusal: { code?: unknown; status?: unknown; message: string } | undefined) => { + expect(refusal).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); + expect(refusal!.message).toContain(`'${PAIR}'`); + expect(refusal!.message).toContain('os migrate packageless-overlays'); +}; + +/** + * A package-less row of `VIEW` as an older release's door stored it: through + * the repository itself (the row and its history), versions 1 and 2, with no + * protocol door in front — the close refuses that write through every door. + */ +async function legacyRowWithHistory(protocol: ObjectStackProtocolImplementation): Promise { + const repo = (protocol as any).getOverlayRepo(); + const ref = { type: 'view', name: VIEW, org: 'env' }; + const opts = { actor: null, source: 'older-release', intent: 'override-artifact', packageId: null } as const; + const first = await repo.put(ref, listBody(VIEW, 'showcase_task', UNBOUND_COLUMNS), { ...opts, parentVersion: null }); + await repo.put(ref, listBody(VIEW, 'showcase_task', EDITED_COLUMNS), { ...opts, parentVersion: first.version }); +} + +describe('[#28576] after the close: a package-less write onto a packaged item is refused, naming the pair', () => { for (const withOverrides of [false, true]) { - it(`a package-less draft save and publish ${withOverrides ? 'carrying' : 'without'} \`overrides\` land on the pair's row, still the declared override`, async () => { + it(`a migrated item: a package-less draft save and publish-mode save ${withOverrides ? 'carrying' : 'without'} \`overrides\` are refused, and the pair's row is untouched`, async () => { const { engine, protocol, rows, rowOf } = await boot(); await movedState(engine, protocol); // Setup's form save: the item as the editor read it, naming no package. const served = await read(protocol); const body: Record = { ...listBody(VIEW, 'showcase_task', EDITED_COLUMNS) }; if (withOverrides) body.overrides = served.overrides; - await protocol.saveMetaItem({ type: 'view', name: VIEW, item: body, mode: 'draft' } as any); - await protocol.publishMetaItem({ type: 'view', name: VIEW } as any); - expect(rows(VIEW)).toEqual([[PAIR, 'active']]); - expect(isServedOverrideRowOf(rowOf(VIEW, PAIR) as any, BASE)).toBe(true); - expect(JSON.parse(String(rowOf(VIEW, PAIR)!.metadata)).overrides).toEqual({ package: BASE, basedOn: '1.2.0' }); - expect(await fiveReads(protocol)).toEqual([EDITED_COLUMNS, EDITED_COLUMNS, EDITED_COLUMNS, EDITED_COLUMNS, EDITED_COLUMNS]); + expectClosed(await refusalOf(protocol.saveMetaItem({ type: 'view', name: VIEW, item: body, mode: 'draft' } as any))); + expectClosed(await refusalOf(protocol.saveMetaItem({ type: 'view', name: VIEW, item: body } as any))); + // `packageId: null` names no package too, as the request contract states. + expectClosed(await refusalOf(protocol.saveMetaItem({ type: 'view', name: VIEW, item: body, packageId: null } as any))); - // …and a publish-mode save naming no package, the other spelling of the same write. - await protocol.saveMetaItem({ type: 'view', name: VIEW, item: { ...listBody(VIEW, 'showcase_task', UNBOUND_COLUMNS) } } as any); expect(rows(VIEW)).toEqual([[PAIR, 'active']]); expect(isServedOverrideRowOf(rowOf(VIEW, PAIR) as any, BASE)).toBe(true); - expect(columnsOf(await read(protocol))).toEqual(UNBOUND_COLUMNS); + expect(await fiveReads(protocol)).toEqual([UNBOUND_COLUMNS, UNBOUND_COLUMNS, UNBOUND_COLUMNS, UNBOUND_COLUMNS, UNBOUND_COLUMNS]); }); } - it('a package-less DELETE removes the pair\'s row and X\'s document is served again', async () => { + it('a migrated item: a package-less DELETE (Setup\'s reset) and a package-less publish are refused, and nothing moves', async () => { const { engine, protocol, rows } = await boot(); await movedState(engine, protocol); - await protocol.deleteMetaItem({ type: 'view', name: VIEW } as any); + expectClosed(await refusalOf(protocol.deleteMetaItem({ type: 'view', name: VIEW } as any))); + expect(rows(VIEW)).toEqual([[PAIR, 'active']]); + + // A draft in the pair, published naming no package: refused, the draft stays a draft. + await protocol.saveMetaItem({ + type: 'view', name: VIEW, packageId: PAIR, mode: 'draft', + item: { ...listBody(VIEW, 'showcase_task', EDITED_COLUMNS), overrides: { package: BASE, basedOn: '1.2.0' } }, + } as any); + expectClosed(await refusalOf(protocol.publishMetaItem({ type: 'view', name: VIEW } as any))); + expect(rows(VIEW).sort()).toEqual([[PAIR, 'active'], [PAIR, 'draft']]); + expect(columnsOf(await read(protocol))).toEqual(UNBOUND_COLUMNS); + // …and naming the pair publishes it. + await protocol.publishMetaItem({ type: 'view', name: VIEW, packageId: PAIR } as any); + expect(columnsOf(await read(protocol))).toEqual(EDITED_COLUMNS); + }); + + it('a migrated item: a save and a DELETE naming ?package=.custom succeed', async () => { + const { engine, protocol, rows, rowOf } = await boot(); + await movedState(engine, protocol); + await protocol.saveMetaItem({ + type: 'view', name: VIEW, packageId: PAIR, + item: { ...listBody(VIEW, 'showcase_task', EDITED_COLUMNS), overrides: { package: BASE, basedOn: '1.2.0' } }, + } as any); + expect(rows(VIEW)).toEqual([[PAIR, 'active']]); + expect(isServedOverrideRowOf(rowOf(VIEW, PAIR) as any, BASE)).toBe(true); + expect(await fiveReads(protocol)).toEqual([EDITED_COLUMNS, EDITED_COLUMNS, EDITED_COLUMNS, EDITED_COLUMNS, EDITED_COLUMNS]); + + await protocol.deleteMetaItem({ type: 'view', name: VIEW, packageId: PAIR } as any); expect(rows(VIEW)).toEqual([]); expect(await fiveReads(protocol)).toEqual([BASE_COLUMNS, BASE_COLUMNS, BASE_COLUMNS, BASE_COLUMNS, BASE_COLUMNS]); }); - it('CONTROL: a package-less save carrying `overrides` over an unmoved package-less row is still refused (the pair is not its address)', async () => { - const { engine, protocol } = await boot(); - await storeRow(engine, VIEW, 'showcase_task', UNBOUND_COLUMNS); - await expect(protocol.saveMetaItem({ - type: 'view', name: VIEW, mode: 'draft', - item: { ...listBody(VIEW, 'showcase_task', EDITED_COLUMNS), overrides: { package: BASE, basedOn: '1.2.0' } }, - } as any)).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); + it('CONTROL: a package-less save and DELETE of a name no package ships (environment metadata) still succeed', async () => { + const { protocol, rows } = await boot(); + await protocol.saveMetaItem({ type: 'view', name: ENV_VIEW, item: listBody(ENV_VIEW, 'showcase_task', UNBOUND_COLUMNS) } as any); + expect(rows(ENV_VIEW)).toEqual([[null, 'active']]); + await protocol.deleteMetaItem({ type: 'view', name: ENV_VIEW } as any); + expect(rows(ENV_VIEW)).toEqual([]); + }); + + it('an unmigrated row is served, and its package-less save, DELETE and rollback are refused naming the ceremony', async () => { + const { protocol, rows, rowOf } = await boot(); + await legacyRowWithHistory(protocol); + const before = rowOf(VIEW, null); + expect(columnsOf(await read(protocol))).toEqual(EDITED_COLUMNS); + expect(columnsOf((await layered(protocol)).effective)).toEqual(EDITED_COLUMNS); + + expectClosed(await refusalOf(protocol.saveMetaItem({ type: 'view', name: VIEW, item: listBody(VIEW, 'showcase_task', BASE_COLUMNS) } as any))); + expectClosed(await refusalOf(protocol.deleteMetaItem({ type: 'view', name: VIEW } as any))); + expectClosed(await refusalOf((protocol as any).rollbackMetaItem({ type: 'view', name: VIEW, toVersion: 1 }))); + + expect(rows(VIEW)).toEqual([[null, 'active']]); + expect(rowOf(VIEW, null)).toEqual(before); + expect(columnsOf(await read(protocol))).toEqual(EDITED_COLUMNS); + // The ceremony still moves it, and the item is the pair's then. + expect((await migrate(protocol)).outcomes.map((o: any) => o.outcome)).toEqual(['moved']); + expect(rows(VIEW)).toEqual([[PAIR, 'active']]); + }); + + it('the rollback refusal\'s remedy holds: after the move, a version from before it is restored by saving its body into the pair, declaring the override', async () => { + const { protocol, rows, rowOf } = await boot(); + await legacyRowWithHistory(protocol); + const refused = await refusalOf((protocol as any).rollbackMetaItem({ type: 'view', name: VIEW, toVersion: 1 })); + expectClosed(refused); + expect(refused!.message).toContain("save that version's body there"); + expect((await migrate(protocol)).outcomes.map((o: any) => o.outcome)).toEqual(['moved']); + // A rollback to that version in the pair restores an undeclared body, + // which the override write gate refuses (D2), not the close… + const inPair = await refusalOf((protocol as any).rollbackMetaItem({ type: 'view', name: VIEW, toVersion: 1 })); + expect(inPair?.message).toContain('declares no override'); + expect(inPair?.message).not.toContain('os migrate packageless-overlays'); + // …so the sentence prescribes the save into the pair, which succeeds. + await protocol.saveMetaItem({ + type: 'view', name: VIEW, packageId: PAIR, + item: { ...listBody(VIEW, 'showcase_task', UNBOUND_COLUMNS), overrides: { package: BASE, basedOn: '1.2.0' } }, + } as any); + expect(rows(VIEW)).toEqual([[PAIR, 'active']]); + expect(isServedOverrideRowOf(rowOf(VIEW, PAIR) as any, BASE)).toBe(true); + expect(columnsOf(await read(protocol))).toEqual(UNBOUND_COLUMNS); + }); + + it('a rollback of the pair\'s own declared row is the pair\'s declared override, never an artifact override (a page)', async () => { + const { engine, protocol, stores } = await boot(); + engine.registerApp({ id: BASE, name: 'showcase', version: '1.2.0', namespace: 'showcase', pages: [pageBody(PAGE, 'Home')] } as any); + await (protocol as any).ensureCustomizationPackage({ basePackageId: BASE }); + const declared = (label: string) => ({ ...pageBody(PAGE, label), overrides: { package: BASE, basedOn: '1.2.0' } }); + await protocol.saveMetaItem({ type: 'page', name: PAGE, packageId: PAIR, item: declared('Edited home') } as any); + await protocol.saveMetaItem({ type: 'page', name: PAGE, packageId: PAIR, item: declared('Second edit') } as any); + const pageRow = () => Array.from(stores.get('sys_metadata')?.values() ?? []) + .filter((r) => r.type === 'page' && r.name === PAGE); + + const rolled = await refusalOf((protocol as any).rollbackMetaItem({ type: 'page', name: PAGE, toVersion: 1 })); + expect(rolled).toBeUndefined(); + expect(pageRow().map((r) => [r.package_id, r.state])).toEqual([[PAIR, 'active']]); + const body = JSON.parse(String(pageRow()[0]!.metadata)); + expect(body.label).toBe('Edited home'); + expect(body.overrides).toEqual({ package: BASE, basedOn: '1.2.0' }); + }); +}); + +/** + * [#28576 ruling 2, ADR-0146 D11] The close reaches exactly what the ceremony + * moves: every row it lists and does not move keeps its package-less save, + * DELETE and rollback, as before the close — the census's own classification + * (`classifyPackagelessRow`) and the ceremony's own steps decide it. Each case + * lays down the row, reads the census's disposition, and writes it naming no + * package. + */ +describe('[#28576] the close reaches only what the ceremony moves: every row it leaves keeps its package-less write', () => { + /** A code package (read-only) shipping one list view of `object`. */ + const shipping = (engine: ObjectQL, id: string, object: string, manifest: Record) => engine.registerApp({ + id, name: id, objects: [objectOf(object, BASE_COLUMNS)], views: [listView(object, BASE_COLUMNS)], ...manifest, + } as any); + /** The package-less save, then DELETE, of `name`: both succeed and leave no row. */ + const expectOpen = async (protocol: ObjectStackProtocolImplementation, rows: (n: string) => unknown, name: string, object: string) => { + expect(await refusalOf(protocol.saveMetaItem({ type: 'view', name, item: listBody(name, object, EDITED_COLUMNS) } as any))).toBeUndefined(); + expect(await refusalOf(protocol.deleteMetaItem({ type: 'view', name } as any))).toBeUndefined(); + return rows(name); + }; + + it('multi_shipped: a name two read-only packages ship — the stored row\'s package-less save and DELETE succeed', async () => { + const { engine, protocol, rows } = await boot(); + await storeRow(engine, SHARED_VIEW, 'shared_task', UNBOUND_COLUMNS); + expect(dispositions(await census(protocol))).toEqual([`${SHARED_VIEW}@active multi_shipped`]); + expect(await expectOpen(protocol, rows, SHARED_VIEW, 'shared_task')).toEqual([]); + }); + + it('no_pair: a base whose id forms no pair — the first package-less save and DELETE succeed', async () => { + const { engine, protocol, rows } = await boot(); + // An id ending in `.custom` derives no pair (`base_is_customization_package`). + shipping(engine, 'com.vendor.custom', 'vendor_task', { version: '1.0.0', namespace: 'vendor' }); + const name = 'vendor_task.default'; + await storeRow(engine, name, 'vendor_task', UNBOUND_COLUMNS); + expect(dispositions(await census(protocol))).toEqual([`${name}@active no_pair`]); + expect(await expectOpen(protocol, rows, name, 'vendor_task')).toEqual([]); + }); + + for (const [why, manifest] of [ + ['no namespace', { version: '1.0.0' }], + ['a non-SemVer version', { version: 'latest', namespace: 'plain' }], + ] as const) { + it(`an unensurable pair (${why}): the census lists the row movable, the ceremony fails it, and its package-less save and DELETE succeed`, async () => { + const { engine, protocol, rows } = await boot(); + shipping(engine, 'com.example.plain', 'plain_task', manifest); + const name = 'plain_task.default'; + await storeRow(engine, name, 'plain_task', UNBOUND_COLUMNS); + expect(dispositions(await census(protocol))).toEqual([`${name}@active movable`]); + const result = await migrate(protocol); + expect(result.outcomes.map((o: any) => o.outcome)).toEqual(['failed']); + expect(rows(name)).toEqual([[null, 'active']]); + expect(await expectOpen(protocol, rows, name, 'plain_task')).toEqual([]); + }); + } + + it('foreign_declaration: a stored row whose `overrides` names another package — its package-less save and DELETE succeed', async () => { + const { engine, protocol, rows } = await boot(); + await storeRow(engine, VIEW, 'showcase_task', UNBOUND_COLUMNS, null, 'active', { overrides: { package: OTHER } }); + expect(dispositions(await census(protocol))).toEqual([`${VIEW}@active foreign_declaration`]); + // The save corrects the key; the row is still the package-less one. + expect(await refusalOf(protocol.saveMetaItem({ type: 'view', name: VIEW, item: listBody(VIEW, 'showcase_task', EDITED_COLUMNS) } as any))).toBeUndefined(); + expect(rows(VIEW)).toEqual([[null, 'active']]); + expect(dispositions(await census(protocol))).toEqual([`${VIEW}@active movable`]); + // …and, now movable, its next package-less write is the closed one. + expectClosed(await refusalOf(protocol.deleteMetaItem({ type: 'view', name: VIEW } as any))); + }); + + it('CONTROL foreign_declaration: its package-less DELETE succeeds', async () => { + const { engine, protocol, rows } = await boot(); + await storeRow(engine, VIEW, 'showcase_task', UNBOUND_COLUMNS, null, 'active', { overrides: { package: OTHER } }); + expect(await refusalOf(protocol.deleteMetaItem({ type: 'view', name: VIEW } as any))).toBeUndefined(); + expect(rows(VIEW)).toEqual([]); + }); + + it('shadowed (S5): a stored row beside the pair\'s declared row — its package-less rollback, save and DELETE are not the close\'s to refuse', async () => { + const { engine, protocol, rows, rowOf } = await boot(); + await legacyRowWithHistory(protocol); + await (protocol as any).ensureCustomizationPackage({ basePackageId: BASE }); + await storeRow(engine, VIEW, 'showcase_task', BASE_COLUMNS.slice(0, 1), PAIR, 'active', { overrides: { package: BASE, basedOn: '1.2.0' } }); + expect(dispositions(await census(protocol))).toEqual([`${VIEW}@active shadowed`]); + const pairRow = rowOf(VIEW, PAIR); + expect(columnsOf(await read(protocol))).toEqual(['title']); + + // The rollback's row is the first active row of the name in store + // order (`resolveOverlayPackageBinding`): here the package-less one, + // which is rolled back to its version 1. + expect(await refusalOf((protocol as any).rollbackMetaItem({ type: 'view', name: VIEW, toVersion: 1 }))).toBeUndefined(); + expect(columnsOf(JSON.parse(String(rowOf(VIEW, null)!.metadata)))).toEqual(UNBOUND_COLUMNS); + // The package-less save and DELETE are not the close's to refuse: + // they are addressed as before the close (`packagelessAddress`: the + // one bound row of the name, the pair's), so the override write gate + // answers the save there (D2), and the DELETE removes the pair's row — + // what "Restore package original" does — never this package-less one. + const saved = await refusalOf(protocol.saveMetaItem({ type: 'view', name: VIEW, item: listBody(VIEW, 'showcase_task', EDITED_COLUMNS) } as any)); + expect(saved?.message).toContain('declares no override'); + expect(saved?.message).not.toContain('os migrate packageless-overlays'); + expect(rowOf(VIEW, PAIR)).toEqual(pairRow); + expect(await refusalOf(protocol.deleteMetaItem({ type: 'view', name: VIEW } as any))).toBeUndefined(); + expect(rows(VIEW)).toEqual([[null, 'active']]); + // …which leaves the package-less row movable: the ceremony's remedy. + expect(dispositions(await census(protocol))).toEqual([`${VIEW}@active movable`]); + expect(columnsOf(await read(protocol))).toEqual(UNBOUND_COLUMNS); + }); + + it('CONTROL movable: the same stored row with no pair row beside it — its package-less rollback, save and DELETE are refused', async () => { + const { protocol, rows } = await boot(); + await legacyRowWithHistory(protocol); + expect(dispositions(await census(protocol))).toEqual([`${VIEW}@active movable`]); + expectClosed(await refusalOf((protocol as any).rollbackMetaItem({ type: 'view', name: VIEW, toVersion: 1 }))); + expectClosed(await refusalOf(protocol.saveMetaItem({ type: 'view', name: VIEW, item: listBody(VIEW, 'showcase_task', EDITED_COLUMNS) } as any))); + expectClosed(await refusalOf(protocol.deleteMetaItem({ type: 'view', name: VIEW } as any))); + expect(rows(VIEW)).toEqual([[null, 'active']]); }); }); diff --git a/packages/qa/dogfood/test/customization-pair.ts b/packages/qa/dogfood/test/customization-pair.ts new file mode 100644 index 00000000000..31aa285fd9c --- /dev/null +++ b/packages/qa/dogfood/test/customization-pair.ts @@ -0,0 +1,30 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#28576] ADR-0146 D11 closed the package-less write onto a packaged item. +// `PUT` or `DELETE /meta//` with no `?package=`, for a view, +// dashboard, report, translation or email template that a read-only package +// ships, now answers `403 NOT_OVERRIDABLE`. An administrator's customization +// of such an item goes into the base's `.custom` pair instead, declaring the +// item it overrides. These helpers are that write, for the suites that +// customize a shipped item on a real boot. + +import { expect } from 'vitest'; +import type { VerifyStack } from '@objectstack/verify'; + +/** The `.custom` pair of a base package. */ +export const pairOf = (base: string): string => `${base}.custom`; + +/** Ensure the base's pair exists (`201` the first time, `200` after). */ +export async function ensurePair(stack: VerifyStack, token: string, base: string): Promise { + const res = await stack.apiAs(token, 'POST', `/packages/${base}/ensure-customization`); + expect([200, 201], await res.clone().text()).toContain(res.status); + return pairOf(base); +} + +/** `path` addressed to the base's pair (`package=.custom` added to its query). */ +export const inPair = (path: string, base: string): string => + `${path}${path.includes('?') ? '&' : '?'}package=${pairOf(base)}`; + +/** `body` declared as the pair's override of the item `base` ships at `version`. */ +export const declaredOverride = >(body: T, base: string, version: string) => + ({ ...body, overrides: { package: base, basedOn: version } }); diff --git a/packages/qa/dogfood/test/email-template-materialization.dogfood.test.ts b/packages/qa/dogfood/test/email-template-materialization.dogfood.test.ts index ff0ad71d07d..e94310e42d7 100644 --- a/packages/qa/dogfood/test/email-template-materialization.dogfood.test.ts +++ b/packages/qa/dogfood/test/email-template-materialization.dogfood.test.ts @@ -35,9 +35,12 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { declaredOverride, ensurePair, inPair } from './customization-pair.js'; import { EmailServicePlugin } from '@objectstack/plugin-email'; import { emailTemplateFixtureStack, etPasswordReset } from './fixtures/email-template-materialization-fixture.js'; +/** The fixture app that ships the authored `auth.password_reset`. */ +const FIXTURE = 'com.dogfood.email-template-fixture'; const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] }; /** @@ -120,10 +123,13 @@ describe('objectstack verify EMAIL TEMPLATE: authored template materializes and it('a Studio save over the authored template sends on the very next mail, with no restart', async () => { const token = await stack.signIn(); - const put = await stack.apiAs(token, 'PUT', '/meta/email_template/auth.password_reset', { + // [#28576] ADR-0146 D11: the Studio save of a packaged item goes into the + // app's `.custom` pair, declaring the app's template it overrides. + await ensurePair(stack, token, FIXTURE); + const put = await stack.apiAs(token, 'PUT', inPair('/meta/email_template/auth.password_reset', FIXTURE), declaredOverride({ ...etPasswordReset, subject: 'Studio reset for {{user.name}}', - }); + }, FIXTURE, '0.0.0')); expect(put.status, await put.clone().text()).toBe(200); const email = (await stack.kernel.getServiceAsync('email')) as any; diff --git a/packages/qa/dogfood/test/email-template-overlay-survives-boot.dogfood.test.ts b/packages/qa/dogfood/test/email-template-overlay-survives-boot.dogfood.test.ts index 2661441c608..8aa78027df3 100644 --- a/packages/qa/dogfood/test/email-template-overlay-survives-boot.dogfood.test.ts +++ b/packages/qa/dogfood/test/email-template-overlay-survives-boot.dogfood.test.ts @@ -43,10 +43,16 @@ // §6 Q1) — an edit and a create through `/data/sys_email_template` are // refused 403 `PERMISSION_DENIED`, nothing is written, and the // metadata-door wording still sends after the next cold boot. +// +// [#28576] ADR-0146 D11 closed the package-less write onto a packaged item, so +// the admin's edit goes into the showcase's `.custom` pair, declaring the +// template it overrides. The pair's row is the one stored row, still +// environment-wide, and it is what the mailer reads. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { type VerifyStack } from '@objectstack/verify'; import { bootShowcase } from './showcase-boot.js'; +import { declaredOverride, ensurePair, inPair } from './customization-pair.js'; import { EmailServicePlugin } from '@objectstack/plugin-email'; import { fileURLToPath } from 'node:url'; import { mkdtempSync, rmSync } from 'node:fs'; @@ -59,6 +65,7 @@ const SYS = { isSystem: true, positions: [], permissions: [] }; /** The one template the showcase package declares (`com.example.showcase`). */ const NAME = 'showcase_task_done_email'; +const BASE = 'com.example.showcase'; const PACKAGE_SUBJECT = '✅ Task done: {{title}}'; const ADMIN_SUBJECT = 'Done (reworded by the admin): {{title}}'; @@ -118,6 +125,7 @@ describe('[#21785] a metadata-door email template edit survives a cold boot (sho dbFile = join(dir, 'showcase.db'); stack = await boot(dbFile); token = await stack.signIn(); + await ensurePair(stack, token, BASE); }, 180_000); afterAll(async () => { @@ -140,7 +148,7 @@ describe('[#21785] a metadata-door email template edit survives a cold boot (sho if (!k.startsWith('_')) body[k] = v; } expect(body.subject).toBe(PACKAGE_SUBJECT); - const put = await call('PUT', `/meta/email_template/${NAME}`, { ...body, subject: ADMIN_SUBJECT }); + const put = await call('PUT', inPair(`/meta/email_template/${NAME}`, BASE), declaredOverride({ ...body, subject: ADMIN_SUBJECT }, BASE, '0.1.0')); expect(put.status, JSON.stringify(put.json)).toBe(200); // [ADR-0131 D6] The door names no organization, whatever the admin's. diff --git a/packages/qa/dogfood/test/email-template-two-locales-survive-boot.dogfood.test.ts b/packages/qa/dogfood/test/email-template-two-locales-survive-boot.dogfood.test.ts index 93320786246..75e0aae9885 100644 --- a/packages/qa/dogfood/test/email-template-two-locales-survive-boot.dogfood.test.ts +++ b/packages/qa/dogfood/test/email-template-two-locales-survive-boot.dogfood.test.ts @@ -31,10 +31,17 @@ // that locale's wording, `DELETE ?member=zh-CN` resets that locale alone // and, after a cold boot, each locale serves the expected wording; a // misspelled or misplaced selector is a located 400. +// +// [#28576] ADR-0146 D11 closed the package-less write onto a packaged item, so +// both locales are saved into the showcase's `.custom` pair: `en-US`, the +// member the base ships, declares the override; `zh-CN`, which the base does +// not ship, is the pair's own member and declares none (#28746), listed and +// sent on the base's address (#28828). The reset names the pair too. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { type VerifyStack } from '@objectstack/verify'; import { bootShowcase } from './showcase-boot.js'; +import { declaredOverride, ensurePair, inPair } from './customization-pair.js'; import { EmailServicePlugin } from '@objectstack/plugin-email'; import { fileURLToPath } from 'node:url'; import { mkdtempSync, rmSync } from 'node:fs'; @@ -47,6 +54,7 @@ const SYS = { isSystem: true, positions: [], permissions: [] }; /** The template the showcase package declares (`com.example.showcase`), in `en-US`. */ const NAME = 'showcase_task_done_email'; +const BASE = 'com.example.showcase'; const PACKAGE_SUBJECT = '✅ Task done: {{title}}'; const ADMIN_EN = 'Done (admin, English): {{title}}'; const ADMIN_ZH = '已完成(管理员中文):{{title}}'; @@ -105,6 +113,12 @@ describe('[#22877] two locales of one email template survive a cold boot (showca .map((r) => ({ member: r.bundle_member ?? null, subject: JSON.parse(String(r.metadata)).subject })) .sort((a, b) => String(a.member).localeCompare(String(b.member))); }; + /** A save of one locale into the pair: the shipped `en-US` declares the override, another locale is the pair's own member. */ + const saveLocale = (served: Record, locale: string, subject: string) => { + const { overrides: _declared, ...doc }: Record = { ...served, locale, subject }; + return call('PUT', inPair(`/meta/email_template/${NAME}`, BASE), + locale === 'en-US' ? declaredOverride(doc, BASE, '0.1.0') : doc); + }; const restart = async () => { await stack!.stop(); stack = await boot(dbFile); @@ -118,6 +132,7 @@ describe('[#22877] two locales of one email template survive a cold boot (showca dbFile = join(dir, 'showcase.db'); stack = await boot(dbFile); token = await stack.signIn(); + await ensurePair(stack, token, BASE); }, 180_000); afterAll(async () => { @@ -131,9 +146,9 @@ describe('[#22877] two locales of one email template survive a cold boot (showca expect(served.status).toBe(200); expect(served.body.subject).toBe(PACKAGE_SUBJECT); - const en = await call('PUT', `/meta/email_template/${NAME}`, { ...served.body, locale: 'en-US', subject: ADMIN_EN }); + const en = await saveLocale(served.body, 'en-US', ADMIN_EN); expect(en.status, JSON.stringify(en.json)).toBe(200); - const zh = await call('PUT', `/meta/email_template/${NAME}`, { ...served.body, locale: 'zh-CN', subject: ADMIN_ZH }); + const zh = await saveLocale(served.body, 'zh-CN', ADMIN_ZH); expect(zh.status, JSON.stringify(zh.json)).toBe(200); expect(await overlayRows()).toEqual([ @@ -159,7 +174,7 @@ describe('[#22877] two locales of one email template survive a cold boot (showca expect(await sendSubject('ja-JP')).toBe('Done (admin, English): Ship it'); const served = await metaBody(); - const again = await call('PUT', `/meta/email_template/${NAME}`, { ...served.body, locale: 'zh-CN', subject: ADMIN_ZH.replace(':', '(二):') }); + const again = await saveLocale(served.body, 'zh-CN', ADMIN_ZH.replace(':', '(二):')); expect(again.status, JSON.stringify(again.json)).toBe(200); expect((await overlayRows()).map((r) => r.member)).toEqual([null, 'zh-CN']); @@ -173,7 +188,7 @@ describe('[#22877] two locales of one email template survive a cold boot (showca expect(await metaBody('?member=zh-CN')).toMatchObject({ status: 200, body: { subject: ADMIN_ZH.replace(':', '(二):') } }); expect(await metaBody('?member=en-US')).toMatchObject({ status: 200, body: { subject: ADMIN_EN } }); - const reset = await call('DELETE', `/meta/email_template/${NAME}?member=zh-CN`); + const reset = await call('DELETE', inPair(`/meta/email_template/${NAME}?member=zh-CN`, BASE)); expect(reset.status, JSON.stringify(reset.json)).toBe(200); expect(reset.json).toMatchObject({ success: true, reset: true }); expect(await overlayRows()).toEqual([{ member: null, subject: ADMIN_EN }]); diff --git a/packages/qa/dogfood/test/managed-content-sealed.dogfood.test.ts b/packages/qa/dogfood/test/managed-content-sealed.dogfood.test.ts index 7a2d6a09b0c..e1969a96248 100644 --- a/packages/qa/dogfood/test/managed-content-sealed.dogfood.test.ts +++ b/packages/qa/dogfood/test/managed-content-sealed.dogfood.test.ts @@ -22,7 +22,9 @@ // 403 `NOT_OVERRIDABLE`, nothing written, nothing removed. // // The controls — what the seal leaves open, measured on the same boot: -// - a regime-O overlay: a packaged VIEW is overlaid and its overlay removed; +// - a regime-O customization: a packaged VIEW is customized in its base's +// `.custom` pair and the customization removed (#28576: the package-less +// write onto it is closed); // - a NEW flow, through `PUT /meta/flow/:name` and through `POST /automation` // (the card's positive control: creating a new flow in Studio works); // - the switch: `POST /automation/:name/toggle` turns the managed flow off and @@ -190,11 +192,21 @@ describe('[ADR-0131 D6] managed content is sealed — the hatch opens nothing a } }); - it('control (regime O): a packaged VIEW is overlaid, and its overlay removed', async () => { + it('control (regime O): a packaged VIEW is customized in its base\'s pair, and the customization removed', async () => { + // [#28576, ADR-0146 D11] The write naming no package onto it is closed, + // naming the pair; the customization lives in `com.example.crm.custom`. const view = authorable(await served('view', VIEW)); - const put = await call('PUT', `/meta/view/${VIEW}`, { ...view, label: 'My Opportunities' }); + const closed = await call('PUT', `/meta/view/${VIEW}`, { ...view, label: 'My Opportunities' }); + expect([closed.status, closed.code], closed.text).toEqual([403, 'NOT_OVERRIDABLE']); + expect(closed.text).toContain("'com.example.crm.custom' (?package=com.example.crm.custom)"); + const pair = await call('POST', '/packages/com.example.crm/ensure-customization'); + expect([200, 201], pair.text).toContain(pair.status); + const put = await call('PUT', `/meta/view/${VIEW}?package=com.example.crm.custom`, { + ...view, label: 'My Opportunities', overrides: { package: 'com.example.crm', basedOn: '4.0.0' }, + }); expect(put.status, put.text).toBe(200); - const del = await call('DELETE', `/meta/view/${VIEW}`); + expect((await served('view', VIEW)).label).toBe('My Opportunities'); + const del = await call('DELETE', `/meta/view/${VIEW}?package=com.example.crm.custom`); expect(del.status, del.text).toBe(200); expect(del.text).toContain('reset to artifact default'); }); diff --git a/packages/qa/dogfood/test/meta-read-doors-live.dogfood.test.ts b/packages/qa/dogfood/test/meta-read-doors-live.dogfood.test.ts index 13cc218c588..84a0f91fb82 100644 --- a/packages/qa/dogfood/test/meta-read-doors-live.dogfood.test.ts +++ b/packages/qa/dogfood/test/meta-read-doors-live.dogfood.test.ts @@ -23,9 +23,15 @@ // (the CONTROL that the field is sortable for a caller who may read it). // // This file writes /meta (the zh-CN member), so it boots its own showcase. +// +// [#28576] ADR-0146 D11 closed the package-less write onto a packaged item, so +// the zh-CN member is authored into the showcase's `.custom` pair. The base +// ships `en-US` only, so the member declares no `overrides`: it is the pair's +// own member, and the by-name read serves it the same. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { type VerifyStack } from '@objectstack/verify'; +import { ensurePair, inPair } from './customization-pair.js'; import { bootShowcase } from './showcase-boot.js'; import { showcaseAppDefaultSecurity } from './showcase-security.js'; @@ -85,7 +91,8 @@ describe('[#26572] the v18 /meta read doors, over HTTP on a booted showcase', () expect(read.status).toBe(200); const shipped: Record = ((await read.json()) as any).item; const authored = Object.fromEntries(Object.entries(shipped).filter(([k]) => !k.startsWith('_'))); - const res = await stack.apiAs(admin, 'PUT', `/meta/email_template/${TEMPLATE}`, { + await ensurePair(stack, admin, PKG); + const res = await stack.apiAs(admin, 'PUT', inPair(`/meta/email_template/${TEMPLATE}`, PKG), { ...authored, locale: 'zh-CN', subject: ZH_SUBJECT, bodyText: 'mrd-live zh-CN body', bodyHtml: '

mrd-live zh-CN body

', }); expect(res.status, await res.clone().text()).toBe(200); diff --git a/packages/qa/dogfood/test/public-form-withdrawal-walled.dogfood.test.ts b/packages/qa/dogfood/test/public-form-withdrawal-walled.dogfood.test.ts index f7f17e50df4..03de40e5cef 100644 --- a/packages/qa/dogfood/test/public-form-withdrawal-walled.dogfood.test.ts +++ b/packages/qa/dogfood/test/public-form-withdrawal-walled.dogfood.test.ts @@ -21,9 +21,14 @@ // with no row landing; republishing restores both doors; // - an edit that leaves the sharing alone lands environment-wide too; // - the same with no active organization (unchanged). +// +// [#28576] ADR-0146 D11 closed the package-less write onto a packaged item, so +// the admin's save goes into the fixture's `.custom` pair, declaring the form it +// overrides; the anonymous doors serve the pair's override. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { declaredOverride, ensurePair, inPair } from './customization-pair.js'; import { defineStack, defineView } from '@objectstack/spec'; import { ObjectSchema, Field } from '@objectstack/spec/data'; import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; @@ -31,6 +36,7 @@ import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plug const LEAD = 'walled_lead'; const SLUG = 'walled-lead-intake'; const VIEW = `/meta/view/${LEAD}.intake`; +const BASE = 'com.dogfood.walled-public-form'; const WalledLead = ObjectSchema.create({ name: LEAD, @@ -108,7 +114,7 @@ describe('walled posture: withdrawing a public form from anonymous intake', () = /** PUT the form at the admin's current session scope. */ const put = async (body: Record) => { - const res = await stack.apiAs(admin, 'PUT', VIEW, body); + const res = await stack.apiAs(admin, 'PUT', inPair(VIEW, BASE), declaredOverride(body, BASE, '0.0.0')); const json = (await res.json()) as Record; return { status: res.status, json }; }; @@ -133,6 +139,7 @@ describe('walled posture: withdrawing a public form from anonymous intake', () = security: new SecurityPlugin({ defaultPermissionSets: [...securityDefaultPermissionSets] }), }); admin = await stack.signIn(); + await ensurePair(stack, admin, BASE); ql = await stack.kernel.getServiceAsync('objectql'); const item = await read(); // The editor's PUT body: the effective item without its read decorations. diff --git a/packages/qa/dogfood/test/showcase-public-form-redirect.dogfood.test.ts b/packages/qa/dogfood/test/showcase-public-form-redirect.dogfood.test.ts index adc82aed85a..9e5929bc76f 100644 --- a/packages/qa/dogfood/test/showcase-public-form-redirect.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-public-form-redirect.dogfood.test.ts @@ -30,6 +30,10 @@ // plugin needs to mount. The plugin is imported as SOURCE by relative path, // like the other package-internal reads in this suite, so the verdict is about // this checkout and not about the last build of `@objectstack/cli`. +// +// [#28576] ADR-0146 D11 closed the package-less write onto a packaged item, so +// the editor's save goes into the showcase's `.custom` pair, declaring the form +// it overrides; the anonymous doors serve the pair's override. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; @@ -37,6 +41,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { type VerifyStack } from '@objectstack/verify'; import { bootShowcase } from './showcase-boot.js'; +import { declaredOverride, ensurePair, inPair } from './customization-pair.js'; import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; import { createConsoleStaticPlugin } from '../../../cli/src/utils/console.js'; @@ -44,6 +49,7 @@ const SLUG = 'contact-us'; const AUTHORED = `/forms/${SLUG}`; const PAGE = `/_console/f/${SLUG}`; const VIEW = '/meta/view/showcase_inquiry.contact'; +const BASE = 'com.example.showcase'; const SYS = { isSystem: true } as const; const BUNDLE_MARKER = 'console-bundle-22079'; @@ -92,13 +98,14 @@ describe('showcase: the authored public form path redirects to the console form const saveSharing = async (patch: Record) => { const body = structuredClone(published); Object.assign(body.config.sharing, patch); - const res = await stack.apiAs(admin, 'PUT', VIEW, body); + const res = await stack.apiAs(admin, 'PUT', inPair(VIEW, BASE), declaredOverride(body, BASE, '0.1.0')); expect(res.status, await res.clone().text()).toBe(200); }; beforeAll(async () => { stack = await boot(); admin = await stack.signIn(); + await ensurePair(stack, admin, BASE); ql = await stack.kernel.getServiceAsync('objectql'); const res = await stack.apiAs(admin, 'GET', VIEW); expect(res.status).toBe(200); diff --git a/packages/qa/dogfood/test/showcase-public-form-walled-intake.dogfood.test.ts b/packages/qa/dogfood/test/showcase-public-form-walled-intake.dogfood.test.ts index fff2eac21ce..5237156814e 100644 --- a/packages/qa/dogfood/test/showcase-public-form-walled-intake.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-public-form-walled-intake.dogfood.test.ts @@ -18,13 +18,19 @@ // The control (a form bound to a `tenancy: { enabled: false }` object on the // same walled posture is accepted, and its admin read carries no warning) is // `public-form-withdrawal-walled.dogfood.test.ts`. +// +// [#28576] ADR-0146 D11 closed the package-less write onto a packaged item, so +// the editor's save goes into the showcase's `.custom` pair, declaring the form +// it overrides; the anonymous doors serve the pair's override. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { type VerifyStack } from '@objectstack/verify'; import { bootShowcase } from './showcase-boot.js'; +import { declaredOverride, ensurePair, inPair } from './customization-pair.js'; import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; const VIEW = '/meta/view/showcase_inquiry.contact'; +const BASE = 'com.example.showcase'; const SYS = { isSystem: true } as const; describe('showcase, walled posture: the public contact form is not offered, and the admin read says why', () => { @@ -59,7 +65,7 @@ describe('showcase, walled posture: the public contact form is not offered, and const saveAllowAnonymous = async (published: Record, allowAnonymous: boolean) => { const body = structuredClone(published); body.config.sharing.allowAnonymous = allowAnonymous; - const res = await stack.apiAs(admin, 'PUT', VIEW, body); + const res = await stack.apiAs(admin, 'PUT', inPair(VIEW, BASE), declaredOverride(body, BASE, '0.1.0')); expect(res.status, await res.clone().text()).toBe(200); }; @@ -69,6 +75,7 @@ describe('showcase, walled posture: the public contact form is not offered, and security: new SecurityPlugin({ defaultPermissionSets: [...securityDefaultPermissionSets] }), }); admin = await stack.signIn(); + await ensurePair(stack, admin, BASE); ql = await stack.kernel.getServiceAsync('objectql'); }, 180_000); diff --git a/packages/qa/dogfood/test/showcase-public-form-withdrawal-layers.dogfood.test.ts b/packages/qa/dogfood/test/showcase-public-form-withdrawal-layers.dogfood.test.ts index 2c4f08f5e47..f682dd612b3 100644 --- a/packages/qa/dogfood/test/showcase-public-form-withdrawal-layers.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-public-form-withdrawal-layers.dogfood.test.ts @@ -27,13 +27,19 @@ // at the same /meta door (#26571); // - open environment-wide (control): both doors accept, and the row lands in // the organization the doors resolve. +// +// [#28576] ADR-0146 D11 closed the package-less write onto a packaged item, so +// the editor's save goes into the showcase's `.custom` pair, declaring the form +// it overrides; the anonymous doors serve the pair's override. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { type VerifyStack } from '@objectstack/verify'; import { bootShowcase } from './showcase-boot.js'; +import { declaredOverride, ensurePair, inPair } from './customization-pair.js'; import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; const VIEW = '/meta/view/showcase_inquiry.contact'; +const BASE = 'com.example.showcase'; const SYS = { isSystem: true } as const; describe('showcase: a public form withdrawal at any metadata layer holds', () => { @@ -72,7 +78,7 @@ describe('showcase: a public form withdrawal at any metadata layer holds', () => const save = async (allowAnonymous: boolean) => { const body = structuredClone(published); body.config.sharing.allowAnonymous = allowAnonymous; - const res = await stack.apiAs(admin, 'PUT', VIEW, body); + const res = await stack.apiAs(admin, 'PUT', inPair(VIEW, BASE), declaredOverride(body, BASE, '0.1.0')); return { status: res.status, json: (await res.json()) as Record }; }; @@ -88,6 +94,7 @@ describe('showcase: a public form withdrawal at any metadata layer holds', () => security: new SecurityPlugin({ defaultPermissionSets: [...securityDefaultPermissionSets] }), }); admin = await stack.signIn(); + await ensurePair(stack, admin, BASE); ql = await stack.kernel.getServiceAsync('objectql'); const res = await stack.apiAs(admin, 'GET', VIEW); expect(res.status).toBe(200); diff --git a/packages/qa/dogfood/test/showcase-public-form-withdrawal.dogfood.test.ts b/packages/qa/dogfood/test/showcase-public-form-withdrawal.dogfood.test.ts index 9955d487c59..5bd45b316ba 100644 --- a/packages/qa/dogfood/test/showcase-public-form-withdrawal.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-public-form-withdrawal.dogfood.test.ts @@ -24,13 +24,19 @@ // admin WITH an active organization is environment-wide too: the doors carry // no organization into a metadata write. The intake row still lands in the // organization the anonymous form door resolves (the Default Organization). +// +// [#28576] ADR-0146 D11 closed the package-less write onto a packaged item, so +// the editor's save goes into the showcase's `.custom` pair, declaring the +// form it overrides; the anonymous doors serve the pair's override. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { type VerifyStack } from '@objectstack/verify'; import { bootShowcase } from './showcase-boot.js'; +import { declaredOverride, ensurePair, inPair } from './customization-pair.js'; import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; const VIEW = '/meta/view/showcase_inquiry.contact'; +const SHOWCASE = 'com.example.showcase'; const SYS = { isSystem: true } as const; interface Probe { @@ -74,7 +80,7 @@ describe('showcase: withdrawing the public contact form closes every intake door if (v === undefined) delete body.config.sharing[k]; else body.config.sharing[k] = v; } - const res = await stack.apiAs(admin, 'PUT', VIEW, body); + const res = await stack.apiAs(admin, 'PUT', inPair(VIEW, SHOWCASE), declaredOverride(body, SHOWCASE, '0.1.0')); const json = (await res.json()) as { message?: string }; expect(res.status, JSON.stringify(json)).toBe(200); return String(json.message ?? ''); @@ -86,6 +92,7 @@ describe('showcase: withdrawing the public contact form closes every intake door security: new SecurityPlugin({ defaultPermissionSets: [...securityDefaultPermissionSets] }), }); admin = await stack.signIn(); + await ensurePair(stack, admin, SHOWCASE); ql = await stack.kernel.getServiceAsync('objectql'); const res = await stack.apiAs(admin, 'GET', VIEW); expect(res.status).toBe(200); diff --git a/packages/qa/dogfood/test/view-container-cross-package-default.dogfood.test.ts b/packages/qa/dogfood/test/view-container-cross-package-default.dogfood.test.ts index a0951f8f9a6..a8bb3925888 100644 --- a/packages/qa/dogfood/test/view-container-cross-package-default.dogfood.test.ts +++ b/packages/qa/dogfood/test/view-container-cross-package-default.dogfood.test.ts @@ -110,14 +110,25 @@ describe('dogfood: a bare-list container on another package\'s object leaves its expect(defaults.map((r) => r.name), 'one list default, the packaged one').toEqual([DEFAULT]); }; - it('control, first: the sanctioned override — a write to showcase_task.default by name — reaches both doors', async () => { + it('control, first: the sanctioned override — a write to showcase_task.default by name in its pair — reaches both doors', async () => { // First, on the pristine stack, so no other case's residue can decide it. - const saved = await stack.apiAs(token, 'PUT', `/meta/view/${DEFAULT}`, { + const body = { name: DEFAULT, object: OBJECT, viewKind: 'list', label: 'Overridden', config: { type: 'grid', columns: [{ field: 'title' }] }, + }; + // [#28576, ADR-0146 D11] The write naming no package onto the packaged + // default is closed, naming the base's pair; the override is written there. + const closed = await stack.apiAs(token, 'PUT', `/meta/view/${DEFAULT}`, body); + expect(closed.status).toBe(403); + expect(await closed.text()).toContain(`'${SHOWCASE}.custom' (?package=${SHOWCASE}.custom)`); + const pair = await stack.apiAs(token, 'POST', `/packages/${SHOWCASE}/ensure-customization`); + expect([200, 201]).toContain(pair.status); + const saved = await stack.apiAs(token, 'PUT', `/meta/view/${DEFAULT}?package=${SHOWCASE}.custom`, { + ...body, + overrides: { package: SHOWCASE, basedOn: '0.1.0' }, }); expect(saved.status).toBe(200); @@ -129,7 +140,7 @@ describe('dogfood: a bare-list container on another package\'s object leaves its expect(read.config).toEqual(listed[0].config); // Deleting the override hands both doors back the packaged default. - const deleted = await stack.apiAs(token, 'DELETE', `/meta/view/${DEFAULT}`); + const deleted = await stack.apiAs(token, 'DELETE', `/meta/view/${DEFAULT}?package=${SHOWCASE}.custom`); expect(deleted.status).toBe(200); await expectDefaultUnchangedOnBothDoors(); }); diff --git a/packages/runtime/src/standalone-stack-artifact-reload-registry-refresh.test.ts b/packages/runtime/src/standalone-stack-artifact-reload-registry-refresh.test.ts index c3138cf4006..d7cf40112dc 100644 --- a/packages/runtime/src/standalone-stack-artifact-reload-registry-refresh.test.ts +++ b/packages/runtime/src/standalone-stack-artifact-reload-registry-refresh.test.ts @@ -49,7 +49,8 @@ const PACKAGE_ID = 'com.test.artifact-reload'; /** One artifact, every display string carrying `v`. */ const artifact = (v: string) => ({ - manifest: { id: PACKAGE_ID, name: 'artifact_reload', type: 'app', version: '1.0.0' }, + // [#28576] `namespace`: the base's `.custom` pair is formed from it (ADR-0146 D3). + manifest: { id: PACKAGE_ID, name: 'artifact_reload', type: 'app', version: '1.0.0', namespace: 'arl' }, objects: [{ name: 'arl_note', label: `Note ${v}`, @@ -208,7 +209,10 @@ describe('an artifact hot reload refreshes every read of every item the boot reg expect(await read()).toEqual(boot); }, BOOT_TIMEOUT); - it('an environment save of the template still wins on every read after a reload re-registers the package', async () => { + // [#28576, ADR-0146 D11] The environment's customization of a packaged + // template is written into the base's pair, declaring what it overrides: the + // package-less save onto it is closed. + it('a customization of the template saved into its pair still wins on every read after a reload re-registers the package', async () => { const { protocol, reload } = await bootOverArtifact(); const subjects = async () => { const pick = (res: any) => (res?.items ?? []).filter((i: any) => i?.name === 'arl_mail').map((i: any) => i.subject); @@ -218,10 +222,15 @@ describe('an artifact hot reload refreshes every read of every item the boot reg byName: (await protocol.getMetaItem({ type: 'email_template', name: 'arl_mail' }))?.item?.subject, }; }; + await protocol.ensureCustomizationPackage({ basePackageId: PACKAGE_ID }); await protocol.saveMetaItem({ type: 'email_template', name: 'arl_mail', - item: { name: 'arl_mail', label: 'Mail', locale: 'en-US', subject: 'Saved subject', bodyHtml: '

saved

' }, + packageId: `${PACKAGE_ID}.custom`, + item: { + name: 'arl_mail', label: 'Mail', locale: 'en-US', subject: 'Saved subject', bodyHtml: '

saved

', + overrides: { package: PACKAGE_ID, basedOn: '1.0.0' }, + }, }); const saved = { list: ['Saved subject'], execution: ['Saved subject'], byName: 'Saved subject' }; // Positive control: the save is live before any reload. diff --git a/packages/spec/src/migrations/entries/semantic/18.packageless-overlay-write-closed.ts b/packages/spec/src/migrations/entries/semantic/18.packageless-overlay-write-closed.ts new file mode 100644 index 00000000000..85033dda2c5 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.packageless-overlay-write-closed.ts @@ -0,0 +1,59 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// ADR-0146 D11 — a runtime narrowing at the metadata write doors with no +// authorable key: a write naming no package onto an item an installed package +// ships is refused where `os migrate packageless-overlays` can move its row. +// Registered because an upgrading deployment can carry the package-less Setup +// rows those writes made, and the operator step that moves them into the +// base's `.custom` pair ships in this same release, so it runs AFTER the +// upgrade (the release-timing ruling on the card). +export const entry: SemanticMigration = { + id: 'packageless-overlay-write-closed', + surface: + 'a metadata write that names no package (PUT, DELETE, publish or rollback under ' + + '/api/v1/meta// with no ?package=, and the metadata item page, in Studio\'s All ' + + 'metadata area where Setup\'s metadata URLs now land, when its URL holds no ?package=) onto a ' + + 'view, dashboard, report, translation or email template that one installed read-only package ' + + 'ships, where `os migrate packageless-overlays` would move the row into that package\'s ' + + 'paired `.custom`; and the package-less sys_metadata rows Setup\'s form stored that way', + replacement: + 'after upgrading, run `os migrate packageless-overlays` (preview) on each deployment with ' + + 'Setup customizations, with the flags and environment the deployment boots with (`--preset` ' + + 'and `--dev` mean what they mean to `os serve`), then `os migrate packageless-overlays ' + + '--apply`: it moves each `movable` row into the base\'s paired `.custom`, declaring ' + + '`overrides: { package: , basedOn: }`. From then on write that ' + + 'customization into the pair: `PUT /api/v1/meta//?package=.custom` with the ' + + '`overrides` declaration in the body, and `DELETE /api/v1/meta//?package=' + + '.custom` to restore the package original. Every row the preview lists and does not ' + + 'move keeps its package-less write: `multi_shipped` (several packages ship the name): delete ' + + 'it naming no package, then save it into the pair of the package it customizes if it is still ' + + 'wanted (the pair\'s override is then served at that base\'s address, `?package=`); ' + + '`no_pair` (the base\'s id forms no pair): nothing to do; `foreign_declaration` (its ' + + '`overrides` names another package): remove the key with a save naming no package, then ' + + 're-run: the move declares the right override; `shadowed` (the pair already holds the item; ' + + 'the pair\'s row is served when it declares the override, otherwise this row still is): ' + + 'nothing to do to keep the pair\'s declared override, or restore the package original (the ' + + 'DELETE above) and re-run to move this one; a `failed` move: fix what the error names and ' + + 're-run; when the error names the pair (it cannot be ensured, or it refuses the row, such as ' + + 'an email template locale the base does not ship) or the base\'s missing version, the row ' + + 'may also be left, as it keeps its package-less write', + reason: + 'A customization of an installed package lives in its paired `.custom` package, where it is ' + + 'a declared override that is exported with the pair and judged against the base\'s version ' + + '(ADR-0146 D1, D2, D11). The package-less write made a row of no package instead, which no ' + + 'export carries. Rows already stored that way are still served unchanged, so nothing breaks ' + + 'at the upgrade. Between the upgrade and `--apply`, every customization is served, and the ' + + 'package-less save, publish, delete or rollback of a movable one is refused 403 ' + + 'NOT_OVERRIDABLE naming the pair and this command. The close reaches only what the command ' + + 'moves (its own classification and its own move steps), so no customization is left without ' + + 'a write door. No conversion applies: the rows are stored data, not authored source, and the ' + + 'move goes through the save door. ADR-0146, ADR-0087.', + acceptanceCriteria: + 'On the deployment\'s database and configuration, with its boot flags and environment, the ' + + 'preview of `os migrate packageless-overlays` lists no movable row. Each customization is then ' + + 'served exactly as before, and a save of a moved one with `?package=.custom` and its ' + + '`overrides` declaration succeeds where the same save naming no package answers 403 ' + + 'NOT_OVERRIDABLE.', +}; diff --git a/skills/objectstack-platform/SKILL.md b/skills/objectstack-platform/SKILL.md index fdbd68c4caa..0a84d6329f6 100644 --- a/skills/objectstack-platform/SKILL.md +++ b/skills/objectstack-platform/SKILL.md @@ -129,7 +129,7 @@ to a new protocol major — never to silence a mismatch. **`manifest.namespace` (ADR-0048):** Optional, but **enforced once set**. When a package declares `manifest.namespace: 'crm'`, every `object.name` must start with `crm_` or `defineStack` errors (`validateNamespacePrefix` in `@objectstack/spec`); the legacy `__` double-underscore form is rejected, and `sys_`-prefixed names are platform-reserved and exempt. The namespace is also a package-ownership key — installing two packages that both claim `crm` fails with `NamespaceConflictError` (downgrade to a warning with `OS_METADATA_COLLISION=warn`). `os lint` additionally emits a non-fatal `naming/namespace-prefix` warning for bare-named UI/automation items (app, page, dashboard, flow, action, report, dataset) when a namespace is set. -**Customizing an installed package (ADR-0146):** Customize an installed base `hotcrm` in its paired writable package `hotcrm.custom`, which depends on it: a same-named item there with `overrides: { package: 'hotcrm', basedOn: '3.2.0' }` (its `manifest.id`/`manifest.version`) replaces the base's document whole. The read honours it only from the pair (the row's `package_id`), today only for a view, dashboard, report, email_template, translation or flow (the rest of `OVERRIDABLE_METADATA_TYPES` wait for their doors), never judges `basedOn` and ignores any other package's declaration. An email template is one item per (name, locale): an override names a locale the base ships; a locale the base does not ship is added without `overrides`. Then no `?package`, `?package=hotcrm`, the layered read and both lists serve it; the env-wide list holds one item per name one code package ships (a dependent's stays under its own `?package=`); a package-unbound Setup row still wins over the base's until D11. The base's address serves the pair's added locale too (both lists, the layered read with `code` null and `effective` the pair's, the execution list, a send in that locale); a locale a dependent adds under the base's name is served only under its own `?package=`, never by a base-named or package-less read. An undeclared pair row or a dependent's row at a locale the base ships is an orphan in the layered read's `overlay`, `effective` = the base's. An override may narrow a public form's anonymous intake, never re-open one any honoured body withdrew. `GET /packages/hotcrm.custom/export` carries it with `overrides`; the base's is unchanged. +**Customizing an installed package (ADR-0146):** Customize an installed base `hotcrm` in its paired writable package `hotcrm.custom`, which depends on it: a same-named item there with `overrides: { package: 'hotcrm', basedOn: '3.2.0' }` (its `manifest.id`/`manifest.version`) replaces the base's document whole. The read honours it only from the pair (the row's `package_id`), today only for a view, dashboard, report, email_template, translation or flow, never judges `basedOn` and ignores any other package's declaration. An email template is one item per (name, locale): an override names a locale the base ships; a locale the base does not ship is added without `overrides`. Then no `?package`, `?package=hotcrm`, the layered read and both lists serve it; the env-wide list holds one item per name one code package ships (a dependent's stays under its own `?package=`); a pre-D11 unbound Setup row wins over the base's until `os migrate packageless-overlays` moves it, and its package-less save, reset and rollback are refused once movable (D11). The base's address serves the pair's added locale too (both lists, the layered read with `code` null and `effective` the pair's, the execution list, a send in that locale); a locale a dependent adds under the base's name is served only under its own `?package=`, never by a base-named or package-less read. An undeclared pair row or a dependent's row at a locale the base ships is an orphan in the layered read's `overlay`, `effective` = the base's. An override may narrow a public form's anonymous intake, never re-open one any honoured body withdrew. `GET /packages/hotcrm.custom/export` carries it with `overrides`. --- diff --git a/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.packagelessWriteClosed-28576.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.packagelessWriteClosed-28576.test.tsx new file mode 100644 index 00000000000..a324f5b2e3b --- /dev/null +++ b/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.packagelessWriteClosed-28576.test.tsx @@ -0,0 +1,197 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#28576 (ADR-0146 D11) — Setup's item page over a PACKAGED item + * shows the server's refusal of its package-less write as sent. + * + * The server closed the package-less write onto an item a code package ships: + * a save or a reset naming no package is refused `403 NOT_OVERRIDABLE`, and + * the sentence names the base's paired `.custom` package and the + * `os migrate packageless-overlays` ceremony — the two things the + * administrator can do. Setup's overlay page stays until its route retires + * (Studio is the editor), so what it owes is to relay that sentence whole, in + * the server's words: its error line leads with the first sentence and keeps + * the full text behind "More" (objectui#13332). + * + * Pinned over the REAL page and a REAL `MetadataClient`, whose transport is an + * in-memory server answering the `/meta` doors as the framework does after + * the close: the packaged item's layers, and a `403` for each write that names + * no package. Both the Save (a `PUT` with no `package`) and the Reset (a + * `DELETE` with no `package`) are asserted to have reached the server with no + * `package` key, so the refusal read back is the one that write was given. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { z } from 'zod'; +import { ComponentRegistry } from '@object-ui/core'; +import { DocSchema } from '@objectstack/spec/system'; +import { MetadataClient } from '@object-ui/data-objectstack'; + +const toasts = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() })); +vi.mock('sonner', () => ({ toast: toasts })); + +const NAME = 'shipped_guide'; +const BASE = 'com.acme.handbook'; +const PAIR = `${BASE}.custom`; + +const doc = (content: string) => ({ name: NAME, label: NAME, content, group: 'reference' }); + +/** The server's sentences, worded as the framework words them; read back verbatim below. */ +const CLOSED = + 'A write naming no package is closed where `os migrate packageless-overlays` can move its row (ADR-0146 D11); ' + + `a package-less row stored before it is served until that command moves it into '${PAIR}'.`; +const SAVE_CLOSED = + `'doc/${NAME}' ships in '${BASE}': save it into '${PAIR}' (?package=${PAIR}), declaring ` + + `overrides: { package: '${BASE}', basedOn }. ${CLOSED}`; +const RESET_CLOSED = `'doc/${NAME}' ships in '${BASE}': remove its customization from '${PAIR}' (?package=${PAIR}). ${CLOSED}`; + +const server = { + /** The packaged item's stored package-less row, as an unmigrated deployment holds it. */ + overlay: doc('# Customized') as Record, + writes: [] as Array<{ method: string; query: URLSearchParams }>, +}; + +const json = (status: number, body: unknown) => + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); + +async function serve(input: RequestInfo | URL, init?: RequestInit): Promise { + const method = (init?.method ?? 'GET').toUpperCase(); + const url = new URL(String(input), 'http://console.test'); + const q = url.searchParams; + const meta = url.pathname.match(/^\/api\/v1\/meta\/(.+)$/); + if (!meta) return json(200, []); + const seg = meta[1].split('/').map(decodeURIComponent); + if (seg[0] === '_drafts') return json(200, { drafts: [] }); + if (seg.length === 1) return json(200, { items: [] }); + if (seg.length === 3 && seg[2] === 'layers') { + const code = { ...doc('# Shipped'), _packageId: BASE, _provenance: 'package' }; + return json(200, { + code, overlay: server.overlay, overlayScope: 'env', effective: server.overlay, + provenance: 'package', packageId: BASE, editable: true, deletable: true, resettable: true, lock: 'none', + }); + } + if (seg.length === 3 && seg[2] === 'references') return json(200, []); + if (seg.length === 2 && (method === 'PUT' || method === 'DELETE')) { + server.writes.push({ method, query: new URLSearchParams(q) }); + // After the close: a write naming no package onto this packaged item is refused. + if (!q.get('package')) { + return json(403, { error: method === 'PUT' ? SAVE_CLOSED : RESET_CLOSED, code: 'NOT_OVERRIDABLE' }); + } + return json(200, { success: true }); + } + if (seg.length === 2) { + if (q.get('state') === 'draft') return json(404, { error: { code: 'NO_DRAFT', message: 'absent' } }); + return json(200, { type: seg[0], name: seg[1], item: server.overlay }); + } + return json(200, {}); +} + +const client = vi.hoisted(() => ({ current: null as unknown })); + +vi.mock('./useMetadata', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + useMetadataClient: () => client.current, + useMetadataTypes: () => ({ + loading: false, + entries: [ + // An overlay-able type as the registry serves the five: the page opens + // the packaged item for editing on this flag (objectui#12141). + { type: 'doc', label: 'Documentation', domain: 'system', allowEnvironmentOverlay: true, allowRuntimeCreate: true, schema: DOC_JSON_SCHEMA }, + ], + }), + }; +}); + +import { MetadataResourceEditPage } from './ResourceEditPage'; +import { registerDefaultMetadataSchemas } from './default-schemas'; +import { registerBuiltinPreviews } from './previews'; + +const DOC_JSON_SCHEMA = z.toJSONSchema(DocSchema as unknown as z.ZodType) as Record; +client.current = new MetadataClient({ baseUrl: '', fetch: vi.fn(serve) as unknown as typeof fetch }); + +registerDefaultMetadataSchemas(); +registerBuiltinPreviews(); + +const STUB_NS = 'packageless-write-closed-28576'; +function StubMarkdown({ schema }: { schema: { content?: string } }) { + return
{schema.content}
; +} +beforeAll(() => { + ComponentRegistry.register('markdown', StubMarkdown as never, { namespace: STUB_NS }); +}); +afterAll(() => { + ComponentRegistry.unregister('markdown', STUB_NS); +}); + +beforeEach(() => { + server.overlay = doc('# Customized'); + server.writes = []; + for (const fn of Object.values(toasts)) fn.mockClear(); + window.localStorage.setItem('metadata-admin:autosave', '0'); +}); +afterEach(() => { + cleanup(); + window.localStorage.removeItem('metadata-admin:autosave'); + window.history.replaceState(null, '', '/'); +}); + +async function settle() { + await act(async () => { + for (let i = 0; i < 20; i++) await new Promise((r) => setTimeout(r, 0)); + }); +} + +async function open() { + const path = `/metadata/doc/${NAME}`; + window.history.replaceState(null, '', path); + render( + + + } /> + list} /> + + , + ); + const source = await screen.findByRole('textbox', { name: 'Markdown source' }, { timeout: 8000 }); + await settle(); + return source; +} + +/** The page's error line, opened whole: the server's sentence as sent. */ +async function refusalShown(): Promise { + fireEvent.click(await screen.findByTestId('refusal-more')); + return screen.getByTestId('refusal-detail').textContent ?? ''; +} + +describe('Setup shows the server\'s refusal of a package-less write onto a packaged item as sent (objectstack#28576)', () => { + it('Save: the PUT names no package, and the refusal is the page\'s error line, word for word', async () => { + const source = await open(); + fireEvent.change(source, { target: { value: '# Customized again' } }); + fireEvent.click(screen.getByRole('button', { name: /Save \(⌘S\)/ })); + + await waitFor(() => expect(server.writes.filter((w) => w.method === 'PUT')).toHaveLength(1)); + await settle(); + expect(server.writes[0]!.query.has('package')).toBe(false); + expect(await refusalShown()).toBe(SAVE_CLOSED); + expect(toasts.success).not.toHaveBeenCalled(); + }); + + it('Reset: the DELETE names no package, and the refusal is the page\'s error line, word for word; the page stays', async () => { + await open(); + fireEvent.click(screen.getByTestId('reset-or-delete-button')); + const dialog = await screen.findByRole('alertdialog'); + fireEvent.click(within(dialog).getByTestId('resource-edit-confirm-action')); + + await waitFor(() => expect(server.writes.filter((w) => w.method === 'DELETE')).toHaveLength(1)); + await settle(); + expect([...server.writes[0]!.query.keys()]).toEqual([]); + expect(await refusalShown()).toBe(RESET_CLOSED); + expect(screen.queryByTestId('list-view')).toBeNull(); + }); +}); diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx index cc0162af5e9..3170d564cc0 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -174,7 +174,6 @@ import { Clock, MousePointerClick, CornerDownRight, - Layers, FileQuestion, PackageX, Library, @@ -283,7 +282,7 @@ import { isStudioOrgScope, studioOrgScopePath, } from './studioScope.js'; -import { STUDIO_ALL_PACKAGES_SEGMENT, STUDIO_METADATA_AREA, studioMetadataAreaPath, studioPillarItemPath } from './metadataArea.js'; +import { STUDIO_METADATA_AREA, studioMetadataAreaPath, studioPillarItemPath } from './metadataArea.js'; import { PermissionSetCloneDialog } from '../metadata-admin/PermissionSetCloneDialog.js'; import type { PermissionSetCloned } from '../metadata-admin/permission-set-clone.js'; import { useWalledCatalogReadOnly } from '../metadata-admin/catalog-scope.js'; @@ -881,17 +880,14 @@ const PACKAGE_LIST_TOAST_ID = 'studio-package-list'; * chip, and this mark at the right of the open item's header, in every * pillar. The pillar's read-only sentence (objectui#11781) is its title. * - * It offers an environment overlay only where one exists: when the open - * item's metadata type declares `allowEnvironmentOverlay` in the served - * registry (ADR-0005), it is a link to that item's page in Studio's "All - * metadata" area, where the overlay is edited: the page Setup's item route - * drew, which ADR-0146 D1 moved into Studio, on its cross-package leg. Any - * other item, no open item, or a registry not yet read draws a plain - * "Read-only" mark that links nowhere. - * - * objectstack#28562 (ADR-0146 D2) — nor does a view, dashboard or report - * (`isStudioCustomizeType`): Studio customizes those itself, into the base's - * paired customization package, so Setup's overlay page is not their editor. + * objectstack#28576 (ADR-0146 D11) — a plain "Read-only" mark that links + * nowhere, for every item of a read-only package. It used to link an item of + * an `allowEnvironmentOverlay` type to its overlay page (Setup's item page, + * later that page in Studio's "All metadata" area), whose save names no + * package; the server refuses that write onto a packaged item now, and a customization lives in the base's paired + * `.custom` package instead (Studio customizes a view, dashboard or report + * there itself, objectstack#28562). So the mark offers no overlay, whatever + * the type's registry flag says. `item` stays in the props the pillars pass. * * objectui#12707 — at a tab row's end, the mark folds to its glyph when the * row cannot hold it whole (`useStudioTabRowFold`), rather than being cut @@ -899,7 +895,6 @@ const PACKAGE_LIST_TOAST_ID = 'studio-package-list'; * and its title stays its tooltip. */ export function StudioReadOnlyLink({ - item, title, locale, }: { @@ -908,50 +903,23 @@ export function StudioReadOnlyLink({ title: string; locale: string; }): React.ReactElement { - const client = useMetadataClient(); - const { entries } = useMetadataTypes(client); - const overlayable = - item !== null && - !isStudioCustomizeType(item.type) && - entries.some((e) => e.type === item.type && e.allowEnvironmentOverlay === true); - const ref = React.useRef(null); - const label = t(overlayable ? 'engine.studio.pkg.readonlyLink' : 'engine.studio.pkg.readonly', locale); + const ref = React.useRef(null); + const label = t('engine.studio.pkg.readonly', locale); const folded = useStudioTabRowFold(ref, label); const className = cn( 'inline-flex h-7 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-md bg-amber-400/15 text-xs font-medium text-amber-700 dark:text-amber-300', - overlayable && 'hover:bg-amber-400/25', folded ? 'w-7 justify-center' : 'px-2.5', ); - const body = ( - <> - {overlayable ? ( -