Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/adr-0067-commit-history.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@objectstack/metadata-core': minor
'@objectstack/objectql': minor
'@objectstack/runtime': minor
---

Package-scoped commit history & rollback for AI authoring (ADR-0067)

Each authoring apply now lands as one revertible **commit** on a package timeline, on top of `sys_metadata_history`:

- New `sys_metadata_commit` object groups a turn's metadata changes (by `event_seq` range).
- `publishPackageDrafts` records each publish as one commit (best-effort) with a per-artifact revert plan and an optional `message` / `aiModel`.
- New protocol methods `listCommits`, `revertCommit`, `rollbackToPackageCommit` (reusing `restoreVersion` + delete; a revert is itself an append-only commit).
- New REST routes: `GET /packages/:id/commits`, `POST /packages/:id/commits/:commitId/revert`, `POST /packages/:id/rollback`.
162 changes: 162 additions & 0 deletions docs/adr/0067-commit-history-and-rollback-for-ai-authoring.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions packages/metadata-core/src/objects/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,6 @@

export { SysMetadataObject, SysMetadataObject as SysMetadata } from './sys-metadata.object.js';
export { SysMetadataHistoryObject } from './sys-metadata-history.object.js';
export { SysMetadataCommitObject } from './sys-metadata-commit.object.js';
export { SysMetadataAuditObject } from './sys-metadata-audit.object.js';
export { SysViewDefinitionObject } from './sys-view-definition.object.js';
153 changes: 153 additions & 0 deletions packages/metadata-core/src/objects/sys-metadata-commit.object.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { ObjectSchema, Field } from '@objectstack/spec/data';

/**
* sys_metadata_commit — Package-scoped commit log (ADR-0067)
*
* One row per authoring TURN (AI apply or Studio batch) that promoted a group
* of metadata changes together. A commit GROUPS the per-item events already
* recorded in `sys_metadata_history` (by their `event_seq` range) into the unit
* a user actually reasons about — "the change my last instruction made" — and
* is the handle for `revertCommit` / `rollbackToPackageCommit`.
*
* Append-only, like `sys_metadata_history`: a revert is recorded as a NEW commit
* (`operation = 'revert'`, `parent_commit_id` = the reverted commit), never an
* in-place mutation. History therefore never loses the record of what happened.
*
* The `items` payload captures, per artifact, exactly what `revertCommit` needs
* to undo the commit losslessly: whether the artifact EXISTED before this commit
* (`existedBefore`) and, if so, the lineage `version` it should be restored to
* (`prevVersion`). A created-by-this-commit artifact reverts by soft-removal;
* a modified one reverts by `restoreVersion(prevVersion)`.
*/
export const SysMetadataCommitObject = ObjectSchema.create({
name: 'sys_metadata_commit',
label: 'Metadata Commit',
pluralLabel: 'Metadata Commits',
icon: 'git-commit',
isSystem: true,
managedBy: 'system',
description: 'Package-scoped commit log grouping a turn’s metadata changes (ADR-0067).',

fields: {
/** Primary Key — the commit id. */
id: Field.text({
label: 'ID',
required: true,
readonly: true,
maxLength: 64,
}),

/** The app/package this commit belongs to (the unit a user reverts). */
package_id: Field.text({
label: 'Package',
required: false,
searchable: true,
readonly: true,
maxLength: 255,
}),

/** apply = a turn promoted changes; revert = this commit undid another. */
operation: Field.select(['apply', 'revert'], {
label: 'Operation',
required: true,
readonly: true,
}),

/** Human-readable summary — for AI turns, the user's prompt. */
message: Field.textarea({
label: 'Message',
required: false,
readonly: true,
description: 'Change summary; for AI turns, the user instruction that produced it.',
}),

/** Producing actor (user id, or an AI principal like "ai:claude"). */
actor: Field.text({
label: 'Actor',
required: false,
readonly: true,
maxLength: 255,
}),

/** AI model that authored the turn (absent for human/CLI commits). */
ai_model: Field.text({
label: 'AI Model',
required: false,
readonly: true,
maxLength: 100,
}),

/** For a revert commit, the id of the commit it reverted. */
parent_commit_id: Field.text({
label: 'Parent Commit',
required: false,
readonly: true,
maxLength: 64,
}),

/** First `sys_metadata_history.event_seq` covered by this commit. */
event_seq_start: Field.number({
label: 'Event Seq Start',
required: false,
readonly: true,
}),

/** Last `sys_metadata_history.event_seq` covered by this commit. */
event_seq_end: Field.number({
label: 'Event Seq End',
required: false,
readonly: true,
}),

/**
* JSON array of the artifacts this commit touched, with the data
* `revertCommit` needs: [{ type, name, existedBefore, prevVersion }].
*/
items: Field.textarea({
label: 'Items',
required: false,
readonly: true,
description: 'JSON: [{ type, name, existedBefore, prevVersion }] — the revert plan.',
}),

/** Number of artifacts in `items` (denormalized for list views). */
item_count: Field.number({
label: 'Item Count',
required: false,
readonly: true,
}),

/** Organization ID for multi-tenant isolation. */
organization_id: Field.lookup('sys_organization', {
label: 'Organization',
required: false,
readonly: true,
description: 'Organization for multi-tenant isolation.',
}),

/** When the commit was recorded. */
created_at: Field.datetime({
label: 'Created At',
required: true,
readonly: true,
}),
},

indexes: [
// List a package's history newest-first (the timeline read pattern).
{ fields: ['organization_id', 'package_id', 'created_at'] },
// Org-wide commit replay / audit.
{ fields: ['organization_id', 'created_at'] },
{ fields: ['parent_commit_id'] },
],

enable: {
trackHistory: false,
searchable: false,
apiEnabled: true,
apiMethods: ['get', 'list'],
trash: false,
},
});
2 changes: 2 additions & 0 deletions packages/objectql/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { StorageNameMapping } from '@objectstack/spec/system';
import {
SysMetadataObject,
SysMetadataHistoryObject,
SysMetadataCommitObject,
SysMetadataAuditObject,
SysViewDefinitionObject,
} from '@objectstack/metadata-core';
Expand Down Expand Up @@ -196,6 +197,7 @@ export class ObjectQLPlugin implements Plugin {
objects: [
SysMetadataObject,
SysMetadataHistoryObject,
SysMetadataCommitObject,
SysMetadataAuditObject,
SysViewDefinitionObject,
],
Expand Down
192 changes: 192 additions & 0 deletions packages/objectql/src/protocol-commit-history.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect, vi } from 'vitest';
import { ObjectStackProtocolImplementation } from './protocol.js';

/**
* ADR-0067 — package-scoped commit history & rollback.
*
* These tests exercise the commit primitives in isolation with a tiny in-memory
* `sys_metadata_commit` engine fake + a stubbed overlay repo, mirroring the
* `makeProtocol` pattern in protocol-publish-package-drafts.test.ts. They prove
* the revert PLAN semantics (created → soft-remove; edited → restoreVersion) and
* the append-only "a revert is itself a commit" rule, without a real database.
*/
function makeFakeEngine(seedCommits: any[] = []) {
const commits: any[] = [...seedCommits];
const engine: any = {
insert: vi.fn(async (table: string, data: any) => {
if (table === 'sys_metadata_commit') commits.push(data);
}),
find: vi.fn(async (table: string, q: any) => {
if (table === 'sys_metadata_commit') {
return commits.filter((c) => c.package_id === q.where.package_id);
}
return [];
}),
findOne: vi.fn(async (table: string, q: any) => {
if (table === 'sys_metadata_commit') return commits.find((c) => c.id === q.where.id) ?? null;
return null; // no active sys_metadata rows by default
}),
};
return { engine, commits };
}

function makeProtocol(engine: any, repo: any) {
const protocol = new ObjectStackProtocolImplementation(engine as never);
(protocol as any).ensureOverlayIndex = async () => {};
(protocol as any).getOverlayRepo = () => repo;
return protocol;
}

const applyCommit = (over: Partial<any> & { id: string; items: any[]; created_at: string }) => ({
package_id: 'app.edu',
operation: 'apply',
message: 'build',
organization_id: null,
item_count: over.items.length,
...over,
items: JSON.stringify(over.items),
});

describe('ADR-0067 — listCommits', () => {
it('returns [] for a package with no commits', async () => {
const { engine } = makeFakeEngine();
const p = makeProtocol(engine, {});
expect(await p.listCommits({ packageId: 'app.none' })).toEqual([]);
});

it('returns a package’s commits newest-first with parsed items', async () => {
const { engine } = makeFakeEngine([
applyCommit({ id: 'c1', items: [{ type: 'object', name: 'a', existedBefore: false, prevVersion: null }], created_at: '2026-06-24T00:00:01.000Z' }),
applyCommit({ id: 'c2', items: [{ type: 'view', name: 'b', existedBefore: false, prevVersion: null }], created_at: '2026-06-24T00:00:02.000Z' }),
]);
const p = makeProtocol(engine, {});
const list = await p.listCommits({ packageId: 'app.edu' });
expect(list.map((c) => c.id)).toEqual(['c2', 'c1']); // newest-first
expect(list[0].items[0]).toMatchObject({ type: 'view', name: 'b' });
});
});

describe('ADR-0067 — revertCommit', () => {
it('soft-removes artifacts the commit CREATED and records a revert commit', async () => {
const { engine, commits } = makeFakeEngine([
applyCommit({ id: 'cmt_1', items: [{ type: 'object', name: 'course', existedBefore: false, prevVersion: null }], created_at: '2026-06-24T00:00:00.000Z' }),
]);
const del = vi.fn(async () => {});
const repo = { get: vi.fn(async () => ({ hash: 'h1' })), delete: del, restoreVersion: vi.fn() };
const p = makeProtocol(engine, repo);

const res = await p.revertCommit({ commitId: 'cmt_1' });

expect(del).toHaveBeenCalledTimes(1);
expect(res.revertedCount).toBe(1);
expect(res.reverted[0]).toMatchObject({ type: 'object', name: 'course', action: 'removed' });
const revertRow = commits.find((c) => c.operation === 'revert');
expect(revertRow).toBeTruthy();
expect(revertRow.parent_commit_id).toBe('cmt_1');
});

it('restores artifacts the commit EDITED to their prevVersion', async () => {
const { engine } = makeFakeEngine([
applyCommit({ id: 'cmt_2', items: [{ type: 'object', name: 'course', existedBefore: true, prevVersion: 3 }], created_at: '2026-06-24T00:00:00.000Z' }),
]);
const restoreVersion = vi.fn(async () => ({}));
const repo = { get: vi.fn(async () => ({ hash: 'h2' })), delete: vi.fn(), restoreVersion };
const p = makeProtocol(engine, repo);

const res = await p.revertCommit({ commitId: 'cmt_2' });

expect(restoreVersion).toHaveBeenCalledWith(
expect.anything(),
3,
expect.objectContaining({ source: 'protocol.revertCommit' }),
);
expect(res.reverted[0]).toMatchObject({ type: 'object', name: 'course', action: 'restored' });
});

it('throws commit_not_found (404) for an unknown commit', async () => {
const { engine } = makeFakeEngine();
const p = makeProtocol(engine, {});
await expect(p.revertCommit({ commitId: 'nope' })).rejects.toMatchObject({ code: 'commit_not_found', status: 404 });
});

it('reverts dependent artifacts in REVERSE apply order (view before its object)', async () => {
const { engine } = makeFakeEngine([
applyCommit({
id: 'cmt_3',
items: [
{ type: 'object', name: 'course', existedBefore: false, prevVersion: null },
{ type: 'view', name: 'course_list', existedBefore: false, prevVersion: null },
],
created_at: '2026-06-24T00:00:00.000Z',
}),
]);
const order: string[] = [];
const repo = {
get: vi.fn(async () => ({ hash: 'h' })),
delete: vi.fn(async (ref: any) => { order.push(ref.name); }),
restoreVersion: vi.fn(),
};
const p = makeProtocol(engine, repo);
await p.revertCommit({ commitId: 'cmt_3' });
expect(order).toEqual(['course_list', 'course']); // dependents first
});
});

describe('ADR-0067 — rollbackToPackageCommit', () => {
it('reverts every apply commit strictly newer than the target', async () => {
const { engine } = makeFakeEngine([
applyCommit({ id: 'c1', items: [], created_at: '2026-06-24T00:00:01.000Z' }),
applyCommit({ id: 'c2', items: [{ type: 'object', name: 'x', existedBefore: false, prevVersion: null }], created_at: '2026-06-24T00:00:02.000Z' }),
applyCommit({ id: 'c3', items: [{ type: 'view', name: 'y', existedBefore: false, prevVersion: null }], created_at: '2026-06-24T00:00:03.000Z' }),
]);
const repo = { get: vi.fn(async () => ({ hash: 'h' })), delete: vi.fn(async () => {}), restoreVersion: vi.fn() };
const p = makeProtocol(engine, repo);

const res = await p.rollbackToPackageCommit({ commitId: 'c1' });

expect(res.success).toBe(true);
expect([...res.revertedCommits].sort()).toEqual(['c2', 'c3']); // c1 itself kept
});

it('throws commit_not_found for an unknown target', async () => {
const { engine } = makeFakeEngine();
const p = makeProtocol(engine, {});
await expect(p.rollbackToPackageCommit({ commitId: 'ghost' })).rejects.toMatchObject({ code: 'commit_not_found' });
});
});

describe('ADR-0067 — publishPackageDrafts records a commit', () => {
it('records an apply commit carrying the message + aiModel + revert plan', async () => {
const commits: any[] = [];
const engine: any = {
insert: vi.fn(async (t: string, d: any) => { if (t === 'sys_metadata_commit') commits.push(d); }),
findOne: vi.fn(async () => null), // no active rows → every draft is a CREATE
find: vi.fn(async () => []),
};
const protocol = new ObjectStackProtocolImplementation(engine as never);
(protocol as any).ensureOverlayIndex = async () => {};
(protocol as any).getOverlayRepo = () => ({
listDrafts: async () => [{ type: 'object', name: 'course' }],
get: async () => null,
});
vi.spyOn(protocol, 'publishMetaItem' as never).mockResolvedValue({ success: true, version: 'h', seq: 7 } as never);

const res: any = await (protocol as any).publishPackageDrafts({
packageId: 'app.edu',
message: 'build an education app',
aiModel: 'claude-opus-4-8',
actor: 'ai:claude',
});

expect(res.commitId).toBeTruthy();
const apply = commits.find((c) => c.operation === 'apply');
expect(apply).toBeTruthy();
expect(apply.message).toBe('build an education app');
expect(apply.ai_model).toBe('claude-opus-4-8');
expect(apply.item_count).toBe(1);
const items = JSON.parse(apply.items);
expect(items[0]).toMatchObject({ type: 'object', name: 'course', existedBefore: false });
});
});
Loading