diff --git a/.changeset/record-package-commit-durability-9066.md b/.changeset/record-package-commit-durability-9066.md new file mode 100644 index 0000000000..c27ce9c15d --- /dev/null +++ b/.changeset/record-package-commit-durability-9066.md @@ -0,0 +1,40 @@ +--- +"@objectstack/metadata-protocol": patch +--- + +fix(metadata-protocol): a failed `sys_metadata_commit` write is reported instead of swallowed — the turn that cannot be reverted is now visible to an operator (#9066) + +`recordPackageCommit` — the ADR-0067 commit writer `publishPackageDrafts` calls +with the revert plan it just captured — sat behind a bare `catch` that answered +`null` for every reason, with nothing logged. The comment's premise was true +(the publish already succeeded and cannot be unwound) but its conclusion — +"grouping is a best-effort overlay" — understated the row: `sys_metadata_commit` +is the ONLY record of a turn's revert plan (`existedBefore` / `prevVersion` per +artifact), the thing `revertCommit` and `rollbackToPackageCommit` act on. When +the insert failed, the artifacts went live, the response read `success: true` +with `commitId` merely absent, the turn could never be reverted, and no line +anywhere said so — so a commit store that was failing kept failing, losing every +later publish's plan the same silent way. + +The failure is now discriminated by error TYPE, through the shared +`isMissingTableError` predicate the read seams in this file already ask: + +- an **unprovisioned** `sys_metadata_commit` (a first boot, or an environment + kernel composed without the commit log) is a configuration fact, identical on + every publish and fixed in one place — reported at `info`, once per protocol + instance, naming the consequence and how to provision the store; +- **every other** failure (connection drop, timeout, permission denial, schema + drift on that table) is a durability degradation and is reported at `error`, + once per turn, naming the package, the operation, the item count, the driver's + own reason, that the publish itself succeeded and still reports success, and + the fix. + +Publish semantics are unchanged: the `catch` still returns `null`, the publish +still succeeds, and no response field was added — whether the caller should be +told the turn is unrevertible is a separate, undecided question. + +The gate that stops this from regressing is extended in the same change: the +insert now goes through a named `persistPackageCommitRow`, declared in +`DURABILITY_CRITICAL_CALLEES` in +`scripts/check-durability-degradation-log-level.mjs`, so a future edit that +quiets this `catch` fails CI instead of shipping. diff --git a/packages/metadata-protocol/src/protocol.record-package-commit-durability.test.ts b/packages/metadata-protocol/src/protocol.record-package-commit-durability.test.ts new file mode 100644 index 0000000000..c1fc275bd8 --- /dev/null +++ b/packages/metadata-protocol/src/protocol.record-package-commit-durability.test.ts @@ -0,0 +1,396 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#9066] A `sys_metadata_commit` write that FAILS must not be silent. + * + * `recordPackageCommit` is the ADR-0067 commit writer `publishPackageDrafts` + * calls with the revert plan it captured a few lines earlier. Its `catch` used + * to be bare — `return null`, for every reason, with nothing logged: + * + * } catch { + * // Commit store unavailable (or insert raced) — the publish itself + * // already succeeded; grouping is a best-effort overlay on top. + * return null; + * } + * + * The comment's premise was true and its conclusion was not. The row is not a + * grouping label: it is the ONLY record of the turn's revert plan + * (`existedBefore` / `prevVersion` per artifact) that `revertCommit` and + * `rollbackToPackageCommit` can act on. When it does not land, the artifacts + * are live, the response says `success: true` with `commitId` merely ABSENT, + * and the turn can never be reverted — the AGENTS.md durability-degradation + * shape exactly: the system keeps looking normal while something it claims to + * persist did not land. And a commit store that is failing stays failing, so + * every later publish lost its plan the same silent way. + * + * ## What this file pins, and what it deliberately does NOT + * + * ONLY the silence changes. The publish must still succeed and the `catch` + * must still answer `null` — unwinding live artifacts over a missing history + * row would be strictly worse than losing the row, and telling the CALLER that + * the turn is unrevertible is a response-field question the #8896 ruling + * forbids for this family. Both halves are asserted below, not assumed: every + * failure case checks `success`, `publishedCount`, the active row, AND the + * absence of `commitId`. + * + * Classification is by error TYPE through the shared `isMissingTableError` + * predicate (`@objectstack/metadata/errors`), the same vocabulary the read + * seams in this file ask (#5532 / #5980 / #8896): + * + * - unprovisioned commit store → `info`, ONCE per protocol instance (a + * configuration fact, identical on every publish, fixed in one place); + * - everything else → `error`, per turn, naming the consequence and the fix. + * + * Every expectation is written against LITERALS — the injected error object + * itself, its literal message, the literal sentences an operator reads — and + * each failure case is paired with a positive control, so "no error was + * logged" can never pass on a harness that never published at all. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +// [#5619] The producer's OWN write-verb dispatch decisions, so the fake engine +// cannot accept a call ObjectQL refuses. From `@objectstack/metadata-core`, not +// `@objectstack/objectql` — objectql depends on THIS package, so that import +// would close a dependency cycle turbo rejects outright. +import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; +import { ObjectStackProtocolImplementation } from './protocol.js'; + +interface Row { + id: string; + type: string; + name: string; + organization_id: string | null; + package_id: string | null; + state: string; + metadata: string; + version?: number; +} + +/** ADR-0048 overlay key — `(type, name, org, state, package)`. */ +const keyOf = (w: Record) => + `${w.type}|${w.name}|${w.organization_id ?? '__env__'}|${w.state ?? 'active'}|${w.package_id ?? '__nopkg__'}`; + +/** + * The overlay reads this fixture serves use flat equality plus `$or`, so those + * are the two shapes implemented — every OTHER combinator is refused loudly + * rather than read as a field name (#8494). A double that silently answers a + * combinator it does not implement is wrong in the direction no assertion can + * see: `row['$and']` is `undefined`, so the clause "does not match" for a + * reason that has nothing to do with the data. + */ +function matchesWhere(row: Record, where: Record): boolean { + for (const [k, v] of Object.entries(where)) { + if (k === '$or') { + if (!(v as Array>).some((c) => matchesWhere(row, c))) return false; + continue; + } + if (k.startsWith('$')) { + throw new Error(`fake engine: unsupported combinator ${k}`); + } + // `undefined` = "dimension not constrained"; `null` = "must be NULL". + if (v === undefined) continue; + if (row[k] !== v) return false; + } + return true; +} + +/** + * Stub engine with an injector on the `sys_metadata_commit` INSERT only. + * + * The injection is STICKY, not one-shot, because the defect's second half is + * that a failing commit store stays failing: two publishes in a row have to be + * observable to tell "said once" apart from "said per turn". Every other table + * — `sys_metadata` above all — keeps working, so a publish that still reports + * success is attributable to this seam and not to a generally broken engine. + */ +function makeStubEngine() { + const rows = new Map(); + const sideTables: Record>> = {}; + let nextId = 0; + let commitFailure: unknown = null; + let commitAttempts = 0; + + const findRow = (w: Record): { key: string; row: Row } | null => { + if (w.id !== undefined) { + for (const [k, r] of rows) if (r.id === w.id) return { key: k, row: r }; + return null; + } + if (w.package_id !== undefined) { + const k = keyOf(w); + const r = rows.get(k); + return r ? { key: k, row: r } : null; + } + for (const [k, r] of rows) if (matchesWhere(r as unknown as Record, w)) return { key: k, row: r }; + return null; + }; + + const engine = { + async findOne(table: string, opts: { where: Record }) { + if (table !== 'sys_metadata') { + return (sideTables[table] ?? []).find((r) => matchesWhere(r, opts.where)) ?? null; + } + return findRow(opts.where)?.row ?? null; + }, + async find(table: string, opts?: { where?: Record }) { + const where = opts?.where ?? {}; + if (table !== 'sys_metadata') { + return (sideTables[table] ?? []).filter((r) => matchesWhere(r, where)); + } + return Array.from(rows.values()) + .filter((r) => matchesWhere(r as unknown as Record, where)); + }, + async insert(table: string, data: Record) { + if (table === 'sys_metadata_commit') { + commitAttempts += 1; + if (commitFailure !== null) throw commitFailure; + } + nextId += 1; + if (table !== 'sys_metadata') { + (sideTables[table] ??= []).push({ id: `x_${nextId}`, ...data }); + return { id: `x_${nextId}` }; + } + const row = { id: `r_${nextId}`, ...data } as unknown as Row; + rows.set(keyOf(data), row); + return { id: row.id }; + }, + async update(_t: string, data: Record, opts: { where: Record }) { + assertEngineUpdateDispatch(data, opts); + const found = findRow(opts.where); + if (!found) return { id: null }; + const merged = { ...found.row, ...data } as unknown as Row; + rows.delete(found.key); + rows.set(keyOf(merged as unknown as Record), merged); + return { id: found.row.id }; + }, + async delete(_t: string, opts: { where: Record }) { + assertEngineDeleteDispatch(opts); + const found = findRow(opts.where); + if (!found) return { deleted: 0 }; + rows.delete(found.key); + return { deleted: 1 }; + }, + async transaction(cb: (ctx: undefined, info: { owned: boolean }) => Promise): Promise { + return cb(undefined, { owned: true }); + }, + registry: { + registerItem: () => {}, + registerObject: () => {}, + // No declared package namespace → the ADR-0028 prefix pre-flight is + // grandfathered, exactly as for a legacy package. + getPackage: () => undefined, + }, + }; + + return { + engine, + rows, + sideTables, + failCommitWith: (error: unknown) => { commitFailure = error; }, + commitAttempts: () => commitAttempts, + }; +} + +/** [#8308] Authored OWD — the publish gate refuses an OWD-less custom object. */ +const objectBody = (name: string, label: string) => ({ + name, + label, + sharingModel: 'private', + fields: { title: { type: 'text', label: 'Title' } }, +}); + +/** The real driver phrasings, verbatim. */ +const connectionDropped = () => + Object.assign(new Error('connection terminated unexpectedly'), { code: 'ECONNRESET' }); +const unprovisionedSqlite = () => + Object.assign(new Error('SQLITE_ERROR: no such table: sys_metadata_commit'), { code: 'SQLITE_ERROR' }); +const unprovisionedPostgres = () => + Object.assign(new Error('relation "sys_metadata_commit" does not exist'), { code: '42P01' }); + +type Protocol = InstanceType; + +/** Draft one object into `app.demo` and publish the package. */ +async function publishOne(protocol: Protocol, name: string, label: string) { + await (protocol as never as { + saveMetaItem: (r: unknown) => Promise; + }).saveMetaItem({ + type: 'object', name, item: objectBody(name, label), + packageId: 'app.demo', mode: 'draft', + }); + return protocol.publishPackageDrafts({ packageId: 'app.demo' }) as Promise<{ + success: boolean; + publishedCount: number; + commitId?: string; + }>; +} + +const commitRows = (sideTables: Record>>) => + sideTables['sys_metadata_commit'] ?? []; + +const activeNames = (rows: Map) => + Array.from(rows.values()).filter((r) => r.state === 'active').map((r) => r.name).sort(); + +function spyConsole() { + return { + error: vi.spyOn(console, 'error').mockImplementation(() => {}), + info: vi.spyOn(console, 'info').mockImplementation(() => {}), + warn: vi.spyOn(console, 'warn').mockImplementation(() => {}), + }; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('[#9066] recordPackageCommit — a failed sys_metadata_commit write is no longer silent', () => { + // ── POSITIVE CONTROL — nothing injected. Without it, every "no error was + // logged" below would also pass on a fixture that never wrote a commit. + + it('control: a healthy publish records the commit row, returns its id, and logs nothing', async () => { + const stub = makeStubEngine(); + const spy = spyConsole(); + const protocol = new ObjectStackProtocolImplementation(stub.engine as never); + + const res = await publishOne(protocol, 'solo_ticket', 'v1'); + + expect(res.success).toBe(true); + expect(res.publishedCount).toBe(1); + expect(typeof res.commitId).toBe('string'); + expect(commitRows(stub.sideTables)).toHaveLength(1); + expect(commitRows(stub.sideTables)[0].package_id).toBe('app.demo'); + expect(spy.error).not.toHaveBeenCalled(); + expect(spy.info).not.toHaveBeenCalled(); + }); + + // ── THE FIX — a commit write that failed for a non-benign reason is LOUD. + + it('a non-benign write failure logs at error, naming the lost revert plan and the fix', async () => { + const stub = makeStubEngine(); + const spy = spyConsole(); + const protocol = new ObjectStackProtocolImplementation(stub.engine as never); + stub.failCommitWith(connectionDropped()); + + const res = await publishOne(protocol, 'solo_ticket', 'v1'); + + // Proof the write was really attempted and really threw — otherwise the + // assertions below are about a publish that never reached the seam. + expect(stub.commitAttempts()).toBe(1); + expect(commitRows(stub.sideTables)).toEqual([]); + + // ⛔ The publish keeps its outcome. Only the silence changed. + expect(res.success).toBe(true); + expect(res.publishedCount).toBe(1); + expect(activeNames(stub.rows)).toEqual(['solo_ticket']); + // `commitId` is ABSENT, not null and not invented — the observable the + // #8896 ruling says the caller already has. No new response field. + expect('commitId' in res).toBe(false); + + expect(spy.error).toHaveBeenCalledTimes(1); + const line = String(spy.error.mock.calls[0][0]); + // The driver's own reason, the turn's identity, and the AGENTS.md pair: + // the CONSEQUENCE, then the FIX. + expect(line).toContain('connection terminated unexpectedly'); + expect(line).toContain("package 'app.demo'"); + expect(line).toContain('(apply, 1 item(s))'); + expect(line).toContain('The publish itself SUCCEEDED and reports success'); + expect(line).toContain('can never be reverted'); + expect(line).toContain('Fix: restore write access to sys_metadata_commit'); + // Not the unprovisioned branch. + expect(spy.info).not.toHaveBeenCalled(); + }); + + it('a second failed turn logs again — the count of unrevertible turns is not collapsed', async () => { + const stub = makeStubEngine(); + const spy = spyConsole(); + const protocol = new ObjectStackProtocolImplementation(stub.engine as never); + stub.failCommitWith(connectionDropped()); + + await publishOne(protocol, 'solo_ticket', 'v1'); + await publishOne(protocol, 'second_ticket', 'v1'); + + expect(stub.commitAttempts()).toBe(2); + expect(activeNames(stub.rows)).toEqual(['second_ticket', 'solo_ticket']); + // Each line is a DIFFERENT turn whose plan was lost, so each is said. + expect(spy.error).toHaveBeenCalledTimes(2); + expect(String(spy.error.mock.calls[1][0])).toContain("package 'app.demo'"); + }); + + it('a missing COLUMN on a provisioned commit store stays loud (the superstring case)', async () => { + const stub = makeStubEngine(); + const spy = spyConsole(); + const protocol = new ObjectStackProtocolImplementation(stub.engine as never); + stub.failCommitWith( + Object.assign( + new Error('column "items" of relation "sys_metadata_commit" does not exist'), + { code: '42703' }, + ), + ); + + const res = await publishOne(protocol, 'solo_ticket', 'v1'); + + expect(res.publishedCount).toBe(1); + expect(spy.error).toHaveBeenCalledTimes(1); + expect(String(spy.error.mock.calls[0][0])) + .toContain('column "items" of relation "sys_metadata_commit" does not exist'); + expect(spy.info).not.toHaveBeenCalled(); + }); + + // ── THE BENIGN CASE — an unprovisioned commit store is a deployment state, + // not a store that broke: informational, and said once. + + it('an UNPROVISIONED commit store is informational, not an error (sqlite phrasing)', async () => { + const stub = makeStubEngine(); + const spy = spyConsole(); + const protocol = new ObjectStackProtocolImplementation(stub.engine as never); + stub.failCommitWith(unprovisionedSqlite()); + + const res = await publishOne(protocol, 'solo_ticket', 'v1'); + + expect(stub.commitAttempts()).toBe(1); + expect(res.success).toBe(true); + expect(res.publishedCount).toBe(1); + expect('commitId' in res).toBe(false); + expect(spy.error).not.toHaveBeenCalled(); + expect(spy.info).toHaveBeenCalledTimes(1); + const line = String(spy.info.mock.calls[0][0]); + expect(line).toContain('sys_metadata_commit is not provisioned'); + expect(line).toContain('no turn can be reverted'); + expect(line).toContain('Fix: provision the commit store'); + }); + + it('an UNPROVISIONED commit store in the postgres phrasing (42P01) is benign too', async () => { + const stub = makeStubEngine(); + const spy = spyConsole(); + const protocol = new ObjectStackProtocolImplementation(stub.engine as never); + stub.failCommitWith(unprovisionedPostgres()); + + const res = await publishOne(protocol, 'solo_ticket', 'v1'); + + expect(res.publishedCount).toBe(1); + expect(stub.commitAttempts()).toBe(1); + expect(spy.error).not.toHaveBeenCalled(); + expect(spy.info).toHaveBeenCalledTimes(1); + }); + + it('the unprovisioned note is said ONCE per instance, and again for a fresh one', async () => { + const stub = makeStubEngine(); + const spy = spyConsole(); + const protocol = new ObjectStackProtocolImplementation(stub.engine as never); + stub.failCommitWith(unprovisionedSqlite()); + + await publishOne(protocol, 'solo_ticket', 'v1'); + await publishOne(protocol, 'second_ticket', 'v1'); + + // Both turns really reached the seam; only the first one spoke. + expect(stub.commitAttempts()).toBe(2); + expect(activeNames(stub.rows)).toEqual(['second_ticket', 'solo_ticket']); + expect(spy.info).toHaveBeenCalledTimes(1); + + // A DIFFERENT protocol is a different composition — it has never said + // it, so it says it. (This is why the flag is per-instance and not a + // module-level `let`.) + const other = new ObjectStackProtocolImplementation(stub.engine as never); + await publishOne(other, 'third_ticket', 'v1'); + expect(spy.info).toHaveBeenCalledTimes(2); + expect(spy.error).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index fc4690c77d..2290fe182d 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -15629,9 +15629,62 @@ export class ObjectStackProtocolImplementation implements // ───────────────────────────────────────────────────────────────────── /** - * Record one commit row (best-effort) grouping a turn's published - * artifacts. Returns the commit id, or null if the commit store is - * unavailable (e.g. unit-test stubs) — recording never blocks a publish. + * [#9066] Has the "commit store is not provisioned" note already been + * printed on this protocol instance? + * + * AGENTS.md's degradation rule says an operator-facing degradation is + * stated ONCE, at the first occurrence, not once per failed write: a + * deployment that never provisioned `sys_metadata_commit` would otherwise + * repeat the same sentence on every publish for the life of the process. + * Deliberately per-INSTANCE and not per-process (no module-level state): + * two protocols in one process are two deployments' worth of composition, + * and a module flag would silence the second one's first publish. + * + * The `error` branch below is NOT deduplicated, on purpose — each failed + * call is a DIFFERENT turn whose revert plan was lost, and the packageId / + * item count that identify it differ per line. Collapsing them would hide + * how many turns are unrevertible. + */ + private commitStoreUnprovisionedNoted = false; + + /** + * The `sys_metadata_commit` INSERT, on its own so it has a NAME. + * + * Extracted for `scripts/check-durability-degradation-log-level.mjs`, whose + * write rule matches declared callee names: `insert` is far too generic to + * declare repo-wide, so a durability write only becomes protectable once it + * has a wrapper of its own (the shape `persistAuditTrailRow` / + * `dropPromotedDraftRow` already take in that vocabulary). With + * `persistPackageCommitRow` declared there, the `catch` in + * {@link recordPackageCommit} can never silently regress to a quiet log + * again — which is precisely how the seam this repairs was born. + */ + private async persistPackageCommitRow(row: Record): Promise { + await this.engine.insert('sys_metadata_commit', row); + } + + /** + * Record one commit row grouping a turn's published artifacts. Returns the + * commit id, or null if the commit row could not be written — recording + * never blocks a publish (ADR-0067 D2's all-or-nothing rule is about the + * ARTIFACTS; the commit row is not allowed to fail one). + * + * [#9066] "Never blocks a publish" is not the same as "never says + * anything", and it used to be: the `catch` here returned `null` in + * silence, for every reason. The row is not a grouping label — it is the + * ONLY record of the turn's revert plan (`existedBefore` / `prevVersion` + * per artifact) that {@link revertCommit} and + * {@link rollbackToPackageCommit} can act on. Without it the artifacts are + * live, `publishPackageDrafts` answers `success: true` with `commitId` + * merely ABSENT, and the turn can never be reverted — the AGENTS.md + * durability-degradation shape exactly: nothing looks broken from the + * outside while something the system claims to persist did not land. A + * failing commit store also stays failing, so every later publish lost its + * plan the same silent way. + * + * The failure is now discriminated by error TYPE, the same way the read + * seams in this file ask (#5532 / #5980 / #8896), through the shared + * `isMissingTableError` predicate rather than a hand-rolled code test. */ private async recordPackageCommit(args: { orgId: string | null; @@ -15649,7 +15702,7 @@ export class ObjectStackProtocolImplementation implements const commitId = 'cmt_' + (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' ? crypto.randomUUID() : `${args.eventSeqEnd ?? 0}-${args.items.length}-${args.packageId}`); - await this.engine.insert('sys_metadata_commit', { + await this.persistPackageCommitRow({ id: commitId, package_id: args.packageId, operation: args.operation, @@ -15665,9 +15718,56 @@ export class ObjectStackProtocolImplementation implements created_at: new Date().toISOString(), }); return { commitId }; - } catch { - // Commit store unavailable (or insert raced) — the publish itself - // already succeeded; grouping is a best-effort overlay on top. + } catch (error) { + // [#9066] The publish keeps its outcome — `null` still comes back + // and the caller still reports success — but the silence is over. + // + // BENIGN: `sys_metadata_commit` was never provisioned. That is a + // real deployment state (a first boot, or an env kernel composed + // without the commit log — the exact case + // `@objectstack/metadata`'s plugin note records), and it is a + // CONFIGURATION fact, not a store that broke: it is the same on + // every publish and it is fixed in one place. Stated once per + // instance, at `info`, per this card's ruling that an unprovisioned + // commit store stays silent or informational. + // + // EVERYTHING ELSE — a connection drop, a timeout, a permission + // denial, schema drift on that one table — is a write that was + // supposed to land and did not, while the publish it describes DID. + // `error`, per AGENTS.md "Degradation log levels", and the line + // owes the two things that rule requires: the CONSEQUENCE (this + // turn is not revertible, and the system will keep looking healthy) + // and the FIX (repair write access to the commit store). + // + // NOT rethrown, and not surfaced to the caller: the artifacts are + // already live and unwinding them over a missing history row would + // be strictly worse than losing the row. Telling the CALLER that + // the turn is unrevertible is a separate question (a response-field + // change the #8896 ruling forbids for this family) and deliberately + // NOT decided here. + if (isMissingTableError(error)) { + if (!this.commitStoreUnprovisionedNoted) { + this.commitStoreUnprovisionedNoted = true; + console.info( + '[Protocol] sys_metadata_commit is not provisioned — publishes and reverts ' + + 'succeed but record no ADR-0067 commit row, so no turn can be reverted ' + + '(the revert plan of every turn is dropped). Fix: provision the commit ' + + 'store — it is registered alongside sys_metadata_history by ' + + "@objectstack/metadata's plugin; run schema sync. Said once per protocol instance.", + ); + } + return null; + } + const reason = (error as { message?: string } | undefined)?.message ?? String(error); + console.error( + `[Protocol] sys_metadata_commit write FAILED for package '${args.packageId}' ` + + `(${args.operation}, ${args.items.length} item(s)): ${reason}. ` + + 'The publish itself SUCCEEDED and reports success, so nothing looks broken — but ' + + "this turn's revert plan was not persisted: the turn is absent from the package " + + 'commit timeline and can never be reverted, and every further publish loses its ' + + 'own the same way until this is repaired. Fix: restore write access to ' + + 'sys_metadata_commit (connectivity, permissions, or schema drift on that table).', + ); return null; } } diff --git a/scripts/check-durability-degradation-log-level.mjs b/scripts/check-durability-degradation-log-level.mjs index 0c32a3bce8..f537667a21 100644 --- a/scripts/check-durability-degradation-log-level.mjs +++ b/scripts/check-durability-degradation-log-level.mjs @@ -195,6 +195,10 @@ const DURABILITY_CRITICAL_CALLEES = new Map([ 'deleteMetaItemFromLoader', 'The metadata definition was never deleted from the authoritative store — `unregister()` still resolves and still announces `deleted`, the in-memory registry entry is gone, and the surviving row is read straight back out of storage by the very next `list()`/`get()`, so the "deleted" item reappears and survives every restart. Nothing retries it (#5259).', ], + [ + 'persistPackageCommitRow', + "The ADR-0067 commit row for a publish/revert turn was never written — the artifacts are LIVE and `publishPackageDrafts` answers `success: true` with `commitId` merely absent, so the API, the metadata and every counter read clean, while the only record of that turn's revert plan (`existedBefore`/`prevVersion` per artifact) does not exist: `revertCommit` and `rollbackToPackageCommit` have nothing to act on and the turn can never be undone. A commit store that is failing stays failing, so every later publish loses its plan the same way (#9066).", + ], ]); /**