diff --git a/.changeset/23291-packageless-read-serves-save-address.md b/.changeset/23291-packageless-read-serves-save-address.md new file mode 100644 index 00000000000..bf17451c2cc --- /dev/null +++ b/.changeset/23291-packageless-read-serves-save-address.md @@ -0,0 +1,25 @@ +--- +'@objectstack/metadata-protocol': patch +--- + +fix(metadata-protocol): a metadata read that names no package serves the row a save that names no package writes, when a package-unbound row of the item is stored beside its one package's row (ADR-0048, ADR-0070) + +Clause-②: no + +- **What was wrong.** A save naming a package (`PUT /api/v1/meta/TYPE/NAME?package=P`) of an item that already had a package-unbound row stored the package's row beside it, with the unbound row first. The read naming no package (`GET /api/v1/meta/TYPE/NAME`) then served the unbound row's body. Meanwhile the save naming no package wrote the package's row, the delete naming no package removed it, the list served it, and the read's own `version` was the package row's token. An author editing the item without `?package=` was served a body that no save changed. A save pinned to the served token overwrote the package's row with an edit of the unbound body. +- **What it does now.** For an item whose stored active rows are bound to exactly one package, a read that names no package takes its stored content from what the read naming that package takes it from: the package's own row, else the package-unbound one. That is the address the package-less save, delete and version token already resolve. The doors whose served row changes: + - the item read, `GET /api/v1/meta/TYPE/NAME`; + - the layered read, `GET …/layers` and the deprecated `?layers=` (its `overlay` and `effective`); + - the published read, `GET …/published`, on the REST server and on the dispatcher; + - the draft reads, `?state=draft` and the draft preview, whose address comes from the same active rows, because the package-less draft save inherits that package. + + An item bound to two or more packages is read as before: the first stored row, ADR-0048 §3.3's best effort. A save or delete naming none of them is still refused `409 RESOURCE_CONFLICT`. The lock reported by these reads is selected as before. +- **What follows the read.** In-process readers of the item that name no package see the same row: the data doors' object reads (import reference targets, export and template fields), seed apply's object lookup, and the security catalog echo of a `permission` or `position`. +- **The `object` save's destructive-change check (`409 DESTRUCTIVE_CHANGE`) now judges the row the save overwrites**, read at the save's own address. Before, it read naming no package and judged the first stored row, which is the package-unbound row in the order that saves produce it. In this two-row state that moves its verdict in both directions: + - A package-less save that drops a field only the package's row has is now refused. Before, it was judged against the package-unbound row and accepted, and the field was dropped with no confirmation. + - A package-less save that drops a field only the package-unbound row has is now accepted. Before, it was refused over a row it does not write. + - A save naming a package with no row of its own is judged against the package-unbound row it overlays, never another package's row. + + The rule and its remedy (`force`) are unchanged. +- **The draft read's corner: a 200 becomes a 404.** Take an item whose active rows are bound to package P and whose only draft is bound to another package Q. Naming no package, the strict draft read (`?state=draft`) now answers `404 NO_DRAFT` instead of serving Q's draft. P's draft is what a package-less draft save would write, and there is none; the read's `version` was already `null`. Read that draft with `?package=Q`. The draft preview naming no package now serves P's active row. +- **What does not change.** No stored row is rewritten or removed. No key, export, schema or error code moves. No metadata write door's own rule moves. The package-unbound row still stands in for every package that has no row of its own: a read naming such a package serves it. Once the package's row is deleted, it is what the read and the next package-less save address again. 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 4e12caad209..f48c38f9720 100644 --- a/packages/metadata-protocol/src/protocol.packageless-save-address.test.ts +++ b/packages/metadata-protocol/src/protocol.packageless-save-address.test.ts @@ -41,6 +41,11 @@ * - 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). + * - [#23291] the read naming no package serves the row that save writes, + * with a package-unbound row stored beside the package's: the card's five + * steps, both storage orders (the plain and the layered read), the draft + * reads and their corner, the stand-in for another package, and an item + * another package holds too. * * Refusals are asserted as the envelope an HTTP boundary answers with * (`resolveThrownHttpError`): `code` and `status`, never a bare throw. @@ -686,3 +691,271 @@ describe('[#23297] CONTROL: one package and a package-less active row — the dr }); } }); + +/** + * [#23291, ADR-0048 §3.3, ADR-0070 TL;DR 1] The read that names no package + * serves the row a save naming none writes. + * + * A save naming a package, of an item whose only row is the package-unbound + * one, stores the package's row beside it, the unbound row first. The read + * naming no package took the first row of any package, so it kept serving the + * unbound body while the package-less save wrote the package's row (#23165), + * the package-less delete removed it, the list served it, and the read's own + * `version` named it: one name, two rows. + * + * The read now resolves the address the save and the delete resolve + * (`packagelessAddress`): for an item bound to exactly one package it serves + * what the read naming that package serves. The package-unbound row is kept: + * ADR-0070 leaves it as a read-side detail and a migration source, and it still + * stands in for every package with no row of its own (ADR-0048 prefer-local), + * which `protocol.org-scoped-write-refused.test.ts` pins for view containers. + * + * Each step's read is asserted against the address its save resolved. + */ +describe('[#23291] the read naming no package serves the row the save naming none writes', () => { + it('the card\'s five steps: every read serves the row the save addressed', async () => { + const { protocol, rows } = makeProtocol(); + const name = 's6_app'; + + // 1. A package-less save of an item with no binding: the unbound row. + const first = await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'unbound' }) }); + expect(first.message).toBe(`Saved app '${name}' (env-wide, state=active) [seq=${first.seq}]`); + expect(rows('app', name).map((r) => r.package_id ?? null)).toEqual([null]); + expect((await protocol.getMetaItem({ type: 'app', name })).item.label).toBe('unbound'); + + // 2. A save naming the package writes the package's row; the unbound row stays, first. + const named = await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'pkg' }), packageId: WRITABLE }); + expect(named.message).toContain(`package=${WRITABLE}`); + expect(rows('app', name).map((r) => [r.package_id ?? null, JSON.parse(r.metadata).label])) + .toEqual([[null, 'unbound'], [WRITABLE, 'pkg']]); + + // 3. The read naming no package serves the package's row, with the + // content and the version token of the read naming the package. + const unnamedRead = await protocol.getMetaItem({ type: 'app', name }); + const namedRead = await protocol.getMetaItem({ type: 'app', name, packageId: WRITABLE }); + expect(unnamedRead.item).toMatchObject({ label: 'pkg', _packageId: WRITABLE }); + expect(namedRead.item).toMatchObject({ label: 'pkg' }); + expect(unnamedRead.version).toEqual(expect.any(String)); + expect(unnamedRead.version).toBe(namedRead.version); + const layered = await protocol.getMetaItemLayered({ type: 'app', name }); + expect(layered.overlay).toMatchObject({ label: 'pkg' }); + expect(layered.effective).toMatchObject({ label: 'pkg' }); + + // 4. A package-less save, pinned to that token, writes the package's + // row (#23165), and the reads and the list serve what it wrote. + const third = await protocol.saveMetaItem({ + type: 'app', name, item: app(name, { label: 'third' }), parentVersion: unnamedRead.version, + }); + expect(third.message).toBe(`Saved app '${name}' (env-wide, package=${WRITABLE}, state=active) [seq=${third.seq}]`); + expect(bodiesByBinding(rows('app', name))).toEqual({ null: app(name, { label: 'unbound' }), [WRITABLE]: app(name, { label: 'third' }) }); + expect((await protocol.getMetaItem({ type: 'app', name })).item.label).toBe('third'); + expect((await protocol.getMetaItem({ type: 'app', name, packageId: WRITABLE })).item.label).toBe('third'); + const listed = (await protocol.getMetaItems({ type: 'app' })).items.filter((i: any) => i?.name === name); + expect(listed.map((i: any) => i.label)).toEqual(['third']); + + // 5. A package-less delete removes the package's row and says the + // package-less row remains; that row is now the address of the + // read and of the next package-less save alike. + const receipt = await protocol.deleteMetaItem({ type: 'app', name }); + expect(receipt.message).toBe( + `Deleted app '${name}' (package=${WRITABLE}), and it still exists: its package-less row remains. [seq=${receipt.seq}]`, + ); + expect(rows('app', name).map((r) => r.package_id ?? null)).toEqual([null]); + expect((await protocol.getMetaItem({ type: 'app', name })).item.label).toBe('unbound'); + const after = await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'unbound again' }) }); + expect(after.message).toBe(`Saved app '${name}' (env-wide, state=active) [seq=${after.seq}]`); + expect((await protocol.getMetaItem({ type: 'app', name })).item.label).toBe('unbound again'); + // And a second package-less delete removes it: the item is gone. + const last = await protocol.deleteMetaItem({ type: 'app', name }); + expect(last.message).toBe(`Deleted app '${name}' — it no longer exists. [seq=${last.seq}]`); + expect(rows('app', name)).toEqual([]); + }); + + for (const unboundFirst of [true, false]) { + it(`whichever order the store holds the two rows in (package-unbound row ${unboundFirst ? 'first' : 'second'})`, async () => { + const { protocol, seedRow } = makeProtocol(); + const name = 's6_order_app'; + const unbound = () => seedRow({ type: 'app', name, package_id: null, metadata: JSON.stringify(app(name, { label: 'unbound' })), checksum: 'sha-unbound' }); + if (unboundFirst) unbound(); + seedRow({ type: 'app', name, package_id: WRITABLE, metadata: JSON.stringify(app(name, { label: 'pkg' })), checksum: 'sha-pkg' }); + if (!unboundFirst) unbound(); + + const read = await protocol.getMetaItem({ type: 'app', name }); + + expect(read.item).toMatchObject({ label: 'pkg', _packageId: WRITABLE }); + expect(read.version).toBe((await protocol.getMetaItem({ type: 'app', name, packageId: WRITABLE })).version); + + // The layered read (`/layers`, `?layers=`, `/published`): its + // overlay is the package's row, and `effective` is what the plain + // read returns. + const layered = await protocol.getMetaItemLayered({ type: 'app', name }); + expect(layered.overlay).toMatchObject({ label: 'pkg' }); + expect(layered.effective).toMatchObject({ name, label: 'pkg' }); + expect((layered.effective as any).label).toBe(read.item.label); + expect(layered.overlay).toEqual((await protocol.getMetaItemLayered({ type: 'app', name, packageId: WRITABLE })).overlay); + }); + } + + it('the layered read naming a package with no row of its own still reports the package-unbound row (the stand-in)', async () => { + const { protocol } = makeProtocol(); + const name = 's6_layered_standin_app'; + await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'unbound' }) }); + await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'pkg' }), packageId: WRITABLE }); + + const layered = await protocol.getMetaItemLayered({ type: 'app', name, packageId: OTHER_WRITABLE }); + + expect(layered.overlay).toMatchObject({ label: 'unbound' }); + expect(layered.effective).toMatchObject({ label: 'unbound' }); + }); + + it('the corner: active rows bound to the package, the only draft bound to ANOTHER package — the strict draft read naming none answers 404 NO_DRAFT, the preview serves the active row', async () => { + const { protocol, rows } = makeProtocol(); + const name = 's6_draft_corner_app'; + await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'pkg' }), packageId: WRITABLE }); + await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'other draft' }), packageId: OTHER_WRITABLE, mode: 'draft' }); + expect(rows('app', name, 'draft').map((r) => r.package_id ?? null)).toEqual([OTHER_WRITABLE]); + + // The address a package-less draft save writes is the package's draft, + // and there is none: the absence answer, as its version token was. + const envelope = resolveThrownHttpError(await refusalOf(() => protocol.getMetaItem({ type: 'app', name, state: 'draft' }))); + expect(envelope).toMatchObject({ status: 404, code: 'NO_DRAFT' }); + // That draft is read by naming its package. + expect((await protocol.getMetaItem({ type: 'app', name, state: 'draft', packageId: OTHER_WRITABLE })).item.label).toBe('other draft'); + // The preview naming none falls back to the active row. + const preview = await protocol.getMetaItem({ type: 'app', name, previewDrafts: true }); + expect(preview.item.label).toBe('pkg'); + expect(preview.item._draft).toBeUndefined(); + }); + + it('the package-unbound row still stands in for a package with no row of its own (ADR-0048 prefer-local)', async () => { + const { protocol } = makeProtocol(); + const name = 's6_standin_app'; + await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'unbound' }) }); + await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'pkg' }), packageId: WRITABLE }); + + expect((await protocol.getMetaItem({ type: 'app', name, packageId: OTHER_WRITABLE })).item.label).toBe('unbound'); + }); + + it('the draft reads serve the draft a package-less draft save writes: the package\'s', async () => { + const { protocol, rows, seedRow } = makeProtocol(); + const name = 's6_draft_app'; + await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'unbound' }) }); + await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'pkg' }), packageId: WRITABLE }); + // Drafts stored before this: the package-unbound one first, the package's beside it. + seedRow({ type: 'app', name, state: 'draft', package_id: null, metadata: JSON.stringify(app(name, { label: 'unbound draft' })), checksum: 'sha-ud' }); + seedRow({ type: 'app', name, state: 'draft', package_id: WRITABLE, metadata: JSON.stringify(app(name, { label: 'pkg draft' })), checksum: 'sha-pd' }); + + const strict = await protocol.getMetaItem({ type: 'app', name, state: 'draft' }); + expect(strict.item.label).toBe('pkg draft'); + expect(strict.version).toBe((await protocol.getMetaItem({ type: 'app', name, state: 'draft', packageId: WRITABLE })).version); + expect((await protocol.getMetaItem({ type: 'app', name, previewDrafts: true })).item).toMatchObject({ label: 'pkg draft', _draft: true }); + + // The package-less draft save, pinned to that token, writes that draft + // (it inherits the package of the active rows, #11087). + await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'pkg draft 2' }), mode: 'draft', parentVersion: strict.version }); + expect(bodiesByBinding(rows('app', name, 'draft'))).toEqual({ + null: app(name, { label: 'unbound draft' }), + [WRITABLE]: app(name, { label: 'pkg draft 2' }), + }); + expect((await protocol.getMetaItem({ type: 'app', name, state: 'draft' })).item.label).toBe('pkg draft 2'); + }); + + it('edge: another package holds a row too — the read is ADR-0048 §3.3\'s best effort, and the package-less save and delete are refused', async () => { + const { protocol, rows } = makeProtocol(); + const name = 's6_shared_app'; + await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'unbound' }) }); + await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'pkg' }), packageId: WRITABLE }); + await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'other' }), packageId: OTHER_WRITABLE }); + const before = bodiesByBinding(rows('app', name)); + expect(Object.keys(before).sort()).toEqual([WRITABLE, OTHER_WRITABLE, 'null'].sort()); + + // No package-less write address exists, so neither write is guessed. + for (const run of [ + () => protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'x' }) }), + () => protocol.deleteMetaItem({ type: 'app', name }), + ]) { + expect(resolveThrownHttpError(await refusalOf(run))).toMatchObject({ status: 409, code: 'RESOURCE_CONFLICT' }); + } + expect(bodiesByBinding(rows('app', name))).toEqual(before); + // Each read naming a package serves its own row; the read naming none serves a stored row. + expect((await protocol.getMetaItem({ type: 'app', name, packageId: WRITABLE })).item.label).toBe('pkg'); + expect((await protocol.getMetaItem({ type: 'app', name, packageId: OTHER_WRITABLE })).item.label).toBe('other'); + expect(['unbound', 'pkg', 'other']).toContain((await protocol.getMetaItem({ type: 'app', name })).item.label); + + // A delete naming one package leaves a one-package item: the reads and + // the package-less doors address that package's row again. + await protocol.deleteMetaItem({ type: 'app', name, packageId: OTHER_WRITABLE }); + expect((await protocol.getMetaItem({ type: 'app', name })).item.label).toBe('pkg'); + const saved = await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'pkg 2' }) }); + expect(saved.message).toContain(`package=${WRITABLE}`); + expect((await protocol.getMetaItem({ type: 'app', name })).item.label).toBe('pkg 2'); + }); +}); + +/** + * [#23291] The `object` save's destructive-change check (409 + * `DESTRUCTIVE_CHANGE`) judges the row the save overwrites: the read at the + * save's own address. With a package-unbound row stored before the package's, + * the check used to read naming no package and judge the unbound row, which no + * package-less save writes, and a save naming a third package was judged + * against whichever row was stored first. + */ +describe('[#23291] an object\'s destructive-change check judges the row the save overwrites', () => { + const objectBody = (name: string, fields: readonly string[]) => ({ + name, label: name, sharingModel: 'private', fields: Object.fromEntries(fields.map((f) => [f, { name: f, type: 'text' }])), + }); + /** Unbound `{a, d}` stored first, the package's `{a, c}` beside it. */ + function pairFixture(name: string) { + const fixture = makeProtocol(); + fixture.seedRow({ type: 'object', name, package_id: null, metadata: JSON.stringify(objectBody(name, ['a', 'd'])), checksum: 'sha-unbound' }); + fixture.seedRow({ type: 'object', name, package_id: WRITABLE, metadata: JSON.stringify(objectBody(name, ['a', 'c'])), checksum: 'sha-pkg' }); + return fixture; + } + const fieldsOf = (row: Row) => Object.keys(JSON.parse(row.metadata).fields ?? {}).sort(); + + it('a package-less save dropping a field only the package\'s row has is refused 409 DESTRUCTIVE_CHANGE, and accepted with force', async () => { + const name = 's6_obj_pkg_field'; + const { protocol, rows } = pairFixture(name); + const before = rows('object', name).map((r) => [r.package_id ?? null, fieldsOf(r)]); + + const envelope = resolveThrownHttpError(await refusalOf(() => protocol.saveMetaItem({ + type: 'object', name, item: objectBody(name, ['a', 'd']), + }))); + expect(envelope).toMatchObject({ status: 409, code: 'DESTRUCTIVE_CHANGE' }); + expect(rows('object', name).map((r) => [r.package_id ?? null, fieldsOf(r)])).toEqual(before); + + await protocol.saveMetaItem({ type: 'object', name, item: objectBody(name, ['a', 'd']), force: true }); + expect(Object.fromEntries(rows('object', name).map((r) => [String(r.package_id ?? null), fieldsOf(r)]))) + .toEqual({ null: ['a', 'd'], [WRITABLE]: ['a', 'd'] }); + }); + + it('a package-less save dropping a field only the package-unbound row has is accepted: it writes the package\'s row', async () => { + const name = 's6_obj_unbound_field'; + const { protocol, rows } = pairFixture(name); + + const saved = await protocol.saveMetaItem({ type: 'object', name, item: objectBody(name, ['a', 'c']) }); + + expect(saved.success).toBe(true); + expect(Object.fromEntries(rows('object', name).map((r) => [String(r.package_id ?? null), fieldsOf(r)]))) + .toEqual({ null: ['a', 'd'], [WRITABLE]: ['a', 'c'] }); + }); + + it('a save naming a third package with no row of its own is judged against the package-unbound stand-in it overlays', async () => { + const name = 's6_obj_third_pkg'; + const { protocol, rows } = pairFixture(name); + + // Drops `c`, which only the OTHER package's row has: not this save's row. + const saved = await protocol.saveMetaItem({ type: 'object', name, item: objectBody(name, ['a', 'd']), packageId: OTHER_WRITABLE }); + expect(saved.success).toBe(true); + expect(fieldsOf(rows('object', name).find((r) => r.package_id === OTHER_WRITABLE)!)).toEqual(['a', 'd']); + + // Drops `d`, which the stand-in it overlays has: refused. + const { protocol: second, rows: secondRows } = pairFixture(`${name}_2`); + const secondBefore = secondRows('object', `${name}_2`).map((r) => [r.package_id ?? null, fieldsOf(r)]); + const envelope = resolveThrownHttpError(await refusalOf(() => second.saveMetaItem({ + type: 'object', name: `${name}_2`, item: objectBody(`${name}_2`, ['a', 'c']), packageId: OTHER_WRITABLE, + }))); + expect(envelope).toMatchObject({ status: 409, code: 'DESTRUCTIVE_CHANGE' }); + expect(secondRows('object', `${name}_2`).map((r) => [r.package_id ?? null, fieldsOf(r)])).toEqual(secondBefore); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 5983e78e1f1..ca6a1b3e806 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -10689,6 +10689,29 @@ export class ObjectStackProtocolImplementation implements // is answered as a draft, as above. const storedRowDeclined = readState === 'active' && this.declinesStoredRow(request.type, request.name); + // [#23291, ADR-0048 §3.3, ADR-0070 TL;DR 1] The package whose stored + // content a read that names no package serves: the address a save or + // a delete naming none writes ({@link packagelessAddress}), read from + // the item's ACTIVE rows at both states, as the draft save inherits + // its package from them (#11087). For an item bound to exactly one + // package, the read then serves what the read naming that package + // serves (its own row, else the package-less one); otherwise any row, + // the best-effort first match ADR-0048 §3.3 states. + // + // Before, it took the first row of any package. A save naming the + // package beside the item's package-unbound row mints the package's + // row and leaves the unbound one stored first, so this read kept + // serving the unbound body while the package-less save wrote the + // package's row, the package-less delete removed it, the list served + // it, and this read's own `version` named it (#23165). The package-less + // row is not converted or removed: ADR-0070 keeps it as a read-side + // detail and a migration source, and it still stands in for every + // package with no row of its own (ADR-0048 prefer-local). + // + // Content only: the lock is selected from the item's rows at the + // address the `_lock` gate judges a package-less save at, below. + const contentPackageId = request.packageId || await this.packagelessReadAddress(request.type, request.name, member); + // ADR-0033 draft-overlay preview (non-strict): when the caller opts in // (admin-gated upstream), prefer a `state='draft'` row if one exists, else // fall back to the active read below. This differs from the strict @@ -10704,7 +10727,7 @@ export class ObjectStackProtocolImplementation implements type: request.type, name: request.name, state: 'draft', - ...(request.packageId ? { packageId: request.packageId } : {}), + ...(contentPackageId ? { packageId: contentPackageId } : {}), ...memberOption(member), }))?.row; if (draftRec) { @@ -10750,7 +10773,7 @@ export class ObjectStackProtocolImplementation implements type: request.type, name: request.name, state: readState, - ...(request.packageId ? { packageId: request.packageId } : {}), + ...(contentPackageId ? { packageId: contentPackageId } : {}), ...memberOption(member), }))?.row; storedRowServed = record !== undefined && record !== null; @@ -11299,6 +11322,14 @@ export class ObjectStackProtocolImplementation implements // strictest lock among the item's stored rows in scope, never only the // row this layer reports. let overlayLockLayer: unknown; + // [#23291] Naming no package, the overlay comes from the address + // {@link getMetaItem} serves its content from + // ({@link packagelessReadAddress}), so `effective` is what that read + // returns and `/published` serves the same document. Content only: the + // lock stays at the request's address, as the `_lock` gate judges it. + // Asked outside the `try` below: its own failed read already answers + // the #5532 envelope, with the driver's error as `cause`. + const contentPackageId = request.packageId || await this.packagelessReadAddress(request.type, request.name); try { // ADR-0048 prefer-local. [#21716] The ONE served-row resolution // {@link getMetaItem} calls too — see {@link findServedOverlayRow}. @@ -11306,7 +11337,7 @@ export class ObjectStackProtocolImplementation implements type: request.type, name: request.name, state: 'active', - ...(request.packageId ? { packageId: request.packageId } : {}), + ...(contentPackageId ? { packageId: contentPackageId } : {}), }); overlayLockLayer = await this.overlayLockLayerAt({ type: request.type, @@ -18776,6 +18807,19 @@ export class ObjectStackProtocolImplementation implements return stored === null ? null : this.receiptVersion(stored); } + /** + * [#23291, ADR-0048 §3.3] The package a by-name read that names no package + * takes its stored CONTENT from: the item's own package when its active + * rows are bound to exactly one ({@link packagelessAddress}, the address + * the save, the delete and the read's `version` resolve), else + * `undefined`, any row (the best-effort first match). {@link getMetaItem} + * and {@link getMetaItemLayered} both ask it, so the plain read, the + * layered read's `effective` and `/published` serve one row. + */ + private async packagelessReadAddress(type: string, name: string, member?: string): Promise { + return (await this.packagelessAddress(PLURAL_TO_SINGULAR[type] ?? type, name, member)).packageId ?? undefined; + } + /** * [#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 @@ -22024,9 +22068,20 @@ export class ObjectStackProtocolImplementation implements // reachable type set and carries the measurements above. if (!request.force && singularType === 'object') { try { + // [#23291, ADR-0048] Judged against the row this save + // overwrites: the read at the save's own address. A + // package-less publish save has resolved `packageId` to the + // item's one package above (#23165); a save naming a package + // with no row of its own reads that package's address, the + // package-unbound stand-in it overlays. Before, the read named + // no package, so with a package-unbound row stored beside the + // package's it judged a row no package-less save writes, and a + // save naming a third package was judged against another + // package's row. const existing = await this.getMetaItem({ type: request.type, name: request.name, + ...(request.packageId ? { packageId: request.packageId } : {}), } as any); const prev = (existing as any)?.item; if (prev) {