Skip to content

Commit 4d99a5c

Browse files
os-zhuangclaude
andauthored
feat: package-scoped commit history & rollback for AI authoring (ADR-0067) (#2256)
* feat(objectql): package-scoped commit history & rollback (ADR-0067 v1) Foundation for ADR-0067: turn each authoring apply into a revertible commit. - sys_metadata_commit object: groups a turn's metadata changes (by event_seq range) into one unit on top of sys_metadata_history; registered alongside it. - protocol.publishPackageDrafts now records each publish as ONE commit (best-effort, never blocks publish), capturing a per-artifact revert plan: created -> soft-remove; edited -> restoreVersion(prevVersion). - protocol.listCommits / revertCommit / rollbackToPackageCommit, reusing the existing restoreVersion + delete primitives. A revert is itself an append-only commit (operation='revert'). - docs/adr/0067 + protocol-commit-history unit tests. Deferred (per ADR): cross-item DB atomicity (ambient-tx), REST routes, cloud turn-wiring, objectui timeline, browser E2E. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(runtime): REST routes for commit history & rollback (ADR-0067) Expose the ADR-0067 commit primitives over HTTP, mirroring the publish-drafts wiring: - GET /packages/:id/commits -> listCommits (timeline) - POST /packages/:id/commits/:commitId/revert -> revertCommit - POST /packages/:id/rollback {commitId} -> rollbackToPackageCommit Best-effort (501 when a custom protocol lacks the method); 400 when rollback is called without a commitId. +5 http-dispatcher route tests (131 green). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(objectql): thread commit message + aiModel through publishPackageDrafts (ADR-0067) publishPackageDrafts now accepts optional message + aiModel and stamps them on the recorded commit, so an AI turn's commit carries the user's instruction (and the authoring model) for the timeline. +1 end-to-end test (19 green). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: changeset for ADR-0067 commit history --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 4a84c98 commit 4d99a5c

9 files changed

Lines changed: 974 additions & 0 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/metadata-core': minor
3+
'@objectstack/objectql': minor
4+
'@objectstack/runtime': minor
5+
---
6+
7+
Package-scoped commit history & rollback for AI authoring (ADR-0067)
8+
9+
Each authoring apply now lands as one revertible **commit** on a package timeline, on top of `sys_metadata_history`:
10+
11+
- New `sys_metadata_commit` object groups a turn's metadata changes (by `event_seq` range).
12+
- `publishPackageDrafts` records each publish as one commit (best-effort) with a per-artifact revert plan and an optional `message` / `aiModel`.
13+
- New protocol methods `listCommits`, `revertCommit`, `rollbackToPackageCommit` (reusing `restoreVersion` + delete; a revert is itself an append-only commit).
14+
- New REST routes: `GET /packages/:id/commits`, `POST /packages/:id/commits/:commitId/revert`, `POST /packages/:id/rollback`.

docs/adr/0067-commit-history-and-rollback-for-ai-authoring.md

Lines changed: 162 additions & 0 deletions
Large diffs are not rendered by default.

packages/metadata-core/src/objects/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,6 @@
1818

1919
export { SysMetadataObject, SysMetadataObject as SysMetadata } from './sys-metadata.object.js';
2020
export { SysMetadataHistoryObject } from './sys-metadata-history.object.js';
21+
export { SysMetadataCommitObject } from './sys-metadata-commit.object.js';
2122
export { SysMetadataAuditObject } from './sys-metadata-audit.object.js';
2223
export { SysViewDefinitionObject } from './sys-view-definition.object.js';
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { ObjectSchema, Field } from '@objectstack/spec/data';
4+
5+
/**
6+
* sys_metadata_commit — Package-scoped commit log (ADR-0067)
7+
*
8+
* One row per authoring TURN (AI apply or Studio batch) that promoted a group
9+
* of metadata changes together. A commit GROUPS the per-item events already
10+
* recorded in `sys_metadata_history` (by their `event_seq` range) into the unit
11+
* a user actually reasons about — "the change my last instruction made" — and
12+
* is the handle for `revertCommit` / `rollbackToPackageCommit`.
13+
*
14+
* Append-only, like `sys_metadata_history`: a revert is recorded as a NEW commit
15+
* (`operation = 'revert'`, `parent_commit_id` = the reverted commit), never an
16+
* in-place mutation. History therefore never loses the record of what happened.
17+
*
18+
* The `items` payload captures, per artifact, exactly what `revertCommit` needs
19+
* to undo the commit losslessly: whether the artifact EXISTED before this commit
20+
* (`existedBefore`) and, if so, the lineage `version` it should be restored to
21+
* (`prevVersion`). A created-by-this-commit artifact reverts by soft-removal;
22+
* a modified one reverts by `restoreVersion(prevVersion)`.
23+
*/
24+
export const SysMetadataCommitObject = ObjectSchema.create({
25+
name: 'sys_metadata_commit',
26+
label: 'Metadata Commit',
27+
pluralLabel: 'Metadata Commits',
28+
icon: 'git-commit',
29+
isSystem: true,
30+
managedBy: 'system',
31+
description: 'Package-scoped commit log grouping a turn’s metadata changes (ADR-0067).',
32+
33+
fields: {
34+
/** Primary Key — the commit id. */
35+
id: Field.text({
36+
label: 'ID',
37+
required: true,
38+
readonly: true,
39+
maxLength: 64,
40+
}),
41+
42+
/** The app/package this commit belongs to (the unit a user reverts). */
43+
package_id: Field.text({
44+
label: 'Package',
45+
required: false,
46+
searchable: true,
47+
readonly: true,
48+
maxLength: 255,
49+
}),
50+
51+
/** apply = a turn promoted changes; revert = this commit undid another. */
52+
operation: Field.select(['apply', 'revert'], {
53+
label: 'Operation',
54+
required: true,
55+
readonly: true,
56+
}),
57+
58+
/** Human-readable summary — for AI turns, the user's prompt. */
59+
message: Field.textarea({
60+
label: 'Message',
61+
required: false,
62+
readonly: true,
63+
description: 'Change summary; for AI turns, the user instruction that produced it.',
64+
}),
65+
66+
/** Producing actor (user id, or an AI principal like "ai:claude"). */
67+
actor: Field.text({
68+
label: 'Actor',
69+
required: false,
70+
readonly: true,
71+
maxLength: 255,
72+
}),
73+
74+
/** AI model that authored the turn (absent for human/CLI commits). */
75+
ai_model: Field.text({
76+
label: 'AI Model',
77+
required: false,
78+
readonly: true,
79+
maxLength: 100,
80+
}),
81+
82+
/** For a revert commit, the id of the commit it reverted. */
83+
parent_commit_id: Field.text({
84+
label: 'Parent Commit',
85+
required: false,
86+
readonly: true,
87+
maxLength: 64,
88+
}),
89+
90+
/** First `sys_metadata_history.event_seq` covered by this commit. */
91+
event_seq_start: Field.number({
92+
label: 'Event Seq Start',
93+
required: false,
94+
readonly: true,
95+
}),
96+
97+
/** Last `sys_metadata_history.event_seq` covered by this commit. */
98+
event_seq_end: Field.number({
99+
label: 'Event Seq End',
100+
required: false,
101+
readonly: true,
102+
}),
103+
104+
/**
105+
* JSON array of the artifacts this commit touched, with the data
106+
* `revertCommit` needs: [{ type, name, existedBefore, prevVersion }].
107+
*/
108+
items: Field.textarea({
109+
label: 'Items',
110+
required: false,
111+
readonly: true,
112+
description: 'JSON: [{ type, name, existedBefore, prevVersion }] — the revert plan.',
113+
}),
114+
115+
/** Number of artifacts in `items` (denormalized for list views). */
116+
item_count: Field.number({
117+
label: 'Item Count',
118+
required: false,
119+
readonly: true,
120+
}),
121+
122+
/** Organization ID for multi-tenant isolation. */
123+
organization_id: Field.lookup('sys_organization', {
124+
label: 'Organization',
125+
required: false,
126+
readonly: true,
127+
description: 'Organization for multi-tenant isolation.',
128+
}),
129+
130+
/** When the commit was recorded. */
131+
created_at: Field.datetime({
132+
label: 'Created At',
133+
required: true,
134+
readonly: true,
135+
}),
136+
},
137+
138+
indexes: [
139+
// List a package's history newest-first (the timeline read pattern).
140+
{ fields: ['organization_id', 'package_id', 'created_at'] },
141+
// Org-wide commit replay / audit.
142+
{ fields: ['organization_id', 'created_at'] },
143+
{ fields: ['parent_commit_id'] },
144+
],
145+
146+
enable: {
147+
trackHistory: false,
148+
searchable: false,
149+
apiEnabled: true,
150+
apiMethods: ['get', 'list'],
151+
trash: false,
152+
},
153+
});

packages/objectql/src/plugin.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { StorageNameMapping } from '@objectstack/spec/system';
77
import {
88
SysMetadataObject,
99
SysMetadataHistoryObject,
10+
SysMetadataCommitObject,
1011
SysMetadataAuditObject,
1112
SysViewDefinitionObject,
1213
} from '@objectstack/metadata-core';
@@ -196,6 +197,7 @@ export class ObjectQLPlugin implements Plugin {
196197
objects: [
197198
SysMetadataObject,
198199
SysMetadataHistoryObject,
200+
SysMetadataCommitObject,
199201
SysMetadataAuditObject,
200202
SysViewDefinitionObject,
201203
],
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, vi } from 'vitest';
4+
import { ObjectStackProtocolImplementation } from './protocol.js';
5+
6+
/**
7+
* ADR-0067 — package-scoped commit history & rollback.
8+
*
9+
* These tests exercise the commit primitives in isolation with a tiny in-memory
10+
* `sys_metadata_commit` engine fake + a stubbed overlay repo, mirroring the
11+
* `makeProtocol` pattern in protocol-publish-package-drafts.test.ts. They prove
12+
* the revert PLAN semantics (created → soft-remove; edited → restoreVersion) and
13+
* the append-only "a revert is itself a commit" rule, without a real database.
14+
*/
15+
function makeFakeEngine(seedCommits: any[] = []) {
16+
const commits: any[] = [...seedCommits];
17+
const engine: any = {
18+
insert: vi.fn(async (table: string, data: any) => {
19+
if (table === 'sys_metadata_commit') commits.push(data);
20+
}),
21+
find: vi.fn(async (table: string, q: any) => {
22+
if (table === 'sys_metadata_commit') {
23+
return commits.filter((c) => c.package_id === q.where.package_id);
24+
}
25+
return [];
26+
}),
27+
findOne: vi.fn(async (table: string, q: any) => {
28+
if (table === 'sys_metadata_commit') return commits.find((c) => c.id === q.where.id) ?? null;
29+
return null; // no active sys_metadata rows by default
30+
}),
31+
};
32+
return { engine, commits };
33+
}
34+
35+
function makeProtocol(engine: any, repo: any) {
36+
const protocol = new ObjectStackProtocolImplementation(engine as never);
37+
(protocol as any).ensureOverlayIndex = async () => {};
38+
(protocol as any).getOverlayRepo = () => repo;
39+
return protocol;
40+
}
41+
42+
const applyCommit = (over: Partial<any> & { id: string; items: any[]; created_at: string }) => ({
43+
package_id: 'app.edu',
44+
operation: 'apply',
45+
message: 'build',
46+
organization_id: null,
47+
item_count: over.items.length,
48+
...over,
49+
items: JSON.stringify(over.items),
50+
});
51+
52+
describe('ADR-0067 — listCommits', () => {
53+
it('returns [] for a package with no commits', async () => {
54+
const { engine } = makeFakeEngine();
55+
const p = makeProtocol(engine, {});
56+
expect(await p.listCommits({ packageId: 'app.none' })).toEqual([]);
57+
});
58+
59+
it('returns a package’s commits newest-first with parsed items', async () => {
60+
const { engine } = makeFakeEngine([
61+
applyCommit({ id: 'c1', items: [{ type: 'object', name: 'a', existedBefore: false, prevVersion: null }], created_at: '2026-06-24T00:00:01.000Z' }),
62+
applyCommit({ id: 'c2', items: [{ type: 'view', name: 'b', existedBefore: false, prevVersion: null }], created_at: '2026-06-24T00:00:02.000Z' }),
63+
]);
64+
const p = makeProtocol(engine, {});
65+
const list = await p.listCommits({ packageId: 'app.edu' });
66+
expect(list.map((c) => c.id)).toEqual(['c2', 'c1']); // newest-first
67+
expect(list[0].items[0]).toMatchObject({ type: 'view', name: 'b' });
68+
});
69+
});
70+
71+
describe('ADR-0067 — revertCommit', () => {
72+
it('soft-removes artifacts the commit CREATED and records a revert commit', async () => {
73+
const { engine, commits } = makeFakeEngine([
74+
applyCommit({ id: 'cmt_1', items: [{ type: 'object', name: 'course', existedBefore: false, prevVersion: null }], created_at: '2026-06-24T00:00:00.000Z' }),
75+
]);
76+
const del = vi.fn(async () => {});
77+
const repo = { get: vi.fn(async () => ({ hash: 'h1' })), delete: del, restoreVersion: vi.fn() };
78+
const p = makeProtocol(engine, repo);
79+
80+
const res = await p.revertCommit({ commitId: 'cmt_1' });
81+
82+
expect(del).toHaveBeenCalledTimes(1);
83+
expect(res.revertedCount).toBe(1);
84+
expect(res.reverted[0]).toMatchObject({ type: 'object', name: 'course', action: 'removed' });
85+
const revertRow = commits.find((c) => c.operation === 'revert');
86+
expect(revertRow).toBeTruthy();
87+
expect(revertRow.parent_commit_id).toBe('cmt_1');
88+
});
89+
90+
it('restores artifacts the commit EDITED to their prevVersion', async () => {
91+
const { engine } = makeFakeEngine([
92+
applyCommit({ id: 'cmt_2', items: [{ type: 'object', name: 'course', existedBefore: true, prevVersion: 3 }], created_at: '2026-06-24T00:00:00.000Z' }),
93+
]);
94+
const restoreVersion = vi.fn(async () => ({}));
95+
const repo = { get: vi.fn(async () => ({ hash: 'h2' })), delete: vi.fn(), restoreVersion };
96+
const p = makeProtocol(engine, repo);
97+
98+
const res = await p.revertCommit({ commitId: 'cmt_2' });
99+
100+
expect(restoreVersion).toHaveBeenCalledWith(
101+
expect.anything(),
102+
3,
103+
expect.objectContaining({ source: 'protocol.revertCommit' }),
104+
);
105+
expect(res.reverted[0]).toMatchObject({ type: 'object', name: 'course', action: 'restored' });
106+
});
107+
108+
it('throws commit_not_found (404) for an unknown commit', async () => {
109+
const { engine } = makeFakeEngine();
110+
const p = makeProtocol(engine, {});
111+
await expect(p.revertCommit({ commitId: 'nope' })).rejects.toMatchObject({ code: 'commit_not_found', status: 404 });
112+
});
113+
114+
it('reverts dependent artifacts in REVERSE apply order (view before its object)', async () => {
115+
const { engine } = makeFakeEngine([
116+
applyCommit({
117+
id: 'cmt_3',
118+
items: [
119+
{ type: 'object', name: 'course', existedBefore: false, prevVersion: null },
120+
{ type: 'view', name: 'course_list', existedBefore: false, prevVersion: null },
121+
],
122+
created_at: '2026-06-24T00:00:00.000Z',
123+
}),
124+
]);
125+
const order: string[] = [];
126+
const repo = {
127+
get: vi.fn(async () => ({ hash: 'h' })),
128+
delete: vi.fn(async (ref: any) => { order.push(ref.name); }),
129+
restoreVersion: vi.fn(),
130+
};
131+
const p = makeProtocol(engine, repo);
132+
await p.revertCommit({ commitId: 'cmt_3' });
133+
expect(order).toEqual(['course_list', 'course']); // dependents first
134+
});
135+
});
136+
137+
describe('ADR-0067 — rollbackToPackageCommit', () => {
138+
it('reverts every apply commit strictly newer than the target', async () => {
139+
const { engine } = makeFakeEngine([
140+
applyCommit({ id: 'c1', items: [], created_at: '2026-06-24T00:00:01.000Z' }),
141+
applyCommit({ id: 'c2', items: [{ type: 'object', name: 'x', existedBefore: false, prevVersion: null }], created_at: '2026-06-24T00:00:02.000Z' }),
142+
applyCommit({ id: 'c3', items: [{ type: 'view', name: 'y', existedBefore: false, prevVersion: null }], created_at: '2026-06-24T00:00:03.000Z' }),
143+
]);
144+
const repo = { get: vi.fn(async () => ({ hash: 'h' })), delete: vi.fn(async () => {}), restoreVersion: vi.fn() };
145+
const p = makeProtocol(engine, repo);
146+
147+
const res = await p.rollbackToPackageCommit({ commitId: 'c1' });
148+
149+
expect(res.success).toBe(true);
150+
expect([...res.revertedCommits].sort()).toEqual(['c2', 'c3']); // c1 itself kept
151+
});
152+
153+
it('throws commit_not_found for an unknown target', async () => {
154+
const { engine } = makeFakeEngine();
155+
const p = makeProtocol(engine, {});
156+
await expect(p.rollbackToPackageCommit({ commitId: 'ghost' })).rejects.toMatchObject({ code: 'commit_not_found' });
157+
});
158+
});
159+
160+
describe('ADR-0067 — publishPackageDrafts records a commit', () => {
161+
it('records an apply commit carrying the message + aiModel + revert plan', async () => {
162+
const commits: any[] = [];
163+
const engine: any = {
164+
insert: vi.fn(async (t: string, d: any) => { if (t === 'sys_metadata_commit') commits.push(d); }),
165+
findOne: vi.fn(async () => null), // no active rows → every draft is a CREATE
166+
find: vi.fn(async () => []),
167+
};
168+
const protocol = new ObjectStackProtocolImplementation(engine as never);
169+
(protocol as any).ensureOverlayIndex = async () => {};
170+
(protocol as any).getOverlayRepo = () => ({
171+
listDrafts: async () => [{ type: 'object', name: 'course' }],
172+
get: async () => null,
173+
});
174+
vi.spyOn(protocol, 'publishMetaItem' as never).mockResolvedValue({ success: true, version: 'h', seq: 7 } as never);
175+
176+
const res: any = await (protocol as any).publishPackageDrafts({
177+
packageId: 'app.edu',
178+
message: 'build an education app',
179+
aiModel: 'claude-opus-4-8',
180+
actor: 'ai:claude',
181+
});
182+
183+
expect(res.commitId).toBeTruthy();
184+
const apply = commits.find((c) => c.operation === 'apply');
185+
expect(apply).toBeTruthy();
186+
expect(apply.message).toBe('build an education app');
187+
expect(apply.ai_model).toBe('claude-opus-4-8');
188+
expect(apply.item_count).toBe(1);
189+
const items = JSON.parse(apply.items);
190+
expect(items[0]).toMatchObject({ type: 'object', name: 'course', existedBefore: false });
191+
});
192+
});

0 commit comments

Comments
 (0)