Skip to content

Commit d5951c8

Browse files
fix(metadata-protocol): a package rollback carries every item a revert step refused, and answers success: false (#27957)
domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F <!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuPMZ8BHWtPf6nBTp51qSM64)_ Fixes #27868 Clause-②: no Metadata-protocol: none Skills-docs: none — the answer's declared shape and its `success` contract do not change, and no skill or doc describes the rollback's `failed[]` population ## 维护者速读 - 改了什么:`rollbackToPackageCommit` 现在把每一步 `revertCommit` 返回的逐项拒绝(例如非规范存储类型的恢复被拒)写进回滚自己的 `failed[]`,有任何一项被拒就答 `success: false`。 - 为什么改:之前只有抛错才进 `failed[]`,逐项拒绝被丢弃,管理员回滚一个包时被告知成功,实际上有的条目原样未动,包处于半回滚状态且毫无提示。 - 风险与回滚:只改 `rollbackToPackageCommit` 一个方法内部,答复形状不变(仍是 `{ commitId, error }`);回滚继续越过被拒条目的行为不变。出问题直接 revert 本 PR 即可。 - 先看:`packages/metadata-protocol/src/protocol.ts`(`rollbackToPackageCommit` 循环里新增的 `for (const item of step.failed)`),然后 `packages/metadata-protocol/src/protocol.rollback-carries-item-failures.test.ts`。 - 两分钟验证:`pnpm --filter @objectstack/metadata-protocol exec vitest run src/protocol.rollback-carries-item-failures.test.ts`,预期 3 个用例全部通过。 - 需要你做的:无,席位落地。 ## What changes `rollbackToPackageCommit` (`POST /packages/:id/rollback`) runs `revertCommit` once per newer `apply` commit. `revertCommit` reports an item it refuses in its **returned** per-item `failed[]` and does not throw; the rollback read only thrown errors, so a package left half rolled back answered `success: true` with an empty `failed[]`. Now each returned per-item failure becomes one entry in the rollback's own `failed[]`: - `commitId`: the commit the item belongs to; - `error`: `Item 'TYPE/NAME' was not reverted: ` followed by the step's own `failed[].error`, which is already client-facing (the P11 withhold in `revertCommit` has run on it). `success` stays `failed.length === 0`, so any refused item makes it false. **Shape.** The declared answer, `RollbackToPackageCommitResponseSchema` (`packages/spec/src/api/package-lifecycle.zod.ts`), types each `failed[]` entry as `{ commitId, error }`, with no item fields. So the item is named inside `error`, and no spec edit is made. The step's per-item `code` (for example `STORED_TYPE_NOT_CANONICAL`) has no field in that shape and is not carried. See Acceptance notes. **What stays the same.** - A refused item stops neither its commit nor the rollback. The step still reverts the item's neighbours and records its revert commit, so that commit is also listed in `revertedCommits`. A partly reverted commit therefore appears in both lists. - A step that throws still lands as one `{ commitId, error }` entry, and the rollback goes on to the next commit. - The HTTP route is unchanged: it still answers 200 with the result as `data`. **Clause-②: no, and why it is not a narrowing.** The answer's shape is unchanged. What changes is `success` becoming `false` for a request that used to read `true`. The published contract already says this is the right answer: `RollbackToPackageCommitResponseSchema.success` is described as "True exactly when nothing failed", and the sibling `RevertPackageCommitResponseSchema` already answers `success: false` for the same refused item. The change brings the implementation back to its declared contract. It does not narrow an accept set or a published shape: every request accepted before is still accepted and gets the same shape. Hence plain `no`, with no `(narrowing)` arm, no `!` and no BREAKING note. ## Tests New `packages/metadata-protocol/src/protocol.rollback-carries-item-failures.test.ts`. It runs through the real protocol and the real `SysMetadataRepository`, with an engine double that also serves the commit ledger, so `listCommits` plans the rollback itself. It is the same harness shape as `protocol.revert-stored-type-canonical.test.ts`. The package history is: target `c0`; `c1` creates a view; `c2` edits `view/grid`, edits `fields/a.b` (stored under the non-canonical type, which `revertCommit` refuses with `STORED_TYPE_NOT_CANONICAL`) and creates a view. 1. **Refused item.** `success: false`. `failed` has exactly one entry with keys `commitId` and `error`: commit `c2`, an error that names `fields/a.b` and quotes the step's refusal text. `revertedCommits` is `c1` and `c2`. Both created views are removed, `view/grid` is back to V1, and `fields/a.b` stays at V2. 2. **Control, all clean.** `{ success: true, revertedCommits: [c1, c2], failed: [] }`. 3. **Control, thrown step.** `c2` is listed but cannot be read. The result is `failed: [{ commitId: 'c2', error: "No commit 'c2'." }]`, and `c1` is still reverted. The test imports `./protocol.js` from `src`, not through a package `exports` path, so no `dist` rebuild sits between the mutation and the run. **Ablation.** Through `scripts/ablation-replace.mjs` on `packages/metadata-protocol/src/protocol.ts`, the anchor `for (const item of step.failed) {` is replaced by `for (const item of [] as typeof step.failed) {`, which drops the carried failures and gives the pre-fix behaviour. The predicted result was case 1 red and both controls green. That is what happened. <details><summary>Ablation output</summary> ```text ablation-replace: anchor "for (const item of step.failed) {" x1 (before) ablation-replace: anchor hits 1 time(s), as declared ablation-replace: anchor x1 -> x0 ablation-replace: replace "for (const item of [] as typeof step.failed) {" x0 -> x1 ablation-replace: blob f0181b9 -> 9780b8b5cb0d132a5936d879d9eb7fc7ff9fe1d4 ablation-replace: ok mutation landed: anchor 1 -> 0, blob f0181b9 -> 9780b8b5cb0d x answers `success: false` and names the commit, the item and the refusal text 30ms AssertionError: expected true to be false // Object.is equality Test Files 1 failed (1) Tests 1 failed | 2 passed (3) ablation-replace: command exited 1 ablation-replace: blob after restore f0181b9 ablation-replace: blob at HEAD f0181b9 ablation-replace: git diff HEAD empty ablation-replace: ok restored: blob == HEAD (f0181b9) and `git diff HEAD` is empty ``` </details> <details><summary>Package suite, typecheck, lint</summary> ```text pnpm --filter @objectstack/metadata-protocol test (at c47a817) exit 0 Test Files 283 passed | 4 skipped (287) Tests 21846 passed | 26 skipped (21872) vitest run on the new pin + batch-verb-driver-text + commit-timeline-instant-order (at the final test-file content, 233b1f0) exit 0 Test Files 3 passed (3) Tests 34 passed (34) pnpm --filter @objectstack/metadata-protocol typecheck (at 233b1f0) exit 0 tsc --listFiles names the new test file (1 hit), so it is compiled pnpm exec eslint packages/metadata-protocol --no-inline-config exit 0 ``` The commit after c47a817 changes only the new test's engine double (it holds `limit` and refuses unknown combinators) and the engine-double ledger. The three affected test files and the typecheck were run again on that content. </details> ## Acceptance notes - **The declared item shape is coarser than the step's.** `RollbackToPackageCommitResponseSchema.failed[]` has no `type`, `name` or `code`, so a client reads the refused item from `error` text and loses the step's machine code. The schema's `failed` description, "Every commit the rollback could not revert.", now also covers a partly reverted commit's refused items. Giving the rollback entry structured `type` / `name` / `code` fields would be a `packages/spec` change (a widening, so `Clause-②: yes`) and is out of this card's surface. carrier: none. - **`check:engine-double-contract` ledger.** `scripts/engine-double-contract.pinned.json` gains the new test's engine double, written by `node scripts/check-engine-double-contract.mjs --write` as the gate prescribes. The new double holds the caller's `limit` (`check:objectql-double-limit`) and refuses any combinator other than `$or` (`check:where-matcher`). - **In-flight intersection.** No open PR touches `packages/metadata-protocol/src/protocol.ts`. All 27 open PRs were read, including all 721 files of the Version Packages PR. The one in-flight branch in the same package, `claude/issue-27829-searchfields-credential-refused` (no PR yet), does not touch `protocol.ts`. `git merge-tree --write-tree HEAD` against it exits 0. ## Verification Head `233b1f0e`, merged with `origin/main` (`0a370917`). The union was run after the final commit, at `233b1f0e`: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives 69 families. All 69 were run, each exit code captured before any pipe, and every one exited 0. Reconciliation: `dispatch-gates --ran` answers "69 derived famil(ies) accounted for — 69 run, 0 NOT-MEASURED (a DERIVED zero — all 69 recorded an exit code and none of them is 3)". At `c47a8170` the first union had four reds: `check:engine-double-contract` (ledger not updated), `check:objectql-double-limit` (the double was limit-blind), `check:where-matcher` (the double had no combinator refusal), and `check:dual-build-cjs-loads` / `check:lean-entry-closure` (exit 3, prerequisite not met: `dist` was missing). The first three were fixed in `233b1f0e`. The last two were measured after the build and are green. The CI families are cited from this PR's check-runs once they report. --- _Generated by [Claude Code](https://claude.ai/code/session_01L6bhgUCizoYWQVJWjhdR6F)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L6bhgUCizoYWQVJWjhdR6F --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5f6dc96 commit d5951c8

4 files changed

Lines changed: 330 additions & 1 deletion

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
fix(metadata-protocol): a package rollback carries every item a revert step refused, and answers `success: false`
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
`rollbackToPackageCommit` (`POST /packages/:id/rollback`) reverts each newer `apply` commit with `revertCommit`. A step that refuses an item — for example a restore of a row stored under a non-canonical type (`STORED_TYPE_NOT_CANONICAL`) — reports it in the step's own returned `failed[]` and does not throw. The rollback read only a thrown error, so it answered `success: true` with an empty `failed[]` for a package left half rolled back.
11+
12+
Each refused item now lands in the rollback's `failed[]` as `{ commitId, error }`: the commit it belongs to, and an `error` that names the item (`Item 'TYPE/NAME' was not reverted: ...`) and quotes the step's client-facing refusal text. `success` is false whenever `failed[]` is non-empty, which is what the declared answer (`RollbackToPackageCommitResponseSchema`) already says: "True exactly when nothing failed". The answer's shape is unchanged.
13+
14+
Nothing else moves. A refused item stops neither its commit nor the rollback: the other items of that commit are still reverted, its revert commit is still recorded, and the commit is still listed in `revertedCommits`. A step that throws still lands as one `failed[]` entry for its commit, and the rollback goes on to the next commit, as before.
Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// `rollbackToPackageCommit` carries every item a `revertCommit` step refused
4+
// into its own `failed[]`, and answers `success: false` for it.
5+
//
6+
// A package rollback is a loop of `revertCommit` calls, newest commit first.
7+
// `revertCommit` reports an item it refuses — here the restore of a row stored
8+
// under a non-canonical type, `STORED_TYPE_NOT_CANONICAL` — in its RETURNED
9+
// per-item `failed[]`; it does not throw. The rollback read only a thrown
10+
// error, so it dropped the returned refusals and answered `success: true` for
11+
// a package left half rolled back, naming nothing it had left in place.
12+
//
13+
// The declared answer (`RollbackToPackageCommitResponseSchema`) says
14+
// `success` is "True exactly when nothing failed", and each `failed[]` entry
15+
// is `{ commitId, error }`. A refused item therefore lands as one entry: the
16+
// commit it belongs to, and an `error` that names the item (`type/name`) and
17+
// quotes `revertCommit`'s client-facing refusal text.
18+
//
19+
// Driven over the real `SysMetadataRepository`, with the commit ledger served
20+
// by the engine double so `listCommits` plans the rollback itself.
21+
22+
import { afterEach, describe, expect, it, vi } from 'vitest';
23+
// [#5619] The producer's OWN write-verb dispatch decisions, from
24+
// `@objectstack/metadata-core` — objectql depends on this package.
25+
import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/metadata-core';
26+
import { ObjectStackProtocolImplementation } from './protocol.js';
27+
28+
interface Row {
29+
id: string;
30+
type: string;
31+
name: string;
32+
organization_id: string | null;
33+
package_id: string | null;
34+
state: string;
35+
metadata: string;
36+
}
37+
38+
interface HistoryRow {
39+
id: string;
40+
type: string;
41+
name: string;
42+
version: number;
43+
organization_id: string | null;
44+
operation_type: string;
45+
}
46+
47+
const PKG = 'app.demo';
48+
49+
/** ADR-0048 overlay key — (type, name, org, state, package_id). */
50+
const keyOf = (w: Record<string, unknown>) =>
51+
`${w.type}|${w.name}|${w.organization_id ?? '__env__'}|${w.state ?? 'active'}|${w.package_id ?? '__nopkg__'}`;
52+
53+
function matchesWhere(r: Record<string, unknown>, where: Record<string, unknown>): boolean {
54+
for (const [k, v] of Object.entries(where)) {
55+
if (k === '$or') {
56+
if (!(v as Array<Record<string, unknown>>).some((c) => matchesWhere(r, c))) return false;
57+
continue;
58+
}
59+
if (k.startsWith('$')) throw new Error(`test double: unsupported combinator '${k}'`);
60+
if (v === undefined) continue;
61+
if ((r[k] ?? null) !== v) return false;
62+
}
63+
return true;
64+
}
65+
66+
/** One `apply` commit row in the driver's snake_case wire shape. */
67+
const applyCommit = (id: string, createdAt: string, items: unknown[]) => ({
68+
id,
69+
package_id: PKG,
70+
organization_id: null,
71+
operation: 'apply',
72+
message: `commit ${id}`,
73+
item_count: items.length,
74+
created_at: createdAt,
75+
items: JSON.stringify(items),
76+
});
77+
78+
function makeProtocol(opts: { unreadableCommits?: readonly string[] } = {}) {
79+
const rows = new Map<string, Row>();
80+
const historyRows: HistoryRow[] = [];
81+
const commits: Array<Record<string, unknown>> = [];
82+
const unreadable = new Set(opts.unreadableCommits ?? []);
83+
let nextId = 0;
84+
85+
const findRow = (w: Record<string, unknown>): { key: string; row: Row } | null => {
86+
if (w.id !== undefined) {
87+
for (const [k, r] of rows) if (r.id === w.id) return { key: k, row: r };
88+
return null;
89+
}
90+
for (const [k, r] of rows) if (matchesWhere(r as any, w)) return { key: k, row: r };
91+
return null;
92+
};
93+
const matchesHistory = (h: HistoryRow, w: Record<string, unknown>): boolean =>
94+
(['organization_id', 'type', 'name', 'version', 'operation_type'] as const)
95+
.every((k) => w[k] === undefined || h[k] === w[k]);
96+
97+
const engine: any = {
98+
async findOne(table: string, o: { where: Record<string, unknown> }) {
99+
assertEngineFindOnePredicate(table, o);
100+
if (table === 'sys_metadata_commit') {
101+
// A commit the ledger LISTS but whose own read answers nothing —
102+
// the inner `revertCommit` then throws `COMMIT_NOT_FOUND`.
103+
if (unreadable.has(String(o.where.id))) return null;
104+
return commits.find((c) => c.id === o.where.id) ?? null;
105+
}
106+
if (table === 'sys_metadata_history') {
107+
return historyRows.find((h) => matchesHistory(h, o.where)) ?? null;
108+
}
109+
if (table !== 'sys_metadata') return null;
110+
return findRow(o.where)?.row ?? null;
111+
},
112+
async find(table: string, o?: { where?: Record<string, unknown>; limit?: number }) {
113+
const matched: unknown[] =
114+
table === 'sys_metadata_commit'
115+
? commits.filter((c) => c.package_id === o?.where?.package_id)
116+
: table === 'sys_metadata_history'
117+
? historyRows.filter((h) => matchesHistory(h, o?.where ?? {}))
118+
: table === 'sys_metadata'
119+
? Array.from(rows.values()).filter((r) => matchesWhere(r as any, o?.where ?? {}))
120+
: [];
121+
return typeof o?.limit === 'number' ? matched.slice(0, o.limit) : matched;
122+
},
123+
async insert(table: string, data: Record<string, unknown>) {
124+
nextId += 1;
125+
if (table === 'sys_metadata_commit') {
126+
commits.push({ ...data });
127+
return { id: String(data.id ?? `cm_${nextId}`) };
128+
}
129+
if (table === 'sys_metadata_history') {
130+
const h = { ...(data as unknown as HistoryRow), id: `h_${nextId}` };
131+
historyRows.push(h);
132+
return { id: h.id };
133+
}
134+
if (table !== 'sys_metadata') return { id: 'side_effect_skip' };
135+
const row = { ...(data as unknown as Row), id: `r_${nextId}` };
136+
rows.set(keyOf(data), row);
137+
return { id: row.id };
138+
},
139+
async update(_t: string, data: Record<string, unknown>, o: { where: Record<string, unknown> }) {
140+
assertEngineUpdateDispatch(data, o);
141+
const found = findRow(o.where);
142+
if (!found) return { id: null };
143+
const merged = { ...found.row, ...(data as unknown as Row) };
144+
rows.delete(found.key);
145+
rows.set(keyOf(merged), merged);
146+
return { id: found.row.id };
147+
},
148+
async delete(_t: string, o: { where: Record<string, unknown> }) {
149+
assertEngineDeleteDispatch(o);
150+
const found = findRow(o.where);
151+
if (!found) return { deleted: 0 };
152+
rows.delete(found.key);
153+
return { deleted: 1 };
154+
},
155+
async transaction<T>(cb: (ctx: unknown, info: { owned: boolean }) => Promise<T>): Promise<T> {
156+
return cb(undefined, { owned: true });
157+
},
158+
async syncObjectSchema() { return true; },
159+
async dropObjectSchema() { return true; },
160+
registry: {
161+
registerItem: () => undefined,
162+
registerObject: () => undefined,
163+
listItems: () => [],
164+
getItem: () => undefined,
165+
getObject: () => undefined,
166+
getPackage: () => undefined,
167+
getArtifactItem: () => undefined,
168+
removeRuntimeShadow: () => false,
169+
removeOverlayEntry: () => undefined,
170+
},
171+
};
172+
const protocol = new ObjectStackProtocolImplementation(engine, () => new Map(), undefined) as any;
173+
return { protocol, rows, commits };
174+
}
175+
176+
/** Write a row through the repository, which stores `type` exactly as given. */
177+
async function seed(protocol: any, type: string, name: string, label: string): Promise<void> {
178+
await protocol.ensureOverlayIndex();
179+
const repo = protocol.getOverlayRepo(null);
180+
const ref = { type, name, org: 'env' };
181+
const existing = await repo.get(ref, { state: 'active' });
182+
await repo.put(ref, { name, label }, {
183+
parentVersion: existing?.hash ?? null,
184+
actor: null,
185+
source: 'test.rollback-item-failures',
186+
intent: 'runtime-only',
187+
state: 'active',
188+
});
189+
}
190+
191+
const stored = (rows: Map<string, Row>, type: string, name: string): any => {
192+
for (const r of rows.values()) if (r.type === type && r.name === name) return JSON.parse(r.metadata);
193+
return undefined;
194+
};
195+
196+
/**
197+
* The package's history: target `c0`; `c1` created `view/created_in_c1`;
198+
* `c2` created `view/created_in_c2`, edited `view/grid` (V1 → V2) and edited
199+
* `fields/a.b` (V1 → V2). The `fields` row is stored under the non-canonical
200+
* type, so `revertCommit` refuses to restore it (`STORED_TYPE_NOT_CANONICAL`).
201+
*/
202+
async function seedPackage(protocol: any, commits: Array<Record<string, unknown>>, opts: { refusedItem: boolean }) {
203+
await seed(protocol, 'view', 'grid', 'V1');
204+
await seed(protocol, 'view', 'grid', 'V2');
205+
await seed(protocol, 'view', 'created_in_c1', 'C1');
206+
await seed(protocol, 'view', 'created_in_c2', 'C2');
207+
if (opts.refusedItem) {
208+
await seed(protocol, 'fields', 'a.b', 'V1');
209+
await seed(protocol, 'fields', 'a.b', 'V2');
210+
}
211+
commits.push(
212+
applyCommit('c0', '2026-01-01T00:00:00Z', []),
213+
applyCommit('c1', '2026-01-02T00:00:00Z', [
214+
{ type: 'view', name: 'created_in_c1', existedBefore: false, prevVersion: null },
215+
]),
216+
applyCommit('c2', '2026-01-03T00:00:00Z', [
217+
{ type: 'view', name: 'grid', existedBefore: true, prevVersion: 1 },
218+
...(opts.refusedItem ? [{ type: 'fields', name: 'a.b', existedBefore: true, prevVersion: 1 }] : []),
219+
{ type: 'view', name: 'created_in_c2', existedBefore: false, prevVersion: null },
220+
]),
221+
);
222+
}
223+
224+
afterEach(() => {
225+
vi.restoreAllMocks();
226+
});
227+
228+
describe('rollbackToPackageCommit carries the items a revert step refused', () => {
229+
it('answers `success: false` and names the commit, the item and the refusal text', async () => {
230+
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
231+
const { protocol, rows, commits } = makeProtocol();
232+
await seedPackage(protocol, commits, { refusedItem: true });
233+
234+
// What the inner step itself reports for the refused item — the text
235+
// the rollback must carry.
236+
const refusal = 'is stored under the non-canonical metadata type \'fields\'';
237+
238+
const res = await protocol.rollbackToPackageCommit({ commitId: 'c0' });
239+
240+
expect(res.success).toBe(false);
241+
expect(res.failed).toHaveLength(1);
242+
expect(Object.keys(res.failed[0]).sort()).toEqual(['commitId', 'error']);
243+
expect(res.failed[0].commitId).toBe('c2');
244+
expect(res.failed[0].error).toContain('fields/a.b');
245+
expect(res.failed[0].error).toContain(refusal);
246+
expect(res.failed[0].error).toContain('PUT /meta/field/a.b');
247+
248+
// Everything else is rolled back as before: both commits are reverted
249+
// (each step records its own revert commit) and the neighbours of the
250+
// refused item are undone.
251+
expect([...res.revertedCommits].sort()).toEqual(['c1', 'c2']);
252+
expect(stored(rows, 'view', 'created_in_c1')).toBeUndefined();
253+
expect(stored(rows, 'view', 'created_in_c2')).toBeUndefined();
254+
expect(stored(rows, 'view', 'grid')?.label).toBe('V1');
255+
// The refused item stayed as it was.
256+
expect(stored(rows, 'fields', 'a.b')?.label).toBe('V2');
257+
});
258+
259+
it('control: an all-clean rollback answers `success: true` with an empty `failed[]`', async () => {
260+
const { protocol, rows, commits } = makeProtocol();
261+
await seedPackage(protocol, commits, { refusedItem: false });
262+
263+
const res = await protocol.rollbackToPackageCommit({ commitId: 'c0' });
264+
265+
expect(res).toEqual({ success: true, revertedCommits: expect.any(Array), failed: [] });
266+
expect([...res.revertedCommits].sort()).toEqual(['c1', 'c2']);
267+
expect(stored(rows, 'view', 'grid')?.label).toBe('V1');
268+
});
269+
270+
it('control: a commit whose revert throws still lands in `failed[]`, and the next commit is still reverted', async () => {
271+
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
272+
const { protocol, rows, commits } = makeProtocol({ unreadableCommits: ['c2'] });
273+
await seedPackage(protocol, commits, { refusedItem: false });
274+
275+
const res = await protocol.rollbackToPackageCommit({ commitId: 'c0' });
276+
277+
expect(res.success).toBe(false);
278+
expect(res.failed).toEqual([{ commitId: 'c2', error: "No commit 'c2'." }]);
279+
expect(res.revertedCommits).toEqual(['c1']);
280+
expect(stored(rows, 'view', 'created_in_c1')).toBeUndefined();
281+
expect(stored(rows, 'view', 'grid')?.label).toBe('V2');
282+
});
283+
});

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27156,6 +27156,15 @@ export class ObjectStackProtocolImplementation implements
2715627156
* Roll a package back THROUGH every `apply` commit newer than `commitId`
2715727157
* (newest first), leaving the package as it was at that commit. Each step is
2715827158
* an individual `revertCommit`, so the whole rollback is itself audited.
27159+
*
27160+
* `failed[]` carries both kinds of failure a step can have: a step that
27161+
* THREW (one entry for the commit), and every item a step REFUSED and
27162+
* reported in its own returned `failed[]` (one entry per item, its `error`
27163+
* naming the item and quoting the step's client-facing text). Either makes
27164+
* `success` false — the declared answer's "true exactly when nothing
27165+
* failed". A refused item stops neither its commit nor the rollback: the
27166+
* step still reverts the item's neighbours and records its revert commit,
27167+
* so that commit is also listed in `revertedCommits`.
2715927168
*/
2716027169
async rollbackToPackageCommit(request: {
2716127170
commitId: string;
@@ -27192,11 +27201,19 @@ export class ObjectStackProtocolImplementation implements
2719227201
const failed: Array<{ commitId: string; error: string }> = [];
2719327202
for (const c of toRevert) {
2719427203
try {
27195-
await this.revertCommit({
27204+
const step = await this.revertCommit({
2719627205
commitId: c.id,
2719727206
...(request.actor ? { actor: request.actor } : {}),
2719827207
});
2719927208
revertedCommits.push(c.id);
27209+
// An item the step refused is returned, never thrown; its
27210+
// `error` is already client-facing (P11).
27211+
for (const item of step.failed) {
27212+
failed.push({
27213+
commitId: c.id,
27214+
error: `Item '${item.type}/${item.name}' was not reverted: ${item.error}`,
27215+
});
27216+
}
2720027217
} catch (e: any) {
2720127218
// [#8333 · P12] The derivative of P11 — `failed[].error` on the
2720227219
// rollback response. Measured on `origin/main` by failing the

‎scripts/engine-double-contract.pinned.json‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1626,6 +1626,21 @@
16261626
"verb": "update",
16271627
"pinned": 1
16281628
},
1629+
{
1630+
"file": "packages/metadata-protocol/src/protocol.rollback-carries-item-failures.test.ts",
1631+
"verb": "delete",
1632+
"pinned": 1
1633+
},
1634+
{
1635+
"file": "packages/metadata-protocol/src/protocol.rollback-carries-item-failures.test.ts",
1636+
"verb": "findOne",
1637+
"pinned": 1
1638+
},
1639+
{
1640+
"file": "packages/metadata-protocol/src/protocol.rollback-carries-item-failures.test.ts",
1641+
"verb": "update",
1642+
"pinned": 1
1643+
},
16291644
{
16301645
"file": "packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts",
16311646
"verb": "delete",

0 commit comments

Comments
 (0)