Skip to content

Commit 2873eb9

Browse files
baozhoutaoclaude
andauthored
fix(metadata-protocol): restoreVersion carries the row's package binding — a package-bound rollback stops 409ing (#6215) (#6574)
* fix(metadata-protocol): restoreVersion carries the row's package binding (#6215) restoreVersion read the active row package-agnostically and then re-put the historical body without stating a packageId. `put` scopes its optimistic-lock lookup by package and an unstated packageId resolves to the UNBOUND row (`package_id IS NULL`), so for a package-bound overlay the lock looked up a row that does not exist, read a null parent hash, and threw ConflictError — every rollback of a row authored in a Studio package workspace answered 409 "advanced during rollback" while nothing had advanced. Both user-facing callers (rollbackMetaItem, revertCommit) go through this one call. restoreVersion now takes both the parent hash and the ADR-0048 package_id from one raw read of the active row and states the binding on the write, as promoteDraft already did — closing the second face too (an insert of a duplicate unbound row instead of an update of the bound one). Tests: the #4636 PR1 tripwire is flipped to the staged assertion (a package-bound rollback succeeds AND re-registers under the row's own package key), revertCommit gains a real-repository pin on a package-bound row, and both callers keep a genuine-conflict refusal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KDU3qAuJyajAQm3GkUXdfA * test(objectql): pin both restoreVersion callers on a package-bound row (#6215) - protocol-writepath-object-ownership: the #4636 PR1 tripwire is replaced by the assertion staged one test above it — a package-bound rollback succeeds, the registry write-through carries the row's OWN package id, the restored body is what the registry serves, and exactly one row (still bound) survives, which is the defect's duplicate-unbound-row second face. - protocol-commit-history: revertCommit's restore limb driven against the REAL SysMetadataRepository over a package-aware engine double, plus the package-less control. - Both callers keep a genuine-conflict refusal: a row that really advanced between the parent read and the optimistic-lock read is still refused (METADATA_CONFLICT / 409 at the throwing surface, code in revertCommit's failed[] record). The race is staged on the in-transaction read rather than by counting reads, so the pin cannot rot into a green that asserts nothing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KDU3qAuJyajAQm3GkUXdfA --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7c6261a commit 2873eb9

4 files changed

Lines changed: 433 additions & 23 deletions

File tree

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
---
4+
5+
fix(metadata-protocol): rolling back a package-bound overlay row no longer 409s (#6215)
6+
7+
Every rollback of a metadata item authored inside a Studio package workspace
8+
failed — and failed by blaming a concurrent edit that never happened:
9+
10+
```
11+
[metadata_conflict] object/myapp_invoice advanced during rollback.
12+
Expected parent sha256:00ca6e72c... but current is null.
13+
```
14+
15+
Both user-facing paths were affected, because both are one call:
16+
`rollbackMetaItem` (the per-item version-history revert) and `revertCommit`
17+
(the package-commit revert) go through `SysMetadataRepository.restoreVersion`.
18+
Only rows with **no** package binding — the legacy shape — rolled back at all,
19+
while ADR-0070 pushes authoring toward always resolving a writable base
20+
package, so the failing share was growing.
21+
22+
**Cause.** `restoreVersion` read the current active row package-agnostically
23+
and then re-put the historical body without saying which row it meant. `put`
24+
scopes its optimistic-lock lookup by package, and an unstated `packageId`
25+
resolves to the *unbound* row (`package_id IS NULL`) rather than "any package"
26+
— so for a row bound to `app.<slug>` the lock looked up a row that does not
27+
exist, read its parent hash as `null`, compared that against the real hash the
28+
first read had just returned, and threw `ConflictError`. The mismatch was
29+
between two reads of the *same* restore, not between two writers.
30+
31+
**Fix.** `restoreVersion` now reads the raw active row once and takes BOTH
32+
facts from it — the parent hash and the ADR-0048 `package_id` — then states
33+
that binding on the write, the same way `promoteDraft` already did. The row the
34+
lock is taken on is therefore, by construction, the row that gets written.
35+
36+
This also closes the defect's second face: had the parent check ever passed,
37+
`put` would have found no row in its `IS NULL` scope and **inserted a duplicate
38+
unbound row** beside the bound one instead of updating it. `sys_metadata`'s
39+
partial unique index keys on `COALESCE(package_id,'')`, so a real database
40+
would have accepted that duplicate.
41+
42+
Unchanged: package-less rows still roll back exactly as before, and a row that
43+
*genuinely* advanced between the rollback's read and its write is still refused
44+
with `METADATA_CONFLICT` / 409. The refusal is narrowed to the case it always
45+
claimed to report, not retired.

packages/metadata-protocol/src/sys-metadata-repository.ts

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -386,14 +386,28 @@ export class SysMetadataRepository implements MetadataRepository {
386386
const body = (spec ?? {}) as Record<string, unknown>;
387387
const hash = hashSpec(body);
388388

389+
// ADR-0048 — the ONE row this write targets. A write is not a search: it
390+
// upserts exactly one `(org, type, name, package_id)` row, so its scope is
391+
// always a concrete package or the unbound row — never `get`'s "any
392+
// package" match. That asymmetry with the sibling read at {@link get} is
393+
// deliberate, and it is why this value is named here rather than inlined:
394+
// `put` reads it TWICE (the optimistic-lock lookup below and the
395+
// `package_id` stamp), and #6215 was a caller — `restoreVersion` — whose
396+
// silence about the binding was resolved to `null` by this expression, so
397+
// the lock looked up a row that does not exist for every package-bound
398+
// overlay. Callers state their scope; this line no longer decides it
399+
// anywhere but for the documented `PutOptions.packageId` default
400+
// (omitted/undefined = the env-local, unbound row).
401+
const targetPackageId: string | null = opts.packageId ?? null;
402+
389403
// Run all reads + writes inside one transaction so the optimistic
390404
// lock, the parent-row mutation, and the history append are atomic.
391405
const result = await this.withTxn(async (ctx) => {
392406
// ADR-0048 — scope the existing-row lookup to the requested package so a
393407
// save for package B does not find (and overwrite) package A's same-name
394408
// overlay. A package-less save (packageId null) targets the global row.
395409
const existing = await this.engine.findOne('sys_metadata', {
396-
where: this.whereFor(ref, state, opts.packageId ?? null),
410+
where: this.whereFor(ref, state, targetPackageId),
397411
context: ctx,
398412
});
399413
const existingHash: string | null = existing?.checksum ?? null;
@@ -436,9 +450,9 @@ export class SysMetadataRepository implements MetadataRepository {
436450
// selected never silently re-binds the row; only fill a null binding.
437451
if (existing) {
438452
const existingPkg = (existing as { package_id?: string | null }).package_id ?? null;
439-
parentRowData.package_id = existingPkg ?? opts.packageId ?? null;
453+
parentRowData.package_id = existingPkg ?? targetPackageId;
440454
} else {
441-
parentRowData.package_id = opts.packageId ?? null;
455+
parentRowData.package_id = targetPackageId;
442456
}
443457
if (existing) {
444458
const existingId = (existing as { id?: string }).id;
@@ -723,6 +737,11 @@ export class SysMetadataRepository implements MetadataRepository {
723737
* with `operation_type='revert'` so the audit trail captures the
724738
* intent. Does NOT touch any draft row.
725739
*
740+
* The restore stays on the row it found: the active row's ADR-0048
741+
* `package_id` is read here and threaded into {@link put}, so a row bound to
742+
* a Studio package is UPDATED in place rather than missed by a lookup
743+
* narrowed to `package_id IS NULL` (#6215).
744+
*
726745
* Throws `[version_not_found]` (404) if the target version row is
727746
* missing or is a delete tombstone (no body to restore).
728747
*/
@@ -759,7 +778,30 @@ export class SysMetadataRepository implements MetadataRepository {
759778
throw err;
760779
}
761780
const body = typeof raw === 'string' ? JSON.parse(raw) : (raw as Record<string, unknown>);
762-
const currentActive = await this.get(ref, { state: 'active' });
781+
// ADR-0048 / #6215 — read the RAW active row, not just its body, and carry
782+
// its `package_id` into the write. `put` upserts exactly ONE row and scopes
783+
// its optimistic-lock lookup by package; an unstated `packageId` resolves to
784+
// the unbound row (`package_id IS NULL`). This restore used to state
785+
// nothing while reading the parent hash package-agnostically, so for a row
786+
// bound to a Studio package (`app.myapp`, …) the two disagreed by
787+
// construction: the lock read `null`, the parent hash was the real one, and
788+
// `put` threw ConflictError. Both user-facing callers — `rollbackMetaItem`
789+
// and `revertCommit` — answered 409 "advanced during rollback" for every
790+
// package-bound overlay while nothing had advanced. Its second face was the
791+
// write: had the lock ever passed, `existing` was `null` and the restore
792+
// INSERTED a duplicate unbound row instead of updating the bound one.
793+
//
794+
// One read supplies both facts, exactly as {@link promoteDraft} does, so
795+
// the row the lock is taken on is by construction the row that is written.
796+
// A missing active row (deleted, or never published) yields `null` — the
797+
// unbound row, which is the only defined answer: `sys_metadata_history`
798+
// carries no `package_id` column, so a vanished binding is not recoverable.
799+
const activeRow = await this.engine.findOne('sys_metadata', {
800+
where: this.whereFor(ref, 'active'),
801+
});
802+
const activePackageId =
803+
(activeRow as { package_id?: string | null } | null)?.package_id ?? null;
804+
const currentActive = activeRow ? this.rowToItem(ref, activeRow) : null;
763805
return this.put(ref, body, {
764806
parentVersion: currentActive?.hash ?? null,
765807
actor: opts.actor,
@@ -768,6 +810,7 @@ export class SysMetadataRepository implements MetadataRepository {
768810
intent: opts.intent ?? 'override-artifact',
769811
state: 'active',
770812
opType: 'revert',
813+
packageId: activePackageId,
771814
});
772815
}
773816

@@ -1051,9 +1094,12 @@ export class SysMetadataRepository implements MetadataRepository {
10511094
// ADR-0048 — when the caller scopes by package, the overlay row is keyed by
10521095
// `(org, type, name, package_id)` so two installed packages shipping the
10531096
// same name each get their OWN customization row (a package-less / global
1054-
// overlay uses `package_id IS NULL`). When `packageId` is omitted (legacy
1055-
// callers — delete/promote/restore), the package dimension is left out so
1056-
// the query keeps its historical "match any package" behaviour.
1097+
// overlay uses `package_id IS NULL`). When `packageId` is omitted, the
1098+
// package dimension is left out so the query keeps its historical "match
1099+
// any package" behaviour — which is what the RESOLVING reads want
1100+
// (delete/promote/restore each locate the one row whatever it is bound to).
1101+
// The writes never rely on it: they resolve the binding from the row that
1102+
// read returned and state it (#6215).
10571103
if (packageId !== undefined) where.package_id = packageId; // string → eq; null → IS NULL
10581104
return where;
10591105
}

0 commit comments

Comments
 (0)