Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/23297-package-axis-delete-draft.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@objectstack/metadata-protocol': minor
---

fix(metadata-protocol)!: a delete naming one package keeps the item's other rows registered, a draft save that names no package of an item two or more packages hold is refused `409 RESOURCE_CONFLICT`, and the delete's `_lock` refusal carries the named package's reason (ADR-0048)

Clause-②: yes (narrowing)

<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no authorable spelling, export or stored shape is removed, renamed or re-shaped, and no stored row is rewritten, converted or deleted, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is one runtime write door: a draft save through the metadata API that names no package, of an item whose active rows two or more packages hold, is refused; the remedy is in the request a caller sends (name the package with `?package=`), never a rewrite of a package source or a stored row. What widens is the runtime registry after a delete that names one package: the item's remaining stored row stays registered, so data CRUD keeps serving an object the item read serves. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and no exported declaration is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->

**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. Since the save and the delete name their package (`?package=` on the REST door, `packageId` in process), three paths still did not take the package they were handed.

**The registry after a delete naming one package.** FROM: `DELETE /api/v1/meta/object/my_object?package=com.acme.a` of an object two packages hold removed `com.acme.a`'s row and the receipt said the other package's row remains, and the item read served that row, but the registry entry was retired as if the deleted row were the whole item. Data CRUD then answered `404 OBJECT_NOT_FOUND` for the object until the next boot or save, and on a kernel whose registry holds the stored rows, registry lookups missed an item of any other type the same way. TO: when a row of the item is still stored after a delete, that row is registered again, as a boot registers it, and nothing is retired. Deleting the last row still retires the item.

**A draft save that names no package.** FROM: `PUT /api/v1/meta/view/my_view?mode=draft` with no `?package=`, of an item whose active rows two packages hold, answered `200` "(env-wide, state=draft)" and stored the draft in whichever package the database returned first. With one package and a package-less row beside it, the draft went into the package or stayed package-less by the same order. TO: the draft inherits the package of the item's active rows when they are bound to one package, whatever the storage order (a package-less row beside it is not a second package). When they are bound to two or more, the save is refused `409 RESOURCE_CONFLICT`, writes nothing, and names the packages with the `?package=` remedy, exactly as the direct save and the delete answer. A draft read that names no package serves the package-unbound draft's version for such an item.

**The delete's `_lock` refusal.** The delete's lock gate now takes the package the delete names, as the save's does. The verdict is unchanged: the lock is the strictest of every package that ships or stores the item, so a delete naming a package is refused whenever the save naming it is. What changes is the prose: when several packages declare the strictest lock, the `ITEM_LOCKED` refusal of a delete naming a package carries that package's reason, where it carried the reason of the package whose id sorts first.

**Remedy.** For an item two or more packages hold, send the draft save with `?package=` naming the package whose draft it is (`packageId` in process). Nothing else asks anything of a caller.
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
// The trace, measured on `origin/main` — all four routes fold at the producer
// ---------------------------------------------------------------------------
// `applyObjectRegistryMutation` has exactly ONE caller
// (`applyRegistryWriteThrough`), which has exactly SIX:
// (`applyRegistryWriteThrough`), which has exactly SEVEN:
//
// 1. `saveMetaItem` passes `singularTypeForRepo`, and the method
// already ran `canonicalizeMetaRequestType`.
Expand Down Expand Up @@ -66,6 +66,12 @@
// `'view'`. It never passes `'object'`, so it
// never reaches `applyObjectRegistryMutation`:
// an orphaned object is rebound in storage only.
// 7. `restoreArtifactRegistryView` (#23297) folds at the call site through
// `canonicalMetaType`, the key it also reads
// the stored rows under, and registers a row
// still stored after a delete (another
// package's, or the package-less one), so the
// registry keeps serving what the store serves.
//
// Both fold maps resolve the plural — `PLURAL_TO_SINGULAR.objects === 'object'`
// and `canonicalMetaUrlType('objects') === 'object'` — so no route can deliver
Expand Down Expand Up @@ -567,9 +573,9 @@ describe('[#8862] the `applyRegistryWriteThrough` caller set stays closed', () =
'utf8',
);

it('has exactly six call sites, all traced in this file’s header', () => {
it('has exactly seven call sites, all traced in this file’s header', () => {
const callSites = source.match(/this\.applyRegistryWriteThrough\(/g) ?? [];
expect(callSites).toHaveLength(6);
expect(callSites).toHaveLength(7);
});

it('`applyObjectRegistryMutation` is reached only through the write-through', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -595,3 +595,94 @@ describe('CONTROL: what the delete resolution leaves alone', () => {
expect((await protocol.getMetaItem({ type: 'app', name })).item).toMatchObject({ active: false });
});
});

/**
* [#23297, ADR-0048] The DRAFT save naming no package, of an item two packages
* hold.
*
* The draft save resolves its address in the repository: a draft that names no
* package is stored in the package of the item's active row (#11087,
* `SysMetadataRepository.resolveWriteHead`). That resolution read ONE active
* row with a package-agnostic `findOne`, so with two packages' rows the draft
* went into whichever package the store returned first, and the save answered
* `200` "(env-wide, state=draft)" without naming it. Measured on a booted
* showcase (SQLite): the draft landed in the package whose id sorts first,
* whichever package was saved first. The direct save and the delete refuse
* that request since #23165; the draft save now refuses it the same way, with
* the same `409 RESOURCE_CONFLICT` and the same `?package=` remedy.
*
* Both orders are pinned, so a fix that merely flips which package wins
* cannot pass: the draft is refused under both.
*/
describe('[#23297] an item TWO packages hold: a DRAFT save naming neither is refused', () => {
const ORDERS = [
{ order: `${WRITABLE} saved first`, packages: [WRITABLE, OTHER_WRITABLE] },
{ order: `${OTHER_WRITABLE} saved first`, packages: [OTHER_WRITABLE, WRITABLE] },
] as const;

for (const { order, packages } of ORDERS) {
it(`409 RESOURCE_CONFLICT naming both packages, and no draft is written (${order})`, async () => {
const name = 'shared_draft_save_app';
const { protocol, rows, metadataWrites } = makeProtocol();
for (const packageId of packages) {
await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: `Active ${packageId}` }), packageId });
}
const before = metadataWrites().length;

const err = await refusalOf(() => protocol.saveMetaItem({
type: 'app', name, item: app(name, { label: 'Pending' }), mode: 'draft',
}));

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(rows('app', name, 'draft')).toEqual([]);
expect(metadataWrites()).toHaveLength(before);
});
}

it('naming one of them saves that package\'s draft, and the other package has none', async () => {
const name = 'shared_draft_save_app';
const { protocol, rows } = makeProtocol();
for (const packageId of [WRITABLE, OTHER_WRITABLE]) {
await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: `Active ${packageId}` }), packageId });
}

const saved = await protocol.saveMetaItem({
type: 'app', name, item: app(name, { label: 'Pending' }), mode: 'draft', packageId: OTHER_WRITABLE,
});

expect(saved.state).toBe('draft');
expect(rows('app', name, 'draft').map((r) => r.package_id ?? null)).toEqual([OTHER_WRITABLE]);
});
});

/**
* [#23297] CONTROL: with ONE package the draft still inherits it (#11087), and
* now whichever row the store returns first. A package-less active row beside
* the package's is not a second package (#23165's rule for the direct save):
* the draft goes into the package. Before, the inheritance read one active row,
* so with the package-less row first in storage the draft stayed package-less.
*/
describe('[#23297] CONTROL: one package and a package-less active row — the draft inherits the package in either storage order', () => {
for (const residueFirst of [false, true]) {
it(`the package-less row stored ${residueFirst ? 'first' : 'second'}`, async () => {
const { protocol, rows, seedRow } = makeProtocol();
const name = 'residue_draft_app';
const residue = () => seedRow({
type: 'app', name, package_id: null, metadata: JSON.stringify(app(name, { active: false })), checksum: 'sha-residue',
});
if (residueFirst) residue();
await protocol.saveMetaItem({ type: 'app', name, item: app(name), packageId: WRITABLE });
if (!residueFirst) residue();

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]);
});
}
});
Loading
Loading