diff --git a/.changeset/23165-direct-save-resolves-package.md b/.changeset/23165-direct-save-resolves-package.md new file mode 100644 index 00000000000..c33759778b2 --- /dev/null +++ b/.changeset/23165-direct-save-resolves-package.md @@ -0,0 +1,35 @@ +--- +'@objectstack/metadata-protocol': minor +'@objectstack/spec': minor +'@objectstack/rest': minor +--- + +fix(metadata-protocol)!: a metadata save or delete that names no package addresses the row of the one package the item belongs to, and a name two or more packages hold is refused `409 RESOURCE_CONFLICT`; the delete door can name its package (ADR-0048, ADR-0070) + +Clause-②: yes (narrowing) + + + +**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + +A `sys_metadata` row is keyed by its package (ADR-0048), so one item can hold one row per package. A save that names a package (`?package=` on the REST door, `packageId` in process) writes that package's row. A save that named none always wrote the package-unbound row, even for an item a package owns, and a delete named no package at all: it removed whichever row of the item the store returned first. The draft save already resolved this: a draft that names no package is stored in the package of the item's active row. + +**FROM.** Save an item into a package (`PUT /api/v1/meta/app/my_app?package=com.acme.app`), then save it again without `?package=`: the second save answered `200` "Saved app 'my_app' (env-wide, state=active)" and wrote a second, package-less row beside the package's row. The item read kept serving the package's row, so nothing the save carried took effect, and a console "Disable" reported success while the app stayed enabled. The same held for the API, the CLI and agents, and for an overlay row bound to a code package. A `DELETE /api/v1/meta/app/my_app` of an item two packages hold removed one of the two rows, the other was then served, and the receipt said "it no longer exists". Uninstalling a package, or discarding its drafts, deleted each of its items by name the same way, so it could remove another package's row of the same name and leave its own. + +**TO.** A direct save, or a delete, that names no package resolves its address from the item's rows (the active rows for a save and a delete, the draft rows for a draft discard): + +- **Bound to one package.** The save writes that package's row, exactly as a save naming the package does, and the item read serves it next; the delete removes that row. The receipts name the package: "Saved app 'my_app' (env-wide, package=com.acme.app, state=active)" and "Deleted app 'my_app' (package=com.acme.app) — it no longer exists." Every check on the row the save writes reads that package: its stored lock, a stored credential carried forward, the optimistic lock, and the writable-base rule. The item read that names no package serves that row's `version`, so a read followed by a save or a delete with the served `If-Match` is accepted. +- **Bound to two or more packages.** The save or delete is refused `409 RESOURCE_CONFLICT` and changes nothing. The message names the packages and the remedy: name one with `?package=`. +- **Bound to none.** The save writes, and the delete removes, the package-unbound row, as before, and the receipts read as before. + +The delete door gains the address a save has: `DELETE /api/v1/meta/:type/:name?package=` (`packageId` in process, `DeleteMetaItemRequestSchema.packageId`) removes that package's row and no other; `all` and an empty value name no package, as on the save door. A repeated `?package=` is refused `400`. A delete receipt now says when another row of the item is still stored, naming it, instead of claiming the item is gone or back at its artifact default. Uninstalling a package and discarding its drafts name the package, so each removes only its own rows. + +**Also refused now**, each as the same save naming the package is refused: a package-less save of an item held by a read-only package (a code package's runtime-only item, or an installed `system`/`cloud` package's) answers `422 WRITABLE_PACKAGE_REQUIRED`; and a package-less save asserting a create (`If-None-Match: *`) of an item a package holds answers `409 METADATA_CONFLICT`. + +`packageId: null` in process names no package too, as the request contracts state. The server-side rewrites keep the row they name: `os migrate meta --stored --apply` and the stored flow credential move pass `null` for a package-unbound row they rewrite, and that row is still the one written; package duplication names its target package. + +**Unchanged.** A save or a delete that names a package exactly. A draft save, which keeps its own inheritance. An item a code package ships with no stored row: the package-less save still writes the package-unbound overlay, and the read serves it. Package-less rows already stored beside a package's row stay as they are; none is converted. A package-less delete of such an item removes the package's row and its receipt says the package-less row remains. + +**Remedy.** For an item two packages hold, send the save or the delete with `?package=` naming the package whose row it edits or removes. A receipt now names the package when the write addressed a package's row; a client that compares receipt text should expect `, package=` after `env-wide` on a save and ` (package=)` after the item on a delete. + +`@objectstack/spec`: the `SaveMetaItemRequestSchema.packageId` description now states what an `active` save that names no package writes, where it said the env-local overlay; `DeleteMetaItemRequestSchema` declares the optional `packageId`. `@objectstack/rest`: `package` joins the reset door's closed query set (`META_ITEM_RESET_PARAMS`). diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index ae267b96749..b15cd76bbc7 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -729,6 +729,7 @@ A write-path strip event: caller-supplied fields legally dropped from the payloa | **state** | `Enum<'active' \| 'draft'>` | optional | Which lifecycle row to discard: `draft` discards the pending draft overlay only (the still-active overlay, if any, keeps serving); `active` or absent resets the live row. Absent defaults to `active`. | | **dropStorage** | `boolean` | optional | Destructive opt-in, default false: also drop the object's physical table after the metadata row is removed (`object` type + `active` state only; never `sys_` tables). Used by the "discard a previewed object" flow so a publish-to-preview leaves no orphan table. | | **member** | `string` | optional | Bundle member selector, for a metadata type whose identity is a pair (today only `email_template`, identified by `(name, locale)`): the member this request addresses, spelled as its discriminator value (`zh-CN`). Absent, the canonical member (`en-US` for `email_template`), which is what a bare-name read answers; naming the canonical value is the same request. It is not the response translation locale, which `?locale=` and `Accept-Language` choose. Refused with 400 `VALIDATION_ERROR` on a type that has no members, and for a blank value or one longer than a stored member can be (100 characters). A well-formed member with nothing stored or shipped answers as that door answers an absent item. | +| **packageId** | `string \| null` | optional | ADR-0048 — the software package whose row this delete removes (`sys_metadata.package_id`; `?package=` on the REST door, where `all` and an empty value name no package), as a save names the row it writes. Named, the delete removes that package's row and no other; a package that holds no row removes nothing. Absent or `null` names no package: the delete then removes the row of the one package the item's rows at the deleted `state` are bound to, is refused `409 RESOURCE_CONFLICT` when they are bound to two or more, and with none removes the env-local row (no package stamp). That row is the one `parentVersion` is judged against. When another row of the item is still stored afterwards, the receipt `message` names it. | --- @@ -2622,7 +2623,7 @@ Installed package with runtime lifecycle state | **actor** | `string` | optional | Identity recorded on the write's history event (`recorded_by`, a lookup into `sys_user`) and audit row. On the REST door this is the request's authenticated identity (one producer) — never a caller-supplied header. Absent, the event is recorded actor-less (null), deliberately not attributed to "system". | | **force** | `boolean` | optional | Destructive-change acknowledgement (`?force=true` on the REST door): skips the safety diff that refuses an `object` save whose body drops fields or narrows types the stored item still carries (409 with the findings otherwise). Only `object` saves reach that diff, so the flag is inert for every other type. Absent = the guard runs. | | **mode** | `Enum<'draft' \| 'publish'>` | optional | Per-item lifecycle (ADR-0005 drafts): `draft` stages the body as a pending draft overlay (`?mode=draft` on the REST door; the publish door promotes it later); `publish` or ABSENT writes straight to the live `active` row — the legacy default, kept so callers that predate the draft/publish split keep working. Any value other than `draft` is read as `publish`. | -| **packageId** | `string \| null` | optional | ADR-0048 — the software package to bind the saved row to (`sys_metadata.package_id`; `?package=` on the REST door, where `all` and an empty value name no package). Set when authoring inside a Studio package workspace; a named read-only base package is refused. On create the row is stamped with this id; on update an existing binding is preserved, never silently re-bound. Absent or `null` names no package: an `active` save writes the env-local overlay (no package stamp), and a `draft` save writes the draft of the package the item's active row is bound to when it is package-bound (a pending change stays in the package it edits), else the env-local draft. That row is also the one the parent-version resolution reads: an unpinned save takes its version as the parent (last-write-wins), a pinned one is judged against it, and the item read at the same scope serves it as `version`. | +| **packageId** | `string \| null` | optional | ADR-0048 — the software package to bind the saved row to (`sys_metadata.package_id`; `?package=` on the REST door, where `all` and an empty value name no package). Set when authoring inside a Studio package workspace; a named read-only base package is refused. On create the row is stamped with this id; on update an existing binding is preserved, never silently re-bound. Absent or `null` names no package. An `active` save then writes the row of the one package the item's active rows are bound to, as a save naming that package does, is refused `409 RESOURCE_CONFLICT` when they are bound to two or more, and with none writes the env-local overlay (no package stamp); a `draft` save writes the draft of the package the item's active row is bound to when it is package-bound (a pending change stays in the package it edits), else the env-local draft. The row written is also the one the parent-version resolution reads: an unpinned save takes its version as the parent (last-write-wins), a pinned one is judged against it, and the item read at the same scope serves it as `version`. | | **writeFace** | `Enum<'package-duplicate' \| 'meta-envelope' \| 'meta-dispatch' \| 'external-import'>` | optional | Which write door a refusal is being rendered FOR — stated by the SERVER, never by a remote caller: every door builds this request field by field and never spreads a request body into it, so there is no path for a client to smuggle a face in, and a face arriving in a wire body is simply never read. Two refusals branch on it, for different questions: the 409 destructive-change remedy names the acknowledgement mechanism that actually exists on the refusing door (`?force=true` on the REST doors; the dispatcher, the duplicate-package door and the external-table import have none), and the 422 invalid-metadata message adapts to whether a structured `issues[]` channel reaches the consumer beside it. Absent = the conservative default wording. | diff --git a/packages/metadata-protocol/src/protocol.delete-object-registry-unregister.test.ts b/packages/metadata-protocol/src/protocol.delete-object-registry-unregister.test.ts index 301a34de629..26642a8a6a4 100644 --- a/packages/metadata-protocol/src/protocol.delete-object-registry-unregister.test.ts +++ b/packages/metadata-protocol/src/protocol.delete-object-registry-unregister.test.ts @@ -45,6 +45,7 @@ interface Row { name: string; organization_id: string | null; state: string; + package_id: string | null; checksum: string; metadata: string; } @@ -56,6 +57,9 @@ const overlayRow = (type: string, name: string): Row => ({ type, name, organization_id: null, + // [#23165] Stored as the store holds an unbound row: the column is there, + // NULL. The delete now addresses its row by package (ADR-0048). + package_id: null, state: 'active', checksum: 'sha256:stored-head', metadata: JSON.stringify({ name, label: 'Stored' }), @@ -133,7 +137,13 @@ function makeHarness(opts: { if (table !== 'sys_metadata') return null; return rows.find((r) => matches(r, query.where)) ?? null; }, - async find() { return []; }, + // [#23165] `sys_metadata` answers from the rows: the delete reads the + // item's rows to resolve its address. Every other table is empty. + async find(table: string, query: { where?: Record; limit?: number } = {}) { + if (table !== 'sys_metadata') return []; + const hit = rows.filter((r) => matches(r, query.where)); + return typeof query.limit === 'number' ? hit.slice(0, query.limit) : hit; + }, async insert(_table: string, data: Record) { return { id: 'inserted', ...data }; }, async update(_table: string, data: Record, options?: Record) { assertEngineUpdateDispatch(data, options); diff --git a/packages/metadata-protocol/src/protocol.delete-receipt-wording.test.ts b/packages/metadata-protocol/src/protocol.delete-receipt-wording.test.ts index bb1cb246361..2e9e73fdbfb 100644 --- a/packages/metadata-protocol/src/protocol.delete-receipt-wording.test.ts +++ b/packages/metadata-protocol/src/protocol.delete-receipt-wording.test.ts @@ -57,6 +57,7 @@ interface Row { name: string; organization_id: string | null; state: string; + package_id: string | null; checksum: string; metadata: string; } @@ -69,6 +70,10 @@ function overlayRow(type: string, name: string, state: 'active' | 'draft' = 'act type, name, organization_id: null, + // [#23165] Stored as the store holds an unbound row: the column is + // there, NULL. The delete now addresses its row by package (ADR-0048), + // so a row with no `package_id` key is a shape no store produces. + package_id: null, state, checksum: 'sha256:stored-head', metadata: JSON.stringify({ name, label: 'Stored' }), @@ -82,7 +87,7 @@ function makeStubEngine(opts: { const rows = [...(opts.rows ?? [])]; const artifactKeys = new Set((opts.artifacts ?? []).map((a) => `${a.type}|${a.name}`)); /** Match only on the keys the caller actually constrained (`whereFor` */ - /** omits `state` on the legacy path and `package_id` on delete). */ + /** omits `state` on the legacy path). */ const matches = (row: Row, where: Record = {}) => Object.entries(where).every(([k, v]) => (row as any)[k] === v); @@ -92,8 +97,15 @@ function makeStubEngine(opts: { if (table !== 'sys_metadata') return null; return rows.find((r) => matches(r, query.where)) ?? null; }, - // History counters (`nextItemVersion` / `nextEventSeq`) read here. - async find() { return []; }, + // History counters (`nextItemVersion` / `nextEventSeq`) read here, + // from `sys_metadata_history`, which holds nothing in this double. + // [#23165] `sys_metadata` answers from the rows: the delete reads the + // item's rows to resolve its address. + async find(table: string, query: { where?: Record; limit?: number } = {}) { + if (table !== 'sys_metadata') return []; + const hit = rows.filter((r) => matches(r, query.where)); + return typeof query.limit === 'number' ? hit.slice(0, query.limit) : hit; + }, async insert(_table: string, data: Record) { return { id: 'inserted', ...data }; }, diff --git a/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts b/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts index 67bcf0f9c75..931912d2a98 100644 --- a/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts +++ b/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts @@ -757,6 +757,22 @@ describe('each package\'s copy of a view container expands into its own package\ })).success).toBe(true); } + /** + * [#23165] A package-less copy stored BESIDE a package's copy. An author + * save naming no package now writes the row of the one package the item is + * bound to, so after pkg_b's copy it would update that copy instead. The + * row this pin reads is the package-less row such a save left before, and + * the door still writes it for a server-stated rewrite of that row + * (`packageId: null` with `source`, the stored-metadata migration's + * spelling), write-through included. + */ + async function saveUnboundCopyBeside(protocol: any, allowAnonymous: boolean, title: string) { + expect((await protocol.saveMetaItem({ + type: 'view', name: 'task', item: container(allowAnonymous, title), packageId: null, + source: 'migrate-stored', force: true, + })).success).toBe(true); + } + /** Each body of the form in the env-wide view list: [package, allowAnonymous, title]. */ async function envWideBodies(protocol: any): Promise> { const envWide: any = await protocol.getMetaItems({ type: 'view' }); @@ -840,6 +856,10 @@ describe('each package\'s copy of a view container expands into its own package\ const { protocol } = harness([['pkg_a', container(true)], ['pkg_b', container(true)]], environmentId); await restoreOpenOverlay(protocol); for (const pkg of order) { + if (pkg === undefined && order[0] === 'pkg_b') { + await saveUnboundCopyBeside(protocol, true, 'Intake (env-wide copy)'); + continue; + } await saveCopy(protocol, pkg, pkg === undefined, pkg ? `Intake (${pkg} copy)` : 'Intake (env-wide copy)'); } diff --git a/packages/metadata-protocol/src/protocol.packageless-save-address.test.ts b/packages/metadata-protocol/src/protocol.packageless-save-address.test.ts new file mode 100644 index 00000000000..9dc06e0a77d --- /dev/null +++ b/packages/metadata-protocol/src/protocol.packageless-save-address.test.ts @@ -0,0 +1,597 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#23165, ADR-0048] Where a direct save that names no package writes. + * + * A `sys_metadata` row is keyed `(org, type, name, package_id)`. A save that + * named no package wrote the package-unbound row, always: for an item a + * package owns (`PUT /api/v1/meta/app/lanebapp_app` with no `?package=`, after + * the item was saved into `com.qa.lanebapp`), it minted a second, package-less + * row beside the package's and answered `200` "Saved app 'lanebapp_app' + * (env-wide, state=active)", while the item read kept serving the package's + * row. Measured on a booted showcase: the console's "Disable" showed success + * and nothing changed. The draft save already resolved its address from the + * package of the item's active row (#11087, `resolveWriteHead`); the direct + * save did not. + * + * Pinned here, through the real save and read doors: + * + * - the round trip: a package-less save of a single-package item writes the + * package's row, the item read serves it next, no second row is minted, and + * the receipt names the package; + * - the item read's `version` names the row that save writes, so a read + * followed by a save with the served token is accepted; + * - an item two packages hold: the package-less save is refused, + * `409 RESOURCE_CONFLICT`, naming both, and nothing is written; naming one + * package saves. The two-package state is measured reachable through the + * door first; + * - the save is a save naming the resolved package: the row keeps its stored + * lock family, and a read-only package answers what `?package=` naming it + * answers; + * - CONTROL: an item with no package binding keeps the package-unbound row, + * and its receipt reads byte for byte as before; + * - `packageId: null` names no package, as `undefined` does (the request + * contract states both); + * - 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; + * - 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). + * + * Refusals are asserted as the envelope an HTTP boundary answers with + * (`resolveThrownHttpError`): `code` and `status`, never a bare throw. + * + * The engine below is a stub: this package cannot import `@objectstack/objectql` + * (that package depends on this one). It keeps real rows, so the REAL save and + * read doors run against it, and its write verbs route through the producer's + * own dispatch predicates. + */ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { assertEngineDeleteDispatch, assertEngineFindOnePredicate, assertEngineUpdateDispatch, isCodeArtifactBody } from '@objectstack/metadata-core'; +import { applyProtection } from '@objectstack/spec/shared'; +import { resolveThrownHttpError } from '@objectstack/types'; +import { ObjectStackProtocolImplementation } from './protocol.js'; +import { resetEnvWritableMetadataTypes } from './sys-metadata-repository.js'; + +type Row = Record; + +/** A writable runtime package: no manifest booted it, and no read-only scope. */ +const WRITABLE = 'com.qa.lanebapp'; +const OTHER_WRITABLE = 'com.qa.otherapp'; +/** A code package: its manifest was booted, so it is read-only (ADR-0070). */ +const CODE_PACKAGE = 'com.example.showcase'; +/** An installed package with a read-only manifest scope. */ +const INSTALLED_READ_ONLY = 'com.vendor.installed'; + +/** Equality and `null`; any other operator is refused, so a matcher that ignored one cannot prove anything. */ +function matches(row: Row, where: Record = {}): boolean { + for (const [key, want] of Object.entries(where)) { + if (want === undefined) continue; + if (key.startsWith('$')) throw new Error(`stub engine: unsupported operator ${key}`); + if (want !== null && typeof want === 'object') throw new Error(`stub engine: unsupported operator on ${key}`); + if ((row[key] ?? null) !== want) return false; + } + return true; +} + +/** + * A stub engine that keeps rows, and a registry holding what a code package's + * loader registered (each artifact as `applyProtection` leaves it). + */ +function makeProtocol(artifacts: Array<{ type: string; item: Record }> = []) { + const tables = new Map(); + const table = (name: string) => { + if (!tables.has(name)) tables.set(name, []); + return tables.get(name)!; + }; + let nextId = 0; + const writes: Array<{ op: string; table: string }> = []; + const shipped = (type: string, name: string) => artifacts + .filter((a) => a.type === type && a.item.name === name) + .map((a) => a.item); + const engine: any = { + manifests: new Set([CODE_PACKAGE]), + async find(t: string, q?: { where?: Record; limit?: number }) { + const rows = table(t).filter((r) => matches(r, q?.where)); + return typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows; + }, + async findOne(t: string, q: { where: Record }) { + assertEngineFindOnePredicate(t, q); + return table(t).find((r) => matches(r, q.where)) ?? null; + }, + async insert(t: string, data: Row) { + writes.push({ op: 'insert', table: t }); + nextId += 1; + const row = { id: `r_${nextId}`, ...data }; + table(t).push(row); + return { ...row }; + }, + async update(t: string, data: Row, q?: Record) { + assertEngineUpdateDispatch(data, q); + writes.push({ op: 'update', table: t }); + const where = (q as { where?: Record } | undefined)?.where; + const hit = table(t).filter((r) => matches(r, where)); + for (const r of hit) Object.assign(r, data); + return { id: hit[0]?.id ?? null }; + }, + async delete(t: string, q?: Record) { + assertEngineDeleteDispatch(q); + writes.push({ op: 'delete', table: t }); + const where = (q as { where?: Record } | undefined)?.where; + const keep = table(t).filter((r) => !matches(r, where)); + const deleted = table(t).length - keep.length; + tables.set(t, keep); + return { deleted }; + }, + registry: { + registerItem: () => {}, + registerObject: () => {}, + unregisterItem: () => {}, + getArtifactItem: (type: string, name: string) => shipped(type, name).find((item) => isCodeArtifactBody(item)), + getItem: (type: string, name: string) => shipped(type, name)[0], + listItems: (type: string) => artifacts.filter((a) => a.type === type).map((a) => a.item), + getObject: () => undefined, + getPackage: (id: string) => (id === INSTALLED_READ_ONLY ? { manifest: { id, scope: 'cloud' } } : undefined), + isPackageDisabled: () => false, + applyNavContributions: (app: unknown) => app, + }, + }; + const protocol = new ObjectStackProtocolImplementation(engine, () => new Map()) as any; + const metadataWrites = () => writes.filter((w) => w.table === 'sys_metadata' && w.op !== 'delete'); + const rows = (type: string, name: string, state = 'active') => + table('sys_metadata').filter((r) => r.type === type && r.name === name && r.state === state); + /** A row as an older build left it, written straight into the table. */ + const seedRow = (row: Row) => { + nextId += 1; + table('sys_metadata').push({ id: `seed_${nextId}`, organization_id: null, state: 'active', version: 1, ...row }); + }; + return { protocol, metadataWrites, rows, seedRow }; +} + +const app = (name: string, extra: Record = {}) => ({ name, label: `App ${name}`, ...extra }); + +/** The stored body of each row, by binding: `null` for the package-unbound row. */ +function bodiesByBinding(rows: Row[]): Record> { + return Object.fromEntries(rows.map((r) => [String(r.package_id ?? null), JSON.parse(r.metadata)])); +} + +async function refusalOf(run: () => Promise): Promise { + try { + await run(); + } catch (e) { + return e; + } + throw new Error('expected the call to be refused, but it resolved'); +} + +/** A view a code package ships, as its loader registers it. */ +function packagedView(name: string) { + const item: Record = { + name, label: `Packaged ${name}`, object: 'task', viewKind: 'list', columns: [{ field: 'name', label: 'Name' }], + }; + applyProtection(item, { packageId: CODE_PACKAGE, packageVersion: '1.0.0' }); + return { type: 'view', item }; +} + +const viewBody = (name: string, label: string) => ({ + name, label, object: 'task', viewKind: 'list', columns: [{ field: 'name', label: 'Name' }], +}); + +const ENV_KEYS = ['OS_TENANCY_POSTURE', 'OS_MULTI_ORG_ENABLED', 'OS_METADATA_WRITABLE'] as const; +const savedEnv: Record = {}; +beforeEach(() => { + for (const k of ENV_KEYS) savedEnv[k] = process.env[k]; + for (const k of ENV_KEYS) delete process.env[k]; + resetEnvWritableMetadataTypes(); +}); +afterEach(() => { + for (const k of ENV_KEYS) { + if (savedEnv[k] === undefined) delete process.env[k]; + else process.env[k] = savedEnv[k]; + } + resetEnvWritableMetadataTypes(); +}); + +describe('a package-less direct save of an item ONE package owns writes that package\'s row', () => { + it('the card\'s probe: the write is what the item read serves next, no second row, and the receipt names the package', async () => { + const { protocol, rows } = makeProtocol(); + const name = 'lanebapp_app'; + await protocol.saveMetaItem({ type: 'app', name, item: app(name), packageId: WRITABLE }); + + const saved = await protocol.saveMetaItem({ type: 'app', name, item: app(name, { active: false }) }); + + expect(saved.success).toBe(true); + expect(saved.message).toBe(`Saved app '${name}' (env-wide, package=${WRITABLE}, state=active) [seq=${saved.seq}]`); + expect(rows('app', name).map((r) => r.package_id ?? null)).toEqual([WRITABLE]); + expect(bodiesByBinding(rows('app', name))[WRITABLE]).toMatchObject({ active: false }); + const read = await protocol.getMetaItem({ type: 'app', name }); + expect(read.item).toMatchObject({ active: false, _packageId: WRITABLE }); + // The read naming the package serves the same row. + expect((await protocol.getMetaItem({ type: 'app', name, packageId: WRITABLE })).item).toMatchObject({ active: false }); + }); + + it('packageId: null names no package too, as the request contract states', async () => { + const { protocol, rows } = makeProtocol(); + const name = 'null_named_app'; + await protocol.saveMetaItem({ type: 'app', name, item: app(name), packageId: WRITABLE }); + const saved = await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'Edited' }), packageId: null }); + expect(saved.message).toContain(`package=${WRITABLE}`); + expect(rows('app', name).map((r) => r.package_id ?? null)).toEqual([WRITABLE]); + }); + + it('both author faces resolve the same address', async () => { + for (const writeFace of ['meta-envelope', 'meta-dispatch'] as const) { + const { protocol, rows } = makeProtocol(); + const name = `face_${writeFace.replace('-', '_')}`; + await protocol.saveMetaItem({ type: 'app', name, item: app(name), packageId: WRITABLE, writeFace }); + await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'Edited' }), writeFace }); + expect(rows('app', name).map((r) => r.package_id ?? null), writeFace).toEqual([WRITABLE]); + expect((await protocol.getMetaItem({ type: 'app', name })).item.label, writeFace).toBe('Edited'); + } + }); + + it('the item read\'s version names the row that save writes, so read → save with the served token is accepted', async () => { + const { protocol, rows } = makeProtocol(); + const name = 'token_app'; + await protocol.saveMetaItem({ type: 'app', name, item: app(name), packageId: WRITABLE }); + + const read = await protocol.getMetaItem({ type: 'app', name }); + expect(read.version).toEqual(expect.any(String)); + // The same token the read naming the package serves: one address. + expect((await protocol.getMetaItem({ type: 'app', name, packageId: WRITABLE })).version).toBe(read.version); + + const saved = await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'Pinned' }), parentVersion: read.version }); + expect(saved.success).toBe(true); + expect(rows('app', name).map((r) => r.package_id ?? null)).toEqual([WRITABLE]); + + // The token it replaced is stale now, and a create assertion meets a + // row that exists: both are the optimistic lock's 409. + for (const parentVersion of [read.version, null]) { + const envelope = resolveThrownHttpError(await refusalOf(() => protocol.saveMetaItem({ + type: 'app', name, item: app(name, { label: 'Stale' }), parentVersion, + }))); + expect(envelope.status, String(parentVersion)).toBe(409); + expect(envelope.code, String(parentVersion)).toBe('METADATA_CONFLICT'); + } + expect(rows('app', name)).toHaveLength(1); + }); + + it('the save is a save naming that package: the row keeps the lock family it stores', async () => { + const { protocol, rows } = makeProtocol(); + const name = 'stamped_app'; + const stamped = { _lock: 'no-delete', _lockReason: 'Stamped by the platform.', _lockSource: 'package' }; + // A server face stamps the family onto the package's row. + await protocol.saveMetaItem({ + type: 'app', name, item: app(name, stamped), packageId: WRITABLE, source: 'migrate-stored', force: true, + }); + + // An author save that says nothing about the lock. + await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'Edited' }) }); + + expect(rows('app', name)).toHaveLength(1); + expect(bodiesByBinding(rows('app', name))[WRITABLE]).toMatchObject({ label: 'Edited', ...stamped }); + const read = await protocol.getMetaItem({ type: 'app', name }); + expect(read).toMatchObject({ lock: 'no-delete', deletable: false }); + }); + + it('a read-only package answers what ?package= naming it answers', async () => { + const { protocol, rows, seedRow, metadataWrites } = makeProtocol(); + const name = 'installed_app'; + seedRow({ type: 'app', name, package_id: INSTALLED_READ_ONLY, metadata: JSON.stringify(app(name)), checksum: 'sha-installed' }); + + const named = resolveThrownHttpError(await refusalOf(() => protocol.saveMetaItem({ + type: 'app', name, item: app(name, { label: 'Edited' }), packageId: INSTALLED_READ_ONLY, + }))); + const unnamed = resolveThrownHttpError(await refusalOf(() => protocol.saveMetaItem({ + type: 'app', name, item: app(name, { label: 'Edited' }), + }))); + + expect(named).toMatchObject({ status: 422, code: 'WRITABLE_PACKAGE_REQUIRED' }); + expect(unnamed).toMatchObject({ status: named.status, code: named.code }); + expect(rows('app', name).map((r) => r.package_id ?? null)).toEqual([INSTALLED_READ_ONLY]); + expect(metadataWrites()).toEqual([]); + }); +}); + +describe('an item TWO packages hold: a save naming neither is refused', () => { + async function twoPackageItem(name: string) { + const fixture = makeProtocol(); + // Reachable through the door: each package-scoped save writes its own row. + await fixture.protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'A' }), packageId: WRITABLE }); + await fixture.protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'B' }), packageId: OTHER_WRITABLE }); + return fixture; + } + + it('two package-scoped saves leave one active row per package', async () => { + const { rows } = await twoPackageItem('shared_app'); + expect(rows('app', 'shared_app').map((r) => r.package_id).sort()).toEqual([WRITABLE, OTHER_WRITABLE].sort()); + }); + + it('409 RESOURCE_CONFLICT naming both packages, and nothing is written', async () => { + const name = 'shared_app'; + const { protocol, rows, metadataWrites } = await twoPackageItem(name); + const before = metadataWrites().length; + const bodiesBefore = bodiesByBinding(rows('app', name)); + + const err = await refusalOf(() => protocol.saveMetaItem({ type: 'app', name, item: app(name, { active: false }) })); + + const envelope = resolveThrownHttpError(err); + expect(envelope.status).toBe(409); + expect(envelope.code).toBe('RESOURCE_CONFLICT'); + expect(err.message).toContain(`'${WRITABLE}'`); + expect(err.message).toContain(`'${OTHER_WRITABLE}'`); + expect(err.message).toContain('?package='); + expect(metadataWrites()).toHaveLength(before); + expect(rows('app', name)).toHaveLength(2); + expect(bodiesByBinding(rows('app', name))).toEqual(bodiesBefore); + }); + + it('naming one of them saves that package\'s row', async () => { + const name = 'shared_app'; + const { protocol, rows } = await twoPackageItem(name); + const saved = await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'A2' }), packageId: WRITABLE }); + expect(saved.message).toContain(`package=${WRITABLE}`); + expect(bodiesByBinding(rows('app', name))).toMatchObject({ [WRITABLE]: { label: 'A2' }, [OTHER_WRITABLE]: { label: 'B' } }); + }); +}); + +describe('CONTROL: what the resolution leaves alone', () => { + it('an item with no package binding keeps the package-unbound row, and its receipt reads as before', async () => { + const { protocol, rows } = makeProtocol(); + const name = 'plain_app'; + const first = await protocol.saveMetaItem({ type: 'app', name, item: app(name) }); + const second = await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'Edited' }) }); + + expect(first.message).toBe(`Saved app '${name}' (env-wide, state=active) [seq=${first.seq}]`); + expect(second.message).toBe(`Saved app '${name}' (env-wide, state=active) [seq=${second.seq}]`); + expect(rows('app', name).map((r) => r.package_id ?? null)).toEqual([null]); + expect((await protocol.getMetaItem({ type: 'app', name })).item.label).toBe('Edited'); + }); + + it('a package-less row minted beside the package\'s row stays as it is; the save writes the package\'s row', async () => { + const { protocol, rows, seedRow } = makeProtocol(); + const name = 'residue_app'; + await protocol.saveMetaItem({ type: 'app', name, item: app(name), packageId: WRITABLE }); + seedRow({ type: 'app', name, package_id: null, metadata: JSON.stringify(app(name, { active: false })), checksum: 'sha-residue' }); + + await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'Edited' }) }); + + const bodies = bodiesByBinding(rows('app', name)); + expect(Object.keys(bodies).sort()).toEqual([WRITABLE, 'null'].sort()); + expect(bodies[WRITABLE]).toMatchObject({ label: 'Edited' }); + expect(bodies.null).toEqual(app(name, { active: false })); + }); + + it('a server-stated rewrite of the package-unbound row (the stored-metadata migration, packageId: null) still writes that row', async () => { + const { protocol, rows, seedRow } = makeProtocol(); + const name = 'residue_app'; + await protocol.saveMetaItem({ type: 'app', name, item: app(name), packageId: WRITABLE }); + seedRow({ type: 'app', name, package_id: null, metadata: JSON.stringify(app(name)), checksum: 'sha-residue' }); + + await protocol.saveMetaItem({ + type: 'app', name, item: app(name, { label: 'Rewritten' }), packageId: null, source: 'migrate-stored', force: true, + }); + + const bodies = bodiesByBinding(rows('app', name)); + expect(bodies.null).toMatchObject({ label: 'Rewritten' }); + expect(bodies[WRITABLE]).toEqual(app(name)); + }); + + it('a package-less DRAFT save still inherits the package of the active row (#11087)', async () => { + const { protocol, rows } = makeProtocol(); + const name = 'draft_app'; + await protocol.saveMetaItem({ type: 'app', name, item: app(name), packageId: WRITABLE }); + + const saved = await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'Pending' }), mode: 'draft' }); + + expect(saved.state).toBe('draft'); + expect(rows('app', name, 'draft').map((r) => r.package_id ?? null)).toEqual([WRITABLE]); + expect(rows('app', name).map((r) => r.package_id ?? null)).toEqual([WRITABLE]); + }); +}); + +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 () => { + const name = 'showcase_task_grid'; + const { protocol, rows } = makeProtocol([packagedView(name)]); + + const saved = await protocol.saveMetaItem({ type: 'view', name, item: viewBody(name, 'Overlaid') }); + + 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'); + }); + + it('with an overlay row bound to the code package: the package-less save writes that row', async () => { + const name = 'showcase_task_grid'; + const { protocol, rows } = makeProtocol([packagedView(name)]); + // The overlay a save naming the package writes (ADR-0005: an overlay of + // a code-shipped item names the package it customizes). + 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') }); + + expect(saved.message).toContain(`package=${CODE_PACKAGE}`); + expect(rows('view', name).map((r) => r.package_id ?? null)).toEqual([CODE_PACKAGE]); + expect((await protocol.getMetaItem({ type: 'view', name })).item.label).toBe('Unnamed'); + }); +}); + +/** + * [#23165, ADR-0048] The DELETE door takes the same address. + * + * `deleteMetaItem` handed the repository no package, so its probe and its + * delete each took the first row a package-agnostic `findOne` met: one + * package's row of an item two packages hold (the other package's row then + * served, while the receipt said "it no longer exists"), or the package's row + * when a package-less row sat beside it. Named, a delete now removes that + * package's row and no other; naming none, it resolves through the save + * door's function: one binding removes that row, two or more are refused, + * none removes the package-unbound row as before. + */ +describe('a delete that names no package removes the row the save door addresses', () => { + it('the round trip: a package-less delete of an item ONE package owns removes that row, and the read serves nothing', async () => { + const { protocol, rows } = makeProtocol(); + const name = 'deleted_app'; + await protocol.saveMetaItem({ type: 'app', name, item: app(name), packageId: WRITABLE }); + + const receipt = await protocol.deleteMetaItem({ type: 'app', name }); + + expect(receipt).toMatchObject({ success: true, reset: true }); + expect(receipt.message).toContain(`package=${WRITABLE}`); + expect(receipt.message).toContain('it no longer exists'); + expect(rows('app', name)).toEqual([]); + const read = await protocol.getMetaItem({ type: 'app', name }).catch((e: unknown) => e); + expect(read?.item ?? null).toBeNull(); + }); + + it('the item read\'s version names the row that delete removes, so read → delete with the served token is accepted', async () => { + const { protocol, rows } = makeProtocol(); + const name = 'pinned_delete_app'; + await protocol.saveMetaItem({ type: 'app', name, item: app(name), packageId: WRITABLE }); + const { version } = await protocol.getMetaItem({ type: 'app', name }); + expect(version).toEqual(expect.any(String)); + + const receipt = await protocol.deleteMetaItem({ type: 'app', name, parentVersion: version }); + + expect(receipt.reset).toBe(true); + expect(rows('app', name)).toEqual([]); + }); + + it('a draft discard naming no package removes the draft the package-less draft save put in the package', async () => { + const { protocol, rows } = makeProtocol(); + const name = 'draft_discard_app'; + await protocol.saveMetaItem({ type: 'app', name, item: app(name), packageId: WRITABLE }); + await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'Pending' }), mode: 'draft' }); + expect(rows('app', name, 'draft').map((r) => r.package_id ?? null)).toEqual([WRITABLE]); + + const receipt = await protocol.deleteMetaItem({ type: 'app', name, state: 'draft' }); + + expect(receipt.reset).toBe(true); + expect(rows('app', name, 'draft')).toEqual([]); + expect(rows('app', name).map((r) => r.package_id ?? null)).toEqual([WRITABLE]); + }); +}); + +describe('an item TWO packages hold: a delete naming neither is refused', () => { + async function twoPackageItem(name: string) { + const fixture = makeProtocol(); + await fixture.protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'A' }), packageId: WRITABLE }); + await fixture.protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'B' }), packageId: OTHER_WRITABLE }); + return fixture; + } + + it('409 RESOURCE_CONFLICT naming both packages, and nothing is deleted', async () => { + const name = 'shared_delete_app'; + const { protocol, rows } = await twoPackageItem(name); + const bodiesBefore = bodiesByBinding(rows('app', name)); + + const err = await refusalOf(() => protocol.deleteMetaItem({ type: 'app', name })); + + const envelope = resolveThrownHttpError(err); + expect(envelope.status).toBe(409); + expect(envelope.code).toBe('RESOURCE_CONFLICT'); + expect(err.message).toContain(`'${WRITABLE}'`); + expect(err.message).toContain(`'${OTHER_WRITABLE}'`); + expect(err.message).toContain('?package='); + expect(bodiesByBinding(rows('app', name))).toEqual(bodiesBefore); + }); + + it('a draft discard naming no package is refused when drafts sit in both packages', async () => { + const name = 'shared_draft_app'; + const { protocol, rows } = await twoPackageItem(name); + for (const packageId of [WRITABLE, OTHER_WRITABLE]) { + await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: `Draft ${packageId}` }), packageId, mode: 'draft' }); + } + + const envelope = resolveThrownHttpError(await refusalOf(() => protocol.deleteMetaItem({ type: 'app', name, state: 'draft' }))); + + expect(envelope).toMatchObject({ status: 409, code: 'RESOURCE_CONFLICT' }); + expect(rows('app', name, 'draft')).toHaveLength(2); + }); + + it('naming one removes that package\'s row only, the other is served, and the receipt names what remains', async () => { + const name = 'shared_delete_app'; + const { protocol, rows } = await twoPackageItem(name); + + const receipt = await protocol.deleteMetaItem({ type: 'app', name, packageId: WRITABLE }); + + expect(receipt.reset).toBe(true); + expect(rows('app', name).map((r) => r.package_id)).toEqual([OTHER_WRITABLE]); + expect(receipt.message).toContain(`package=${WRITABLE}`); + expect(receipt.message).toContain(`'${OTHER_WRITABLE}'`); + expect(receipt.message).not.toContain('no longer exists'); + expect((await protocol.getMetaItem({ type: 'app', name })).item).toMatchObject({ label: 'B', _packageId: OTHER_WRITABLE }); + }); + + it('naming a package that holds no row removes nothing and says the other rows remain', async () => { + const name = 'shared_delete_app'; + const { protocol, rows } = await twoPackageItem(name); + + const receipt = await protocol.deleteMetaItem({ type: 'app', name, packageId: 'com.qa.thirdapp' }); + + expect(receipt).toMatchObject({ success: true, reset: false }); + expect(receipt.message).toContain(`'${WRITABLE}'`); + expect(receipt.message).toContain(`'${OTHER_WRITABLE}'`); + expect(receipt.message).not.toContain('nothing to delete'); + expect(rows('app', name)).toHaveLength(2); + }); + + it('uninstalling one package removes its row, never the other package\'s', async () => { + const name = 'shared_uninstall_app'; + const { protocol, rows } = await twoPackageItem(name); + + const out = await protocol.deletePackage({ packageId: OTHER_WRITABLE, keepData: true }); + + expect(out.failed ?? []).toEqual([]); + expect(rows('app', name).map((r) => r.package_id)).toEqual([WRITABLE]); + expect(bodiesByBinding(rows('app', name))[WRITABLE]).toMatchObject({ label: 'A' }); + }); + + it('discarding one package\'s drafts removes its draft, never the other package\'s', async () => { + const name = 'shared_discard_app'; + const { protocol, rows } = await twoPackageItem(name); + for (const packageId of [WRITABLE, OTHER_WRITABLE]) { + await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: `Draft ${packageId}` }), packageId, mode: 'draft' }); + } + + const out = await protocol.discardPackageDrafts({ packageId: OTHER_WRITABLE }); + + expect(out).toMatchObject({ discardedCount: 1, failedCount: 0 }); + expect(rows('app', name, 'draft').map((r) => r.package_id)).toEqual([WRITABLE]); + }); +}); + +describe('CONTROL: what the delete resolution leaves alone', () => { + it('an item with no package binding: the package-unbound row is removed, and the receipt reads as before', async () => { + const { protocol, rows } = makeProtocol(); + const name = 'plain_delete_app'; + await protocol.saveMetaItem({ type: 'app', name, item: app(name) }); + + const receipt = await protocol.deleteMetaItem({ type: 'app', name }); + + expect(receipt.message).toBe(`Deleted app '${name}' — it no longer exists. [seq=${receipt.seq}]`); + expect(rows('app', name)).toEqual([]); + }); + + it('a package-less row beside the package\'s row stays: the delete removes the package\'s row and says the other remains', async () => { + const { protocol, rows, seedRow } = makeProtocol(); + const name = 'residue_delete_app'; + await protocol.saveMetaItem({ type: 'app', name, item: app(name), packageId: WRITABLE }); + seedRow({ type: 'app', name, package_id: null, metadata: JSON.stringify(app(name, { active: false })), checksum: 'sha-residue' }); + + const receipt = await protocol.deleteMetaItem({ type: 'app', name }); + + expect(rows('app', name).map((r) => r.package_id ?? null)).toEqual([null]); + expect(receipt.message).toContain(`package=${WRITABLE}`); + expect(receipt.message).toContain('package-less row'); + expect(receipt.message).not.toContain('no longer exists'); + expect((await protocol.getMetaItem({ type: 'app', name })).item).toMatchObject({ active: false }); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index fed6417cdea..c9886e96a6b 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -1123,6 +1123,72 @@ function authoredLockFamilyRefusal( return err; } +/** + * [#23165, ADR-0048] The refusal of a direct save, or a delete, that names no + * package, of an item whose rows at the address are bound to two or more + * packages ({@link ObjectStackProtocolImplementation.packagelessAddress}). + * + * A `sys_metadata` row is keyed `(org, type, name, package_id)`, so such a + * write has no one row to write or remove. Before this refusal the save wrote + * a package-less row beside the packages' rows and answered `200`, for a row + * the item read does not serve, and the delete removed whichever row the + * package-agnostic `findOne` met first and answered "it no longer exists" + * while another package's row stayed served. Choosing one of the packages is + * that driver-order coin toss (#8907). The remedy is the request's: name the + * package, which is the address every door of the item takes (`?package=`, + * ADR-0048). + * + * `RESOURCE_CONFLICT` / 409, no code minted: the standard catalog's member for + * a refusal that comes from environment state rather than the body (the same + * request is accepted once it names its package), and the code this + * package already answers when a name is held by more than one owner + * (`importUnmanagedPackage`). The prescription first, because a 4xx message + * crosses the REST boundary bounded at 500 characters with its tail + * truncated; the candidates follow, sorted, so the message is the same on + * every driver. + */ +function packagelessAddressAmbiguityRefusal( + verb: 'save' | 'delete', + type: string, + name: string, + packageIds: readonly string[], +): Error & { code: 'RESOURCE_CONFLICT'; status: 409 } { + const shown = packageIds.slice(0, 5).map((id) => `'${id}'`).join(', '); + const more = packageIds.length > 5 ? ` and ${packageIds.length - 5} more` : ''; + const [nothing, single] = verb === 'save' + ? ['Nothing was written.', 'no single row to write'] + : ['Nothing was deleted.', 'no single row to remove']; + const err = new Error( + `Name the package this ${verb} is for, then send it again: on the REST door add ?package= with the package id, ` + + `in process pass packageId. ${nothing} ${type} '${name}' belongs to ${packageIds.length} packages ` + + `(${shown}${more}), and a ${verb} that names none has ${single}.`, + ) as Error & { code: 'RESOURCE_CONFLICT'; status: 409 }; + err.code = 'RESOURCE_CONFLICT'; + err.status = 409; + return err; +} + +/** + * [#23165] The rows of an item a delete leaves stored at its address, in + * words, for the delete receipt: its rows bound to other packages (sorted, as + * {@link ObjectStackProtocolImplementation.packagelessAddress} answers them, + * at most five named) and its package-less row. The receipt names them + * instead of claiming the item gone or back at its artifact default. + */ +function remainingRowsPhrase(packageIds: readonly string[], unbound: boolean, noun: 'row' | 'draft'): string { + const parts: string[] = []; + if (packageIds.length > 0) { + const shown = packageIds.slice(0, 5).map((id) => `'${id}'`).join(', '); + const more = packageIds.length > 5 ? ` and ${packageIds.length - 5} more` : ''; + parts.push(packageIds.length === 1 + ? `its ${noun} bound to package ${shown}` + : `its ${noun}s bound to packages ${shown}${more}`); + } + if (unbound) parts.push(`its package-less ${noun}`); + const count = packageIds.length + (unbound ? 1 : 0); + return `${parts.join(' and ')} ${count === 1 ? 'remains' : 'remain'}`; +} + /** * One entry of the `422 INVALID_METADATA` envelope's `issues[]` — the shape * Studio's designer keys on to highlight the offending form control. @@ -18518,6 +18584,11 @@ export class ObjectStackProtocolImplementation implements * token of the draft a package-less draft save overwrites, which is the * draft bound to the package of the item's active row when there is one * (#11087) — the address the save resolves, not the key it was handed. + * [#23165] The active read that names no package likewise serves the token + * of the row a direct save (or a delete) naming none writes: the one + * package's row the item is bound to ({@link packagelessAddress}). For an + * item several packages hold, that save and that delete are refused + * whatever they carry, and the token stays the package-unbound row's. */ private async readVersionToken( /** [#22877] `member` — the bundle member the read addressed; omitted, the canonical one. */ @@ -18530,10 +18601,91 @@ export class ObjectStackProtocolImplementation implements name: request.name, org: 'env', } as Parameters[0]; - const stored = await this.storedHeadAt(repo, ref, state, request.packageId ?? null, request.member); + const packageId = request.packageId == null && state === 'active' + ? (await this.packagelessAddress(ref.type, ref.name, request.member)).packageId + : request.packageId ?? null; + const stored = await this.storedHeadAt(repo, ref, state, packageId, request.member); return stored === null ? null : this.receiptVersion(stored); } + /** + * [#23165, ADR-0048] THE package-less write address: which row of an item a + * write that names no package addresses, read from the item's stored rows + * at `state`. The item's own package when those rows are bound to exactly + * one, else the package-unbound row. `candidates` is every distinct + * binding, sorted; two or more is a name several packages hold, which the + * doors refuse ({@link packagelessAddressAmbiguityRefusal}) and for which + * `packageId` is `null`. `unbound` says whether a package-unbound row sits + * beside them, which is what a delete of a package's row leaves served. + * + * The one resolution its three readers take, so they cannot disagree: + * {@link saveMetaItem}'s direct (active) save, which then writes as a save + * naming that package does; {@link deleteMetaItem}, which then removes as a + * delete naming that package does, its draft discard reading the draft + * rows; and {@link readVersionToken}, whose `version` a save or a delete at + * the same address accepts. + * + * Why the package's row: a write addresses exactly one + * `(org, type, name, package_id)` row, and the item read that names no + * package serves the package's row whenever it is the item's only row. A + * save that wrote the package-unbound row instead minted a second row + * beside it and answered `200` for a write that read never served; a + * delete that took the first row a package-agnostic `findOne` met removed + * one package's row of an item two packages hold, or the package-less row + * beside a package's, and claimed the item gone. The draft save has + * resolved its address from the item's active row since #11087 + * (`SysMetadataRepository.resolveWriteHead`, unchanged); the direct save + * and the delete did not, and this is that half. + * + * The rows are the ones the item read chooses among + * ({@link findServedOverlayRow}): the environment's (ADR-0131 D6), the + * state the write addresses, the canonical type, the bundle member + * (#22877), and no package predicate. They are read in full, never + * sampled: with two packages' rows at one address a package-agnostic + * `findOne` is a driver-order coin toss (#8907). A package-less row beside + * the package's row is not a second package, and stays as it is (no + * conversion: the card's existing-rows line). An item with no binding + * keeps the package-unbound row, as before. An unprovisioned store holds + * no row; any other failed read propagates (#5532), because guessing the + * binding would choose the row the write lands in. + * + * ⛔ Not the repository's write-address default: `put`, `headAt` and + * `delete` keep their documented ones (omitted = the package-unbound row + * for `put`/`headAt`, any row for `delete`), because the callers that reach + * them with no package (`promoteDraft` and `restoreVersion` of a + * package-less row) mean that row, as do the server-stated rewrites the + * save door does not resolve for. The doors resolve, then name the + * package. + */ + private async packagelessAddress( + type: string, + name: string, + /** [#22877] The bundle member the write addresses (a save's body's, a delete's selector); omitted, the canonical one. */ + member?: string, + state: 'active' | 'draft' = 'active', + ): Promise<{ packageId: string | null; candidates: readonly string[]; unbound: boolean }> { + let rows: Array<{ package_id?: unknown }> | null | undefined; + try { + // [#21911] The explicit system opt-in — see findServedOverlayRow. + rows = await this.engine.find('sys_metadata', { + where: { type, name, state, organization_id: null, ...overlayMemberWhere(type, member) }, + context: { isSystem: true }, + }); + } catch (error) { + // [#5532] An unprovisioned store holds no row, so no binding; any + // other failure is a read that did not happen, never "no binding". + this.rethrowUnlessMetadataStoreUnprovisioned(error, 'sys_metadata'); + } + const bindings = new Set(); + let unbound = false; + for (const row of rows ?? []) { + if (typeof row?.package_id === 'string' && row.package_id !== '') bindings.add(row.package_id); + else unbound = true; + } + const candidates = [...bindings].sort(); + return { packageId: candidates.length === 1 ? candidates[0] ?? null : null, candidates, unbound }; + } + /** * Resolve a caller's version token to the STORED head it names, for a write * whose current stored head is `currentStored`: the token must equal the @@ -21179,6 +21331,56 @@ export class ObjectStackProtocolImplementation implements if (lockErr) throw lockErr; } + // [#23165, ADR-0048] THE WRITE ADDRESS of a direct save that names no + // package: the item's own package when its active rows are bound to + // exactly one ({@link packagelessAddress}). It used to be the + // package-unbound row, always, so a save of a package's item minted a + // second row beside the package's and answered `200` while the item + // read kept serving the package's row. The draft save already resolved + // its address this way (#11087, `SysMetadataRepository.resolveWriteHead`). + // + // Resolved ONCE, here, and from here on the save IS a save naming that + // package: every gate below that asks about the row this save writes + // (the D9.9 object check, the stored lock family, the runtime + // authoring gate, ADR-0070 D1's writable-base check, the lock parent, + // the credential carry-forward, `repo.put`, the registry write-through + // and the receipt) reads the same `packageId`, so no two of them can + // judge different rows. A package that is read-only answers here what + // `?package=` naming it answers. + // + // Below the authorization refusals on purpose: the code-only refusal, + // the package door and the `_lock` gate judge the item, not its row, + // and they answer before this store read, as #22220 ordered. + // + // Asked of a publish-mode AUTHORING save whose `packageId` is absent or + // `null`, the two spellings of "no package" the request contract + // states. Not asked of: a save that names a package (ADR-0048, its + // address is its own); a draft save, whose binding the repository + // resolves (#11087); the server-stated rewrites of a row this store + // already holds (`source` set: the stored-metadata migration and the + // stored flow-credential move, which pass `null` for the + // package-unbound row they read and rewrite; `package-duplicate`, + // which always names its target), the same two the authoring rules + // above exempt. + // + // Several bindings is a name more than one package holds, and a save + // naming none of them is refused, not guessed. + if ( + mode === 'publish' + && request.packageId == null + && request.source === undefined + && request.writeFace !== 'package-duplicate' + ) { + const writeType = PLURAL_TO_SINGULAR[request.type] ?? request.type; + const address = await this.packagelessAddress( + writeType, request.name, itemDiscriminator(writeType, request.item), + ); + if (address.candidates.length > 1) { + throw packagelessAddressAmbiguityRefusal('save', request.type, request.name, address.candidates); + } + if (address.packageId !== null) request = { ...request, packageId: address.packageId }; + } + const singularType = PLURAL_TO_SINGULAR[request.type] ?? request.type; // [ADR-0029 D9.9 / #6995] The refusal that is about REPRESENTABILITY @@ -22064,6 +22266,9 @@ export class ObjectStackProtocolImplementation implements // [#22877] The saved body's member — the row this save wrote. ...mutationEventMemberOfBody(singularTypeForRepo, request.item), }); + const receiptPackage = typeof request.packageId === 'string' && request.packageId !== '' + ? `, package=${request.packageId}` + : ''; return { success: true, // [#21207] The version token: the keyed form of the stored @@ -22119,9 +22324,15 @@ export class ObjectStackProtocolImplementation implements // claim for another. Distinguishing it honestly needs a read // this path does not already make, and a receipt is not worth // a query — so the verb stays the neutral, true "Saved". + // + // [#23165] …and it names the package whose row this save wrote, + // when the save's address names one: the package the caller + // named, or the one a direct save naming none resolved to + // ({@link packagelessAddress}). A save into the + // package-unbound row reads exactly as it did. message: artifactBacked - ? `Saved customization overlay (env-wide, state=${mode === 'draft' ? 'draft' : 'active'}) — type=${request.type}, name=${request.name} [seq=${result.seq}]` - : `Saved ${singularTypeForRepo} '${request.name}' (env-wide, state=${mode === 'draft' ? 'draft' : 'active'}) [seq=${result.seq}]`, + ? `Saved customization overlay (env-wide${receiptPackage}, state=${mode === 'draft' ? 'draft' : 'active'}) — type=${request.type}, name=${request.name} [seq=${result.seq}]` + : `Saved ${singularTypeForRepo} '${request.name}' (env-wide${receiptPackage}, state=${mode === 'draft' ? 'draft' : 'active'}) [seq=${result.seq}]`, }; } catch (err: any) { if (err instanceof ConflictError) throw await this.saveConflict(err, request, writeSource); @@ -24850,7 +25061,7 @@ export class ObjectStackProtocolImplementation implements * canonical member's was addressed for every draft of the name, so a * second locale's draft answered "no pending draft" and stayed. */ - draft: { type: string; name: string; member?: string }, + draft: { type: string; name: string; member?: string; packageId?: string | null }, actor: string | undefined, ): Promise { await this.deleteMetaItem({ @@ -24859,6 +25070,10 @@ export class ObjectStackProtocolImplementation implements state: 'draft', ...(actor ? { actor } : {}), ...(draft.member !== undefined ? { member: draft.member } : {}), + // [#23165, ADR-0048] The package the draft was listed under: the + // discard removes THAT package's draft, never another package's + // draft of the same name. + ...(draft.packageId !== undefined ? { packageId: draft.packageId } : {}), }); } @@ -25130,6 +25345,12 @@ export class ObjectStackProtocolImplementation implements type: row.type, name: row.name, state, + // [#23165, ADR-0048] The package being uninstalled + // owns this row, so the delete names it. Named no + // package, the delete of a name a second package also + // holds was a driver-order pick between the two rows + // (and is now refused). + packageId: request.packageId, ...(request.actor ? { actor: request.actor } : {}), ...(dropStorage ? { dropStorage: true } : {}), }); @@ -27542,6 +27763,16 @@ export class ObjectStackProtocolImplementation implements dropStorage?: boolean; /** [#22877] The bundle member to reset — {@link resolveBundleMemberSelector}; omitted, the canonical one. */ member?: string; + /** + * [#23165, ADR-0048] The package whose row this delete removes, as a + * save names the row it writes (`?package=` on the REST door). Named, + * the delete removes that package's row and no other. Absent or `null` + * names no package: the delete removes the row of the one package the + * item's rows at its state are bound to, is refused `409 + * RESOURCE_CONFLICT` when they are bound to two or more, and with none + * removes the package-unbound row ({@link packagelessAddress}). + */ + packageId?: string | null; }): Promise<{ success: boolean; message?: string; @@ -27715,18 +27946,70 @@ export class ObjectStackProtocolImplementation implements name: request.name, org: orgId ?? 'env', } as Parameters[0]; + // [#23165] The refusal of a package-less delete of an item two or + // more packages hold: the door's own verdict, rethrown as it is by + // the `catch` below, never re-wrapped as a delete that failed. + let addressRefusal: Error | undefined; try { const targetState: 'active' | 'draft' = request.state === 'draft' ? 'draft' : 'active'; + // [#23165, ADR-0048] THE ADDRESS of the row this delete removes. + // It used to be no address at all: `repo.get` and `repo.delete` + // were handed no package, so each took the first row a + // package-agnostic `findOne` met — one package's row of an item + // two packages hold (the other package's row then served, and + // the receipt said "it no longer exists"), or the package's row + // when a package-less row sat beside it. + // + // Named, the delete removes that package's row and no other. + // Naming none, it takes the save door's resolution + // ({@link packagelessAddress}), read from the rows at the state + // it removes: the one package those rows are bound to, the + // package-unbound row when they are bound to none, and a + // refusal when they are bound to two or more. A draft discard + // reads the draft rows, so it removes the draft that exists, + // wherever the draft save put it (#11087). + // + // Resolved here, below every door that judges the item rather + // than its row (the organization refusal, the packaged-base + // door, the creatable check and the `_lock` gate), as the save + // door orders it. The rows read here are also what the receipt + // below tells the truth about: what is still stored, and so + // still served, once this row is gone. + const rowsAtAddress = await this.packagelessAddress( + singularTypeForRepo, request.name, member, targetState, + ); + if (request.packageId == null && rowsAtAddress.candidates.length > 1) { + addressRefusal = packagelessAddressAmbiguityRefusal( + 'delete', request.type, request.name, rowsAtAddress.candidates, + ); + throw addressRefusal; + } + const packageId: string | null = request.packageId ?? rowsAtAddress.packageId; // Probe first — "no overlay exists" is a success/no-op, not // a conflict. The repo would otherwise throw ConflictError. - const current = await repo.get(ref, { state: targetState, ...memberOption(member) }); + const current = await repo.get(ref, { state: targetState, packageId, ...memberOption(member) }); // [#21899] An origin-gated code-defined item with NO stored row: // the delete would remove nothing, and the item itself is not // removable at runtime — refused with the package door's own // verdict, the one its save gets. Rethrown as it is by the // `catch` below. if (!current && originGatedRefusal !== null) throw originGatedRefusal; + // [#23165] A delete NAMING a package that holds no row here, of + // an item whose other rows are stored: nothing is removed and + // those rows are still served, so neither the registry heal + // below nor its "already at artifact default" / "nothing to + // delete" claim holds. A delete naming none never lands here: + // it addresses a row the read above found. + if (!current && request.packageId != null && (rowsAtAddress.candidates.length > 0 || rowsAtAddress.unbound)) { + return { + success: true, + reset: false, + message: targetState === 'draft' + ? `No pending draft of ${request.type}/${request.name} is bound to package '${request.packageId}', so nothing was discarded: ${remainingRowsPhrase(rowsAtAddress.candidates, rowsAtAddress.unbound, 'draft')}.` + : `No row of ${request.type}/${request.name} is bound to package '${request.packageId}', so nothing was deleted: ${remainingRowsPhrase(rowsAtAddress.candidates, rowsAtAddress.unbound, 'row')}.`, + }; + } if (!current) { // Self-heal: even with no overlay row, a stale runtime // shadow may linger in the registry (e.g. pollution from @@ -27787,6 +28070,10 @@ export class ObjectStackProtocolImplementation implements ? 'override-artifact' : 'runtime-only', state: targetState, + // [#23165] The row the probe read, by its key — never the + // repository's "any package" match, which takes the first + // row it meets. + packageId, ...memberOption(member), }); @@ -27843,6 +28130,12 @@ export class ObjectStackProtocolImplementation implements organizationId: orgId, ...mutationEventMember(singularTypeForRepo, member), }); + // [#23165] What is still stored at this address once this row + // is gone, from the rows the address read already holds. + const remainingPackages = rowsAtAddress.candidates.filter((id) => id !== packageId); + const unboundRemains = rowsAtAddress.unbound && packageId !== null; + const remains = remainingPackages.length > 0 || unboundRemains; + const deletedPackage = packageId !== null ? ` (package=${packageId})` : ''; return { success: true, reset: true, @@ -27867,17 +28160,31 @@ export class ObjectStackProtocolImplementation implements // The draft leg discards a pending draft and never claimed // a reset, so it is unchanged. `[seq=…]` stays on every // branch — HMR cursors read it. + // + // [#23165] Both active sentences claim what is left once + // this row is gone, and that is true only when no other row + // of the item is stored at this address: a delete of one + // package's row leaves another package's row, or the + // package-less row beside it, stored and served. Then the + // receipt names what remains instead. A delete of a + // package's row names the package, as the save receipt + // does; a delete of the package-unbound row with nothing + // left reads byte for byte as before. message: (request.state === 'draft') ? `Draft discarded — ${request.type}/${request.name}. [seq=${result.seq}]` : artifactBacked - ? `Customization overlay deleted — ${request.type}/${request.name} reset to artifact default. [seq=${result.seq}]` - : `Deleted ${singularTypeForRepo} '${request.name}' — it no longer exists. [seq=${result.seq}]`, + ? (remains + ? `Customization overlay deleted${deletedPackage} — ${request.type}/${request.name} still carries another overlay: ${remainingRowsPhrase(remainingPackages, unboundRemains, 'row')}. [seq=${result.seq}]` + : `Customization overlay deleted${deletedPackage} — ${request.type}/${request.name} reset to artifact default. [seq=${result.seq}]`) + : (remains + ? `Deleted ${singularTypeForRepo} '${request.name}'${deletedPackage}, and it still exists: ${remainingRowsPhrase(remainingPackages, unboundRemains, 'row')}. [seq=${result.seq}]` + : `Deleted ${singularTypeForRepo} '${request.name}'${deletedPackage} — it no longer exists. [seq=${result.seq}]`), }; } catch (err: any) { // [#21899] The door's own verdict, not a store failure: it // leaves with its envelope and sentence, never re-wrapped as an // overlay delete that failed. - if (err === originGatedRefusal) throw err; + if (err === originGatedRefusal || (addressRefusal !== undefined && err === addressRefusal)) throw err; if (err instanceof ConflictError) { // [#21207] Keyed values or none in the text and attributes. const conflict = await this.metadataConflictRefusal( diff --git a/packages/rest/src/meta-draft-head-package-inheritance.test.ts b/packages/rest/src/meta-draft-head-package-inheritance.test.ts index 46512cd8029..c463e3291d4 100644 --- a/packages/rest/src/meta-draft-head-package-inheritance.test.ts +++ b/packages/rest/src/meta-draft-head-package-inheritance.test.ts @@ -167,7 +167,11 @@ async function boot(opts: { item?: { type: string; name: string }; permissions?: }, { context: { isSystem: true } } as any); }; - return { read, save, stored, storedBodies, seed }; + /** `DELETE /meta/view/case_grid` (the reset). */ + const remove = (query: Record = {}, headers: Record = {}) => + call('DELETE', `${META}/:type/:name`, { path, params: item, query, headers }); + + return { read, save, remove, stored, storedBodies, seed }; } const ok = (res: any) => { @@ -263,17 +267,23 @@ describe('[#22128] `?package=all`: the read resolves `all` the way the save does expect(await h.stored('active')).toEqual([{ label: 'edited', packageId: null }]); }, 60_000); - it('active, no row at the env-local address: the read serves `null`, and `If-None-Match: *` creates it', async () => { + // [#23165] Replaced, not respelled: this case pinned the read serving + // `null` and `If-None-Match: *` minting an env-local row BESIDE the + // package's row, which is the defect #23165 removes (a package-less save + // of an item a package owns writes that package's row). The invariant the + // rider asked for still holds and is what is pinned: `all` names no + // package on both doors, so the read's token is the one the save compares + // against, and the save writes the row the read serves. + it('active, an item a package owns: the read serves that package row\'s `version`, and the save naming no package addresses the same row', async () => { const h = await boot(); ok(await h.save(view('live'), { package: PKG })); - expect(ok(await h.read({ package: 'all' })).version).toBeNull(); - const created = ok(await h.save(view('env-local'), { package: 'all' }, { 'if-none-match': '*' })); - expect(ok(await h.read({ package: 'all' })).version).toBe(created.version); - expect(await h.stored('active')).toEqual(expect.arrayContaining([ - { label: 'live', packageId: PKG }, - { label: 'env-local', packageId: null }, - ])); + const token = ok(await h.read({ package: 'all' })).version; + expect(token).toMatch(KEYED); + const refused = refusedConflict(await h.save(view('as if first'), { package: 'all' }, { 'if-none-match': '*' })); + expect(refused.currentVersion).toBe(token); + ok(await h.save(view('edited'), { package: 'all' }, { 'if-match': token })); + expect(await h.stored('active')).toEqual([{ label: 'edited', packageId: PKG }]); }, 60_000); it('draft of a package-owned item: `?state=draft&package=all` serves the inherited draft and its token, and the save accepts it', async () => { @@ -350,3 +360,63 @@ describe('[#22128] the credential carry-forward reads the row the draft save ove expect(drafts.map((r) => [r.body.label, r.packageId, holdsCredential(r.body)])).toEqual([['draft 2', null, true]]); }, 60_000); }); + +/** + * [#23165, ADR-0048] The direct save and the delete that name no package, on + * the real routes: they address the row of the one package the item belongs + * to, a name two packages hold is refused, and `?package=` on the delete door + * removes that package's row. The protocol suite + * (`protocol.packageless-save-address.test.ts` in metadata-protocol) holds the + * rest of the matrix over a stub engine; these drive the HTTP doors over + * better-sqlite3. + */ +describe('[#23165] a save or a delete naming no package addresses the one package\'s row, on the real routes', () => { + const OTHER_PKG = 'com.probe.other'; + + /** A refusal of a write naming no package, of a name two packages hold. */ + const refusedAmbiguous = (res: any) => { + expect(res.statusCode, JSON.stringify(res.body)).toBe(409); + expect(res.body.code).toBe('RESOURCE_CONFLICT'); + return res.body; + }; + + it('the card\'s probe: the package-less save writes the package\'s row and the read serves it; the package-less delete removes that row', async () => { + const h = await boot(); + ok(await h.save(view('live'), { package: PKG })); + + const saved = ok(await h.save(view('disabled'))); + expect(saved.message).toContain(`package=${PKG}`); + expect(await h.stored('active')).toEqual([{ label: 'disabled', packageId: PKG }]); + expect(ok(await h.read()).item.label).toBe('disabled'); + + const deleted = ok(await h.remove()); + expect(deleted.reset).toBe(true); + expect(await h.stored('active')).toEqual([]); + }, 60_000); + + it('two packages: the package-less save and delete are refused 409 and change nothing; `?package=` on the delete removes that row only', async () => { + const h = await boot(); + ok(await h.save(view('A'), { package: PKG })); + ok(await h.save(view('B'), { package: OTHER_PKG })); + const both = [{ label: 'A', packageId: PKG }, { label: 'B', packageId: OTHER_PKG }]; + + expect(refusedAmbiguous(await h.save(view('neither'))).error).toContain(OTHER_PKG); + expect(refusedAmbiguous(await h.remove()).error).toContain(OTHER_PKG); + expect(await h.stored('active')).toEqual(expect.arrayContaining(both)); + expect(await h.stored('active')).toHaveLength(2); + + const deleted = ok(await h.remove({ package: PKG })); + expect(deleted.reset).toBe(true); + expect(deleted.message).toContain(OTHER_PKG); + expect(await h.stored('active')).toEqual([{ label: 'B', packageId: OTHER_PKG }]); + expect(ok(await h.read()).item.label).toBe('B'); + }, 60_000); + + it('control: an item with no package binding is saved and deleted at the package-unbound row, as before', async () => { + const h = await boot(); + ok(await h.save(view('plain'))); + expect(await h.stored('active')).toEqual([{ label: 'plain', packageId: null }]); + expect(ok(await h.remove()).reset).toBe(true); + expect(await h.stored('active')).toEqual([]); + }, 60_000); +}); diff --git a/packages/rest/src/rest-server-meta-member-selector.test.ts b/packages/rest/src/rest-server-meta-member-selector.test.ts index b08123657ca..6b52157c4da 100644 --- a/packages/rest/src/rest-server-meta-member-selector.test.ts +++ b/packages/rest/src/rest-server-meta-member-selector.test.ts @@ -143,7 +143,7 @@ describe('[#22877] each by-name /meta door declares a closed query set, the memb it('the measured sets — paging, switches and the read a frame down included', () => { expect([...META_ITEM_READ_PARAMS].sort()).toEqual(['layers', 'locale', 'member', 'package', 'preview', 'state']); - expect([...META_ITEM_RESET_PARAMS].sort()).toEqual(['dropStorage', 'member', 'state']); + expect([...META_ITEM_RESET_PARAMS].sort()).toEqual(['dropStorage', 'member', 'package', 'state']); expect([...META_ITEM_HISTORY_PARAMS].sort()).toEqual(['limit', 'member', 'sinceSeq']); expect([...META_ITEM_PUBLISH_PARAMS].sort()).toEqual(['member', 'package']); expect([...META_ITEM_ROLLBACK_PARAMS].sort()).toEqual(['member', 'toVersion']); @@ -209,6 +209,27 @@ describe('[#22877] PRESERVATION — the request each protocol door receives', () }); }); + it('[#23165] DELETE item: `?package=` reaches `deleteMetaItem` as `packageId`; `all` and an empty value name none', async () => { + for (const [raw, packageId] of [['com.acme.mail', 'com.acme.mail'], ['all', undefined], ['', undefined]] as const) { + const { protocol, drive } = boot(); + const answer = await drive('DELETE', ITEM, { package: raw }); + expect(answer.status, `${raw}: ${JSON.stringify(answer.body)}`).toBe(200); + expect(protocol.deleteMetaItem.mock.calls[0][0], raw).toEqual({ + type: 'email_template', name: 'acme.welcome', actor: 'u1', + ...(packageId !== undefined ? { packageId } : {}), + }); + } + }); + + it('[#23165] DELETE item: REFUSAL — a repeated `?package=` is a located 400 and the protocol is never called', async () => { + const { protocol, drive } = boot(); + const answer = await drive('DELETE', ITEM, { package: ['com.acme.mail', 'com.acme.other'] }); + expect(answer.status).toBe(400); + expect(answer.body?.error?.code).toBe('VALIDATION_ERROR'); + expect(answer.body?.error?.message).toContain('"package" query parameter was supplied 2 times'); + expect(protocol.deleteMetaItem).not.toHaveBeenCalled(); + }); + it('GET /history: the member reaches `historyMetaItem` beside the paging pair', async () => { const { protocol, drive } = boot(); const answer = await drive('GET', `${ITEM}/history`, { member: 'zh-CN', sinceSeq: '3', limit: '10' }); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index fb7a8dda55f..2da3e1f00e6 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -670,9 +670,12 @@ export { * [#22877] The closed set of `DELETE {metaPath}/:type/:name` (the reset). * **Measured**: `state` (the draft discard) and `dropStorage` (the opt-in * table teardown); the OCC pin and the actor ride headers and the session. - * Plus {@link META_ITEM_MEMBER_PARAM}. + * Plus {@link META_ITEM_MEMBER_PARAM}. [#23165, ADR-0048] Plus `package`, the + * row the delete removes, read through {@link metaItemPackageBinding} as the + * other doors of the item read it: a delete naming no package of an item two + * packages hold is refused `409`, and naming the package is its remedy. */ -export const META_ITEM_RESET_PARAMS: readonly string[] = ['state', 'dropStorage', META_ITEM_MEMBER_PARAM]; +export const META_ITEM_RESET_PARAMS: readonly string[] = ['state', 'dropStorage', 'package', META_ITEM_MEMBER_PARAM]; /** * [#22877] The closed set of `GET {metaPath}/:type/:name/history`. @@ -7324,7 +7327,7 @@ export class RestServer { // is not what the request asked for. // [#22877] Recognition first, over the measured closed set. if (refuseUnknownQueryParams(req, res, META_ITEM_RESET_PARAMS)) return; - if (refuseRepeatedQueryParams(req, res, ['state', 'dropStorage', META_ITEM_MEMBER_PARAM])) return; + if (refuseRepeatedQueryParams(req, res, ['state', 'dropStorage', 'package', META_ITEM_MEMBER_PARAM])) return; const stateParam = typeof req.query?.state === 'string' && req.query.state.toLowerCase() === 'draft' ? 'draft' as const @@ -7335,6 +7338,7 @@ export class RestServer { // previewed object" flow so a publish-to-preview leaves no // orphan table. Destructive — opt-in, defaults off. const dropStorage = req.query?.dropStorage === 'true' || req.query?.dropStorage === '1'; + const deletePackageId = metaItemPackageBinding(req.query?.package); // [ADR-0131 D6] No organization, as on the `PUT` twin: the // reset reaches the environment-wide row. @@ -7364,6 +7368,10 @@ export class RestServer { ...(dropStorage ? { dropStorage: true } : {}), // [#22877] Resets ONE member of a bundled type. ...metaMemberSelector(req.query), + // [#23165, ADR-0048] The package whose row the delete + // removes; `all` and an empty value name none, as on + // the save door, and the protocol then resolves it. + ...(deletePackageId ? { packageId: deletePackageId } : {}), }; const result = await p.deleteMetaItem(deleteRequest); res.json(result); diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index d29a7e616fc..aad9e323e64 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -498,6 +498,7 @@ "api/DeleteMetaItemRequest:dropStorage", "api/DeleteMetaItemRequest:member", "api/DeleteMetaItemRequest:name", + "api/DeleteMetaItemRequest:packageId", "api/DeleteMetaItemRequest:parentVersion", "api/DeleteMetaItemRequest:state", "api/DeleteMetaItemRequest:type", diff --git a/packages/spec/src/api/protocol.test.ts b/packages/spec/src/api/protocol.test.ts index 37dd7c8f0ec..2917b335ffc 100644 --- a/packages/spec/src/api/protocol.test.ts +++ b/packages/spec/src/api/protocol.test.ts @@ -2263,6 +2263,7 @@ describe('DeleteMetaItemRequestSchema declares the contract members the reset do actor: 'admin@objectos.ai', state: 'draft', dropStorage: true, + packageId: 'com.acme.crm', }; const result = DeleteMetaItemRequestSchema.safeParse(full); expect(result.success).toBe(true); @@ -2271,6 +2272,15 @@ describe('DeleteMetaItemRequestSchema declares the contract members the reset do } }); + it('[ADR-0048] packageId names the row the delete removes; null names no package, as on the save', () => { + for (const packageId of ['com.acme.crm', null] as const) { + const result = DeleteMetaItemRequestSchema.safeParse({ ...base, packageId }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.packageId).toBe(packageId); + } + expect(DeleteMetaItemRequestSchema.safeParse({ ...base, packageId: 42 }).success).toBe(false); + }); + it('requires type AND name — the reset addresses one item', () => { expect(DeleteMetaItemRequestSchema.safeParse(base).success).toBe(true); expect(DeleteMetaItemRequestSchema.safeParse({ type: 'view' }).success).toBe(false); @@ -2348,6 +2358,7 @@ describe('MetadataProtocol.deleteMetaItem types against the caught-up request sc actor: 'admin', state: 'draft', dropStorage: true, + packageId: 'com.acme.crm', }; expect(good.type).toBe('view'); // @ts-expect-error `environmentId` is transport-level (commit 2a29caa53) — not a declared request member; the REST door layers it on via TransportScopedMetaRequest. diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index 6e2840b1dde..ef0dc2a2b94 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -713,11 +713,14 @@ export const SaveMetaItemRequestSchema = lazySchema(() => z.object({ + 'a Studio package workspace; a named read-only base package is ' + 'refused. On create the row is stamped with this id; on update an ' + 'existing binding is preserved, never silently re-bound. Absent or ' - + '`null` names no package: an `active` save writes the env-local ' - + 'overlay (no package stamp), and a `draft` save writes the draft of ' + + '`null` names no package. An `active` save then writes the row of the ' + + 'one package the item\'s active rows are bound to, as a save naming ' + + 'that package does, is refused `409 RESOURCE_CONFLICT` when they are ' + + 'bound to two or more, and with none writes the env-local overlay (no ' + + 'package stamp); a `draft` save writes the draft of ' + 'the package the item\'s active row is bound to when it is ' + 'package-bound (a pending change stays in the package it edits), else ' - + 'the env-local draft. That row is also the one the parent-version ' + + 'the env-local draft. The row written is also the one the parent-version ' + 'resolution reads: an unpinned save takes its version as the parent ' + '(last-write-wins), a pinned one is judged against it, and the item ' + 'read at the same scope serves it as `version`.', @@ -1381,6 +1384,19 @@ export const DeleteMetaItemRequestSchema = lazySchema(() => z.object({ + 'object" flow so a publish-to-preview leaves no orphan table.', ), member: z.string().optional().describe(BUNDLE_MEMBER_SELECTOR_DESCRIPTION), + packageId: z.string().nullable().optional().describe( + 'ADR-0048 — the software package whose row this delete removes ' + + '(`sys_metadata.package_id`; `?package=` on the REST door, where ' + + '`all` and an empty value name no package), as a save names the row it ' + + 'writes. Named, the delete removes that package\'s row and no other; a ' + + 'package that holds no row removes nothing. Absent or `null` names no ' + + 'package: the delete then removes the row of the one package the ' + + 'item\'s rows at the deleted `state` are bound to, is refused `409 ' + + 'RESOURCE_CONFLICT` when they are bound to two or more, and with none ' + + 'removes the env-local row (no package stamp). That row is the one ' + + '`parentVersion` is judged against. When another row of the item is ' + + 'still stored afterwards, the receipt `message` names it.', + ), })); /** @@ -1388,8 +1404,10 @@ export const DeleteMetaItemRequestSchema = lazySchema(() => z.object({ * * Describes the FULL body `DELETE /api/v1/meta/:type/:name` returns (#13155 — * the #5745 ruling carried to the third verb on this door). `reset === true` - * means a row was deleted (item is now at artifact default); `reset === false` - * means no overlay row existed (already at artifact default). + * means a row was deleted; `reset === false` means the delete's address held + * no row, so nothing was removed. Either way the item is at its artifact + * default only when no other row of it is still stored, and the `message` + * names any that is (ADR-0048: one row per package). * * **Why the widening.** The metadata door has three write verbs, all running * the same ADR-0094 mutation projector and the same history append, and all diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index d89784d4801..ca84b36b564 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -1121,6 +1121,21 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/metadata-protocol/src/protocol.packageless-save-address.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/protocol.packageless-save-address.test.ts", + "verb": "findOne", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/protocol.packageless-save-address.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/metadata-protocol/src/protocol.platform-schedule-org-gate.test.ts", "verb": "delete",