diff --git a/.changeset/restore-version-package-scope.md b/.changeset/restore-version-package-scope.md new file mode 100644 index 0000000000..29a98c96d5 --- /dev/null +++ b/.changeset/restore-version-package-scope.md @@ -0,0 +1,45 @@ +--- +"@objectstack/metadata-protocol": patch +--- + +fix(metadata-protocol): rolling back a package-bound overlay row no longer 409s (#6215) + +Every rollback of a metadata item authored inside a Studio package workspace +failed — and failed by blaming a concurrent edit that never happened: + +``` +[metadata_conflict] object/myapp_invoice advanced during rollback. +Expected parent sha256:00ca6e72c... but current is null. +``` + +Both user-facing paths were affected, because both are one call: +`rollbackMetaItem` (the per-item version-history revert) and `revertCommit` +(the package-commit revert) go through `SysMetadataRepository.restoreVersion`. +Only rows with **no** package binding — the legacy shape — rolled back at all, +while ADR-0070 pushes authoring toward always resolving a writable base +package, so the failing share was growing. + +**Cause.** `restoreVersion` read the current active row package-agnostically +and then re-put the historical body without saying which row it meant. `put` +scopes its optimistic-lock lookup by package, and an unstated `packageId` +resolves to the *unbound* row (`package_id IS NULL`) rather than "any package" +— so for a row bound to `app.` the lock looked up a row that does not +exist, read its parent hash as `null`, compared that against the real hash the +first read had just returned, and threw `ConflictError`. The mismatch was +between two reads of the *same* restore, not between two writers. + +**Fix.** `restoreVersion` now reads the raw active row once and takes BOTH +facts from it — the parent hash and the ADR-0048 `package_id` — then states +that binding on the write, the same way `promoteDraft` already did. The row the +lock is taken on is therefore, by construction, the row that gets written. + +This also closes the defect's second face: had the parent check ever passed, +`put` would have found no row in its `IS NULL` scope and **inserted a duplicate +unbound row** beside the bound one instead of updating it. `sys_metadata`'s +partial unique index keys on `COALESCE(package_id,'')`, so a real database +would have accepted that duplicate. + +Unchanged: package-less rows still roll back exactly as before, and a row that +*genuinely* advanced between the rollback's read and its write is still refused +with `METADATA_CONFLICT` / 409. The refusal is narrowed to the case it always +claimed to report, not retired. diff --git a/packages/metadata-protocol/src/sys-metadata-repository.ts b/packages/metadata-protocol/src/sys-metadata-repository.ts index af03340364..4ba788e499 100644 --- a/packages/metadata-protocol/src/sys-metadata-repository.ts +++ b/packages/metadata-protocol/src/sys-metadata-repository.ts @@ -386,6 +386,20 @@ export class SysMetadataRepository implements MetadataRepository { const body = (spec ?? {}) as Record; const hash = hashSpec(body); + // ADR-0048 — the ONE row this write targets. A write is not a search: it + // upserts exactly one `(org, type, name, package_id)` row, so its scope is + // always a concrete package or the unbound row — never `get`'s "any + // package" match. That asymmetry with the sibling read at {@link get} is + // deliberate, and it is why this value is named here rather than inlined: + // `put` reads it TWICE (the optimistic-lock lookup below and the + // `package_id` stamp), and #6215 was a caller — `restoreVersion` — whose + // silence about the binding was resolved to `null` by this expression, so + // the lock looked up a row that does not exist for every package-bound + // overlay. Callers state their scope; this line no longer decides it + // anywhere but for the documented `PutOptions.packageId` default + // (omitted/undefined = the env-local, unbound row). + const targetPackageId: string | null = opts.packageId ?? null; + // Run all reads + writes inside one transaction so the optimistic // lock, the parent-row mutation, and the history append are atomic. const result = await this.withTxn(async (ctx) => { @@ -393,7 +407,7 @@ export class SysMetadataRepository implements MetadataRepository { // save for package B does not find (and overwrite) package A's same-name // overlay. A package-less save (packageId null) targets the global row. const existing = await this.engine.findOne('sys_metadata', { - where: this.whereFor(ref, state, opts.packageId ?? null), + where: this.whereFor(ref, state, targetPackageId), context: ctx, }); const existingHash: string | null = existing?.checksum ?? null; @@ -436,9 +450,9 @@ export class SysMetadataRepository implements MetadataRepository { // selected never silently re-binds the row; only fill a null binding. if (existing) { const existingPkg = (existing as { package_id?: string | null }).package_id ?? null; - parentRowData.package_id = existingPkg ?? opts.packageId ?? null; + parentRowData.package_id = existingPkg ?? targetPackageId; } else { - parentRowData.package_id = opts.packageId ?? null; + parentRowData.package_id = targetPackageId; } if (existing) { const existingId = (existing as { id?: string }).id; @@ -723,6 +737,11 @@ export class SysMetadataRepository implements MetadataRepository { * with `operation_type='revert'` so the audit trail captures the * intent. Does NOT touch any draft row. * + * The restore stays on the row it found: the active row's ADR-0048 + * `package_id` is read here and threaded into {@link put}, so a row bound to + * a Studio package is UPDATED in place rather than missed by a lookup + * narrowed to `package_id IS NULL` (#6215). + * * Throws `[version_not_found]` (404) if the target version row is * missing or is a delete tombstone (no body to restore). */ @@ -759,7 +778,30 @@ export class SysMetadataRepository implements MetadataRepository { throw err; } const body = typeof raw === 'string' ? JSON.parse(raw) : (raw as Record); - const currentActive = await this.get(ref, { state: 'active' }); + // ADR-0048 / #6215 — read the RAW active row, not just its body, and carry + // its `package_id` into the write. `put` upserts exactly ONE row and scopes + // its optimistic-lock lookup by package; an unstated `packageId` resolves to + // the unbound row (`package_id IS NULL`). This restore used to state + // nothing while reading the parent hash package-agnostically, so for a row + // bound to a Studio package (`app.myapp`, …) the two disagreed by + // construction: the lock read `null`, the parent hash was the real one, and + // `put` threw ConflictError. Both user-facing callers — `rollbackMetaItem` + // and `revertCommit` — answered 409 "advanced during rollback" for every + // package-bound overlay while nothing had advanced. Its second face was the + // write: had the lock ever passed, `existing` was `null` and the restore + // INSERTED a duplicate unbound row instead of updating the bound one. + // + // One read supplies both facts, exactly as {@link promoteDraft} does, so + // the row the lock is taken on is by construction the row that is written. + // A missing active row (deleted, or never published) yields `null` — the + // unbound row, which is the only defined answer: `sys_metadata_history` + // carries no `package_id` column, so a vanished binding is not recoverable. + const activeRow = await this.engine.findOne('sys_metadata', { + where: this.whereFor(ref, 'active'), + }); + const activePackageId = + (activeRow as { package_id?: string | null } | null)?.package_id ?? null; + const currentActive = activeRow ? this.rowToItem(ref, activeRow) : null; return this.put(ref, body, { parentVersion: currentActive?.hash ?? null, actor: opts.actor, @@ -768,6 +810,7 @@ export class SysMetadataRepository implements MetadataRepository { intent: opts.intent ?? 'override-artifact', state: 'active', opType: 'revert', + packageId: activePackageId, }); } @@ -1051,9 +1094,12 @@ export class SysMetadataRepository implements MetadataRepository { // ADR-0048 — when the caller scopes by package, the overlay row is keyed by // `(org, type, name, package_id)` so two installed packages shipping the // same name each get their OWN customization row (a package-less / global - // overlay uses `package_id IS NULL`). When `packageId` is omitted (legacy - // callers — delete/promote/restore), the package dimension is left out so - // the query keeps its historical "match any package" behaviour. + // overlay uses `package_id IS NULL`). When `packageId` is omitted, the + // package dimension is left out so the query keeps its historical "match + // any package" behaviour — which is what the RESOLVING reads want + // (delete/promote/restore each locate the one row whatever it is bound to). + // The writes never rely on it: they resolve the binding from the row that + // read returned and state it (#6215). if (packageId !== undefined) where.package_id = packageId; // string → eq; null → IS NULL return where; } diff --git a/packages/objectql/src/protocol-commit-history.test.ts b/packages/objectql/src/protocol-commit-history.test.ts index 675014773d..334c6891dc 100644 --- a/packages/objectql/src/protocol-commit-history.test.ts +++ b/packages/objectql/src/protocol-commit-history.test.ts @@ -2,6 +2,12 @@ import { describe, it, expect, vi } from 'vitest'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { SchemaRegistry } from './registry.js'; +// [#4550 / #5480] The producer's OWN write-verb dispatch decisions, so the +// #6215 double below cannot accept a call `ObjectQL.delete` / `ObjectQL.update` +// refuses. +import { assertEngineDeleteDispatch } from './engine-delete-dispatch.js'; +import { assertEngineUpdateDispatch } from './engine-update-dispatch.js'; /** * ADR-0067 — package-scoped commit history & rollback. @@ -197,3 +203,231 @@ describe('ADR-0067 — publishPackageDrafts records a commit', () => { expect(items[0]).toMatchObject({ type: 'object', name: 'course', existedBefore: false }); }); }); + +/** + * #6215 — `revertCommit`'s RESTORE limb, on a package-bound overlay row. + * + * The suites above drive `revertCommit` against a stubbed overlay repo, which + * is the right instrument for the revert PLAN (created → soft-remove, edited → + * `restoreVersion`) and blind by construction to what the repository then does + * with the plan. #6215 lived exactly there: `restoreVersion` called `put` + * without a `packageId`, `put` scoped its optimistic-lock lookup with + * `whereFor(ref, state, opts.packageId ?? null)`, and `null` is the PREDICATE + * `package_id IS NULL` — so for a row bound to a Studio package the lock read a + * hash of `null`, compared it against the real parent hash, and threw. Every + * package-commit revert of an artifact authored in a package workspace came + * back `failedCount: 1` with a message blaming a concurrent edit. + * + * So this block runs the REAL `SysMetadataRepository` over a package-aware + * in-memory engine — the second of the two user-facing callers `restoreVersion` + * has (the first, `rollbackMetaItem`, is pinned in + * `protocol-writepath-object-ownership.test.ts`). Both share one line of + * repository code; pinning both is what catches the day they diverge. + */ + +/** A Studio authoring workspace id — writable under ADR-0070. */ +const APP_PKG = 'app.myapp'; + +const matchesWhere = (r: Record, w: Record): boolean => { + for (const [k, v] of Object.entries(w)) { + if (v === undefined) continue; + if (r[k] !== v) return false; + } + return true; +}; + +const rowKey = (w: Record) => + `${w.type}|${w.name}|${w.organization_id ?? '__env__'}|${w.state ?? 'active'}|${w.package_id ?? '__nopkg__'}`; + +/** + * Multi-table in-memory engine: `sys_metadata` (keyed by the ADR-0048 overlay + * key INCLUDING `package_id`, without which this file could not see the defect + * at all), `sys_metadata_history`, `sys_metadata_commit`. Both write verbs are + * pinned to ObjectQL's own dispatch predicates. + */ +function makeRealRepoHarness(seedCommits: any[] = []) { + const registry = new SchemaRegistry({ multiTenant: false }); + (registry as any).logLevel = 'silent'; + const rows = new Map(); + const historyRows: any[] = []; + const commits: any[] = [...seedCommits]; + let nextId = 0; + + const findRow = (w: Record) => { + for (const [k, r] of rows) if (matchesWhere(r, w)) return { key: k, row: r }; + return null; + }; + + const engine: any = { + registry, + async findOne(table: string, opts: { where: Record }) { + if (table === 'sys_metadata_commit') return commits.find((c) => matchesWhere(c, opts.where)) ?? null; + if (table === 'sys_metadata_history') return historyRows.find((h) => matchesWhere(h, opts.where)) ?? null; + if (table !== 'sys_metadata') return null; + return findRow(opts.where)?.row ?? null; + }, + async find(table: string, opts: { where: Record }) { + if (table === 'sys_metadata_commit') return commits.filter((c) => matchesWhere(c, opts.where)); + if (table === 'sys_metadata_history') return historyRows.filter((h) => matchesWhere(h, opts.where)); + if (table !== 'sys_metadata') return []; + return Array.from(rows.values()).filter((r) => matchesWhere(r, opts.where)); + }, + async insert(table: string, data: Record) { + if (table === 'sys_metadata_commit') { commits.push(data); return { id: (data as any).id }; } + if (table === 'sys_metadata_history') { + const h = { id: `h_${++nextId}`, ...(data as any) }; + historyRows.push(h); + return { id: h.id }; + } + if (table !== 'sys_metadata') return { id: 'side_table' }; + const row = { id: `r_${++nextId}`, ...(data as any) }; + rows.set(rowKey(data), row); + return { id: row.id }; + }, + async update(table: string, data: Record, opts: { where: Record }) { + assertEngineUpdateDispatch(data, opts); + if (table !== 'sys_metadata') return { id: null }; + const found = findRow(opts.where); + if (!found) return { id: null }; + const merged = { ...found.row, ...(data as any) }; + rows.delete(found.key); + rows.set(rowKey(merged), merged); + return { id: merged.id }; + }, + async delete(table: string, opts?: Record) { + assertEngineDeleteDispatch(opts); + if (table !== 'sys_metadata') return { deleted: 0 }; + const found = findRow(((opts as any)?.where ?? {}) as Record); + if (!found) return { deleted: 0 }; + rows.delete(found.key); + return { deleted: 1 }; + }, + async syncObjectSchema() { /* no physical storage in this double */ }, + }; + + const protocol = new ObjectStackProtocolImplementation(engine, undefined, 'env_test'); + return { protocol, engine, rows, historyRows, commits, registry }; +} + +/** + * The reverted artifact is a `view`, not an `object`, and deliberately: the + * repository's `assertAllowed` refuses `override-artifact` on `object` (not + * `allowOrgOverride`), and `revertCommit` — unlike `rollbackMetaItem`, which + * derives `runtime-only` for a runtime-created artifact — passes no intent, so + * an object item fails that gate BEFORE reaching the scoping this file pins. + * That is a separate defect of the same family, filed as #6563; using an + * overlay-allowed type keeps this pin measuring one thing. The repository line + * under test is type-agnostic. + */ +const gridBody = (label: string) => ({ + name: 'myapp_case_grid', type: 'grid', label, columns: ['id', 'title'], +}); + +/** v1 authored in the package workspace, then the edit the commit recorded. */ +async function seedPackageBoundEdit(protocol: any) { + await protocol.saveMetaItem({ + type: 'view', name: 'myapp_case_grid', packageId: APP_PKG, item: gridBody('Cases'), + }); + await protocol.saveMetaItem({ + type: 'view', name: 'myapp_case_grid', packageId: APP_PKG, item: gridBody('Renamed'), + }); +} + +const editedCommit = () => applyCommit({ + id: 'cmt_pkg', + package_id: APP_PKG, + items: [{ type: 'view', name: 'myapp_case_grid', existedBefore: true, prevVersion: 1 }], + created_at: '2026-08-08T00:00:00.000Z', +}); + +describe('#6215 — revertCommit restores a PACKAGE-BOUND overlay row', () => { + it('restores the pre-commit body IN PLACE: one row, still bound to its package', async () => { + const { protocol, rows } = makeRealRepoHarness([editedCommit()]); + await seedPackageBoundEdit(protocol); + + const res = await protocol.revertCommit({ commitId: 'cmt_pkg' }); + + // Pre-fix this was `failedCount: 1` carrying "advanced during rollback". + expect(res.failed).toEqual([]); + expect(res.revertedCount).toBe(1); + expect(res.reverted[0]).toMatchObject({ type: 'view', name: 'myapp_case_grid', action: 'restored' }); + + // IN PLACE — the write targeted the bound row rather than inserting an + // unbound duplicate beside it (the defect's second face; `sys_metadata`'s + // partial unique index keys on `COALESCE(package_id,'')`, so a real DB + // would have accepted that duplicate too). + const stored = Array.from(rows.values()).filter((r) => r.name === 'myapp_case_grid'); + expect(stored).toHaveLength(1); + expect(stored[0].package_id).toBe(APP_PKG); + expect(JSON.parse(stored[0].metadata).label).toBe('Cases'); + // The revert is itself an append-only commit (ADR-0067), as before. + expect((res as any).revertCommitId).toBeTruthy(); + }); + + it('a package-LESS row still reverts — the legacy shape is not regressed', async () => { + const { protocol, rows } = makeRealRepoHarness([applyCommit({ + id: 'cmt_global', + package_id: null, + items: [{ type: 'view', name: 'myapp_case_grid', existedBefore: true, prevVersion: 1 }], + created_at: '2026-08-08T00:00:00.000Z', + })]); + await protocol.saveMetaItem({ type: 'view', name: 'myapp_case_grid', item: gridBody('Cases') }); + await protocol.saveMetaItem({ type: 'view', name: 'myapp_case_grid', item: gridBody('Renamed') }); + + const res = await protocol.revertCommit({ commitId: 'cmt_global' }); + + expect(res.failed).toEqual([]); + expect(res.revertedCount).toBe(1); + const stored = Array.from(rows.values()).filter((r) => r.name === 'myapp_case_grid'); + expect(stored).toHaveLength(1); + expect(stored[0].package_id).toBeNull(); + expect(JSON.parse(stored[0].metadata).label).toBe('Cases'); + }); + + /** + * The refusal that must SURVIVE the fix: a row that REALLY advanced between + * the revert's parent read and its write is still refused. Staging it needs a + * real interleaving now that both facts come from one read, so the engine's + * `findOne` hands the restore a snapshot while a concurrent publish lands on + * the stored row. + * + * Envelope note (ADR-0112): `revertCommit` converts a per-item throw into a + * `failed[]` record, whose declared shape carries `error` + `code` and no + * `status` — so `code` is asserted here and the full `{ code, status }` pair + * is asserted at the throwing surface, `rollbackMetaItem`, in + * `protocol-writepath-object-ownership.test.ts`. + */ + it('still refuses a GENUINELY advanced row: METADATA_CONFLICT, nothing written', async () => { + const { protocol, engine, rows } = makeRealRepoHarness([editedCommit()]); + await seedPackageBoundEdit(protocol); + + const realFindOne = engine.findOne.bind(engine); + // The concurrent publish lands between `restoreVersion`'s parent read and + // `put`'s optimistic-lock read. That second read is identified by the one + // property only it has — it runs INSIDE the write transaction, so the + // engine call carries a `context` — rather than by counting reads, which + // would silently stop covering anything the day a read is added. + let fired = false; + engine.findOne = async (table: string, opts: Record) => { + const row = await realFindOne(table, opts); + if (!fired && table === 'sys_metadata' && row && 'context' in opts && opts.where?.state === 'active') { + fired = true; + row.checksum = `sha256:${'a'.repeat(64)}`; // someone else published + } + return row; + }; + + const res = await protocol.revertCommit({ commitId: 'cmt_pkg' }); + + expect(fired).toBe(true); + expect(res.revertedCount).toBe(0); + expect(res.failedCount).toBe(1); + expect(res.failed[0]).toMatchObject({ type: 'view', name: 'myapp_case_grid', code: 'METADATA_CONFLICT' }); + // Refused means refused: the concurrent writer's body stands, and no + // unbound duplicate was left behind. + const stored = Array.from(rows.values()).filter((r) => r.name === 'myapp_case_grid'); + expect(stored).toHaveLength(1); + expect(stored[0].package_id).toBe(APP_PKG); + expect(JSON.parse(stored[0].metadata).label).toBe('Renamed'); + }); +}); diff --git a/packages/objectql/src/protocol-writepath-object-ownership.test.ts b/packages/objectql/src/protocol-writepath-object-ownership.test.ts index 664a35146c..39520c75c9 100644 --- a/packages/objectql/src/protocol-writepath-object-ownership.test.ts +++ b/packages/objectql/src/protocol-writepath-object-ownership.test.ts @@ -147,7 +147,11 @@ function makeHarness() { // reported on, and the only one where `saveMetaItem`'s overlay gate is // engaged at all. const protocol = new ObjectStackProtocolImplementation(engine, undefined, 'env_test'); - return { registry, protocol, rows, historyRows, synced }; + // `engine` is returned so a test can wrap a verb and stage a REAL concurrent + // write between two of the protocol's reads (see the #6215 conflict pin) — + // the only way the optimistic lock can still refuse a rollback now that the + // parent hash and the write scope come from the same row. + return { registry, protocol, rows, historyRows, synced, engine }; } function objectBody(name: string, extra?: Record) { @@ -346,24 +350,81 @@ describe('#4636 — rollback re-registers under the row\'s own package binding', }); /** - * TRIPWIRE — this pins a DEFECT, not a desired behaviour. + * #6215 — the flipped tripwire. * - * The package-bound half of the ownership key resolved above is - * unreachable through `rollbackMetaItem` today, and not because of - * anything in this PR: `SysMetadataRepository.restoreVersion` calls `put` - * with no `packageId`, `put` scopes its existing-row lookup with + * This test used to assert the DEFECT (`[tripwire] a package-bound + * rollback still 409s`): `SysMetadataRepository.restoreVersion` called + * `put` with no `packageId`, `put` scoped its existing-row lookup with * `whereFor(ref, state, opts.packageId ?? null)`, and `null` is a - * PREDICATE (`package_id IS NULL`) rather than "any package". So the - * lookup misses the package-bound row it is restoring, reads its parent - * hash as null, and every rollback of a package-bound row answers 409 - * before any registry write-through runs. Filed as #6215. + * PREDICATE (`package_id IS NULL`) rather than "any package" — so the + * lookup missed the package-bound row it was restoring, read its parent + * hash as null, and every rollback of a package-bound row answered 409 + * before any registry write-through ran. * - * When that is fixed this test FAILS, which is the point: whoever fixes it - * must then assert what the write-through does with the key, and the - * assertion is already written one test up. + * `restoreVersion` now reads the raw active row and threads its + * `package_id` into `put`, so this is the assertion #4636 PR1 staged one + * test up, on the half that was unreachable then: the package-bound key, + * carried through a real rollback. */ - it('[tripwire] a package-bound rollback still 409s — a pre-existing repository-scoping defect', async () => { - const { protocol } = makeHarness(); + it('resolves the ownership key from the ROW: a package-bound rollback keeps its package id', async () => { + const { registry, protocol, rows } = makeHarness(); + + await protocol.saveMetaItem({ + type: 'object', + name: 'myapp_invoice', + packageId: APP_PKG, + item: objectBody('myapp_invoice'), + }); + const evolved = objectBody('myapp_invoice'); + (evolved.fields as any).due_date = { name: 'due_date', type: 'date', label: 'Due' }; + await protocol.saveMetaItem({ + type: 'object', + name: 'myapp_invoice', + packageId: APP_PKG, + item: evolved, + }); + + const back = await protocol.rollbackMetaItem({ + type: 'object', + name: 'myapp_invoice', + toVersion: 1, + }); + + expect(back.success).toBe(true); + // The write-through half: the restored body is re-registered under the + // row's OWN package id, never the sentinel, so the package filter that + // `saveMetaItem` populates keeps matching after a rollback… + expect(owner(registry, 'myapp_invoice')?.packageId).toBe(APP_PKG); + expect(registry.getAllObjects(APP_PKG).map((o: any) => o.name)).toEqual(['myapp_invoice']); + // …and the registry serves the RESTORED body, not the one being reverted. + expect(Object.keys((registry.getObject('myapp_invoice') as any).fields)).not.toContain('due_date'); + // Same stamp as every other org write — a rollback is not a package install. + expect((owner(registry, 'myapp_invoice')?.definition as any)?._provenance).toBe('org'); + // The defect's SECOND face: the lookup that missed the bound row would, + // had the parent check passed, have INSERTED an unbound duplicate + // instead of updating it. One row, still bound, carrying v1's body. + // (`sys_metadata`'s partial unique index keys on + // `COALESCE(package_id,'')`, so the duplicate would survive a real DB.) + const stored = Array.from(rows.values()).filter((r) => r.name === 'myapp_invoice'); + expect(stored).toHaveLength(1); + expect(stored[0].package_id).toBe(APP_PKG); + expect(Object.keys(JSON.parse(stored[0].metadata).fields)).not.toContain('due_date'); + }); + + /** + * The refusal that must SURVIVE the fix. `rollbackMetaItem`'s 409 is not + * retired — it is narrowed to the case it always claimed to report: the row + * really did advance between the rollback's parent read and its write. + * + * Staging that needs a real interleaving, because the parent hash and the + * write scope now come from the SAME read: the engine's `findOne` is + * wrapped to hand the rollback a snapshot of the active row and, in the + * same breath, let a concurrent publish land on the stored row. If the trap + * never fires the rollback succeeds and this test goes red — it cannot rot + * into a green that asserts nothing. + */ + it('still refuses (METADATA_CONFLICT / 409) when the package-bound row REALLY advanced', async () => { + const { protocol, engine, rows } = makeHarness(); await protocol.saveMetaItem({ type: 'object', @@ -380,10 +441,34 @@ describe('#4636 — rollback re-registers under the row\'s own package binding', item: evolved, }); + const realFindOne = engine.findOne.bind(engine); + // The concurrent publish lands between `restoreVersion`'s parent read + // and `put`'s optimistic-lock read. That second read is identified by + // the one property only it has — it runs INSIDE the write transaction, + // so the engine call carries a `context` — rather than by counting + // reads, which would silently stop covering anything the day a read is + // added to the rollback path. + let fired = false; + engine.findOne = async (table: string, opts: Record) => { + const row = await realFindOne(table, opts); + if (!fired && table === 'sys_metadata' && row && 'context' in opts && opts.where?.state === 'active') { + fired = true; + (row as any).checksum = `sha256:${'a'.repeat(64)}`; // someone else published + } + return row; + }; + await expect(protocol.rollbackMetaItem({ type: 'object', name: 'myapp_invoice', toVersion: 1, - })).rejects.toThrow(/metadata_conflict/); + })).rejects.toMatchObject({ code: 'METADATA_CONFLICT', status: 409 }); + expect(fired).toBe(true); + // The refusal is total: the stored row still carries the body the + // concurrent writer left, and no unbound duplicate was created. + const stored = Array.from(rows.values()).filter((r) => r.name === 'myapp_invoice'); + expect(stored).toHaveLength(1); + expect(stored[0].package_id).toBe(APP_PKG); + expect(Object.keys(JSON.parse(stored[0].metadata).fields)).toContain('due_date'); }); });