diff --git a/.changeset/adr-0067-commit-history.md b/.changeset/adr-0067-commit-history.md new file mode 100644 index 0000000000..a361ee90c9 --- /dev/null +++ b/.changeset/adr-0067-commit-history.md @@ -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`. diff --git a/docs/adr/0067-commit-history-and-rollback-for-ai-authoring.md b/docs/adr/0067-commit-history-and-rollback-for-ai-authoring.md new file mode 100644 index 0000000000..739039038e --- /dev/null +++ b/docs/adr/0067-commit-history-and-rollback-for-ai-authoring.md @@ -0,0 +1,162 @@ +# ADR-0067: Commit history and rollback for AI authoring — turns become atomic, revertible commits + +**Status**: Proposed (2026-06-24) +**Deciders**: ObjectStack Protocol Architects +**Builds on / amends**: [ADR-0045](./0045-additive-materialization-and-visibility-gate.md) (**amended**: ADR-0045 keeps a *draft + human-confirm* gate on mutations as the safety mechanism; this ADR replaces *confirm-before* with *revert-after* for everything except irreversible data loss, and unifies the two authoring regimes under one primitive — the commit), [ADR-0027](./0027-metadata-authoring-lifecycle.md) (draft workspace — retained as a *review affordance*, demoted from *safety mechanism*), [ADR-0033](./0033-ai-assisted-metadata-authoring.md) ("AI never publishes — it drafts" → **AI commits; commits are revertible**), [ADR-0034](./0034-transactional-writes-and-ambient-transaction.md) (per-write transaction — **extended to span a whole turn**), [ADR-0038](./0038-build-verification-loop.md) (machine gate — runs per commit, before it lands) +**Consumers**: `@objectstack/objectql` (commit grouping, atomic turn-apply, `revertCommit`, history query — built on the existing `sys_metadata_history` + `restoreVersion`), `@objectstack/runtime` + `@objectstack/rest` (commit/revert routes), `../cloud/service-ai-studio` (turn = commit; auto-commit policy; data-loss confirmation), `../objectui` (commit timeline + "revert to here") + +**Premise**: pre-launch, no back-compat debt — specify the target end-state directly. + +**Design center**: **a confirm gate is the right safety primitive only for changes you cannot take back.** ADR-0045 made additive builds safe-by-immediacy and kept a human-confirm gate on mutations, reasoning "nothing visible changes without approval." But a confirm-before-publish gate is a *pessimistic lock*: it pays its cost on every change to prevent the rare irreversible one. Git's lesson — and the lesson of every modern authoring tool — is that when changes are **atomic and cheaply reversible**, the safe primitive is *optimistic*: let it land, keep perfect history, revert if wrong. The platform already has the hard parts (per-item version history with full-body snapshots; a single-item `restoreVersion`; per-write transactions). What is missing is the unit the user actually thinks in — **a commit: the atomic, named, revertible set of metadata changes from one AI turn** — and the rule that decides when revert is *not* enough and a human must still confirm: **the destruction of real user data, and nothing else.** + +--- + +## TL;DR + +1. **A turn is a commit.** Every AI apply (and Studio batch) records its metadata changes as one **commit** — a named group over the existing `sys_metadata_history` event log, tagged with a `commit_id`, actor, message (the user's prompt), and the AI model. The per-item history and full-body snapshots that back rollback already exist; this ADR adds the *grouping* and the *package-scoped HEAD*. +2. **Commits are atomic.** All metadata writes in a turn land in **one transaction** or none do. This closes ADR-0045's residual seam — `publishPackageDrafts` is per-item best-effort today, so a mid-batch failure leaves a half-live app ("claimed 120 rows, opens empty"). A commit cannot half-land. +3. **Rollback is first-class.** `revertCommit(id)` / `rollbackToPackageCommit(id)` restores every item in the target commit atomically, reusing the single-item `restoreVersion` primitive. A revert is a **new forward commit** (git-revert, not reset): history is append-only and never loses the record. +4. **The confirm gate narrows from "before publish" to "before irreversible data loss."** With atomic + revertible commits, additive *and* mutation turns auto-commit with no per-change confirmation. The only confirmation that remains is destroying **real user data** (§Decision-5). Governed orgs keep their ADR-0045/0027 approval gate by policy — unchanged. +5. **Metadata reverts cleanly; data is made reversible by discipline.** Destructive operations (drop field/object) go **soft by default** — data retained, column/table hidden, recoverable in the ADR-0045 trash window — so their revert restores data losslessly. Hard teardown happens only on explicit discard/GC. This is what makes "revert-after" actually safe. +6. **Substrate: the framework history log is the commit log; package snapshots are named restore points.** `sys_metadata_history` (per-event, lightweight, already carries actor/diff/`operation_type`) is the commit substrate. `sys_package_version` (full-bundle, heavy) is reserved for **named restore points** — the escape hatch cut before a risky revert, and marketplace release — *not* a per-turn snapshot. + +--- + +## Context — what already exists, and the one gap + +A commit-history model usually means "build a versioning system." Here, most of it is already shipped; the gap is narrow and specific. + +### The primitives that already exist + +| Capability | Where | Note | +|---|---|---| +| Per-item version history, **full body per version** | `sys_metadata_history` (`packages/metadata-core/src/objects/sys-metadata-history.object.ts`) | append-only; `operation_type ∈ {create,update,publish,revert,delete}`; `event_seq` (per-org monotonic, orders *all* changes); `version` (per-item monotonic); `recorded_by` (incl. `ai:claude`) | +| Read a prior version / **single-item revert** | `SysMetadataRepository.history()` / `restoreVersion(ref, targetVersion)` (`packages/objectql/src/sys-metadata-repository.ts`) | revert already lands as a forward `operation_type='revert'` event | +| Per-write ACID (metadata row + history row atomic) | `withTxn()` / `engine.transaction()` (same file + `engine.ts`) | exists **per item**; not across items | +| Full-bundle package snapshot + atomic rollback-by-install | `sys_package_version` + `snapshotBundleAsManifest()` + install pointer swap (cloud `service-tenant` / `service-cloud`) | downgrade already detected ("Rolled back v2→v1") | +| Soft vs hard delete | `keepData` / `dropStorage` flags (`engine.ts` teardown) | **default keeps data**; hard drop is opt-in — soft-drop is already the default posture | + +### The gap + +Three things are missing, and only three: + +1. **Grouping.** There is no "commit" — no id that says *these N history events were one turn*. History is a flat per-item stream ordered by `event_seq`. +2. **Cross-item atomicity.** `publishPackageDrafts` publishes each draft independently and "collects per-item failures without aborting the rest" (its own test name). There is no turn-spanning transaction, so a turn can half-land — the exact seam behind ADR-0038's "seed staged but never materialized" / "Published! but empty" incident classes. +3. **A package-scoped HEAD + revert-a-commit.** `restoreVersion` reverts one item; nothing reverts *the set of items a turn touched* as a unit, and nothing tracks "which commit is current" for a package. + +Everything else — the snapshots, the revert mechanic, the audit fields — is reuse. + +### Why this is the right time + +ADR-0045's own "Consequences/Costs" lists the two regimes ("materialize vs. draft … must be explained") and its v1.1 tail (trash-can, quota) as open. The commit model **subsumes** them: revert *is* discard; the history *is* the audit trail; one primitive (commit) replaces "is this additive or a mutation?" as the thing the user reasons about. And the friction the founder named — *"I don't want to keep confirming publishes"* — is precisely ADR-0045's mutation-confirm gate, which this ADR is designed to retire safely. + +--- + +## Options considered + +**A — Keep ADR-0045 as-is.** Additive auto-publishes; mutations stay drafts behind a human confirm. *Rejected.* Leaves the two-regime split and the per-mutation confirm friction in place — the problem this ADR exists to solve. It also leaves the half-land seam (per-item publish) unaddressed. + +**B — Per-turn full-bundle snapshot via a cloud `/checkpoint` route on `sys_package_version`.** The "obvious" git-snapshot: serialize the whole package every turn, install-swap to roll back. *Rejected.* (1) Heavy — duplicates the entire package bundle per turn, unbounded DB growth on a chatty iteration loop. (2) Wrong layer — puts the versioning *mechanism* in cloud, violating ADR-0002 "open mechanism, close intelligence"; the framework already owns metadata versioning. (3) Doesn't actually solve data rollback — `sys_package_version` snapshots the *declared* bundle (metadata + seed), not the live runtime rows a user typed in, so install-swap rollback silently diverges from real data anyway. Retained only for what it's good at: heavyweight **named restore points** (§Decision-6). + +**C — Commit log on `sys_metadata_history` + turn-atomic apply + first-class `revertCommit`; package snapshots as named restore points; destructive ops soft-by-default.** *Chosen.* Builds on the existing framework primitives, keeps the mechanism open-core-correct, and makes revert genuinely safe by pairing it with soft-delete discipline. + +--- + +## Decision + +### 1. A turn is a commit + +A **commit** is the atomic set of metadata events produced by one apply/turn, identified by a `commit_id` and recorded as a group over `sys_metadata_history`. Minimal mechanism: a `commit_id` column on the history event (plus a thin `sys_metadata_commit` row carrying `{ commit_id, package_id, organization_id, message, actor, ai_model?, event_seq_range, parent_commit_id }`). No new snapshot store — the bodies are already in `sys_metadata_history.metadata`. + +The commit's **message is the user's prompt**; its **actor** is the AI principal; its **diff** is computed from the per-event `checksum` / `previous_checksum` already recorded. A package's commits form a **linear history with a HEAD** (the current top), scoped per environment (metadata is org/env-scoped). + +The server owns commit assembly — the model never constructs it. `apply_blueprint` and the per-item authoring tools (`add_field`, `create_metadata`, …) run inside a turn context that opens a commit, writes through it, and seals it. + +### 2. Commits are atomic + +All metadata writes in a turn execute inside **one `engine.transaction()`**; any failure rolls the whole commit back — no half-built app, no orphan rows, no partial visibility flip. This replaces `publishPackageDrafts`'s per-item best-effort loop with a turn-spanning transaction (the per-item `withTxn` composes into it). The visibility flip (ADR-0045 `hidden:false`) is part of the same transaction — never "some apps visible, some not." + +*Driver caveat*: in-memory drivers have no real transaction (`withTxn` no-ops); atomicity is a SQL-driver guarantee. Production is SQL; document the gap, don't paper over it. + +### 3. Rollback is first-class and append-only + +- `revertCommit(commitId)` — restore every item the commit touched to its **pre-commit** body, atomically (reuse `restoreVersion` per item inside one transaction). Recorded as a **new forward commit** (`operation_type='revert'`), so history is never rewritten and a revert is itself revertible. +- `rollbackToPackageCommit(commitId)` — revert the package's HEAD back through every commit after the target, as one transaction. This is the "一层一层回退" the founder described — **commit-granular, not per-artifact** (§Resolved-1). +- Revert restores **metadata** deterministically. Its effect on **data** is governed by §5. + +### 4. The confirm gate, relocated + +ADR-0045 gated *publish* (a human confirms before anything visible changes). This ADR relocates the gate to the only place revert can't save you: + +- **Default (self-use / auto-publish environments)**: additive **and** mutation turns **auto-commit** — no per-change confirmation. The user reasons over the **commit timeline** (review, revert), not a stream of approve-prompts. This is the friction removal. +- **Confirmation survives in exactly two cases**: (a) **governed environments** keep their ADR-0045/0027 approval gate by *policy* (compliance requirement — unchanged; the `autoPublishAiBuilds=false` path); (b) **irreversible destruction of real user data**, everywhere, per §5. + +The ADR-0027 **draft workspace + `?preview=draft` diff review** is retained as a *review affordance* (a reviewer may still preview a pending change), but it is **no longer the safety mechanism** — revertibility is. Drafts stop being mandatory for mutations in the default path. + +### 5. Metadata reverts cleanly; data is made reversible (the crux) + +Metadata is declarative and snapshotted, so it reverts exactly. Data does not: reverting "create object *X*" or "drop field *f*" touches real tables and real rows — including rows a user typed in after publish. The rule that keeps revert safe: + +- **Destructive operations are soft by default.** Dropping a field/object hides the column/table and **retains the data** (the existing `keepData` posture; never `dropStorage:true` on a revert). Reverting the drop restores the data losslessly. **Hard teardown** (physical drop) happens only on explicit **discard/GC** (ADR-0045 trash-can), never as a side effect of a commit or revert. +- **Reverting an additive commit is tiered** (the *limit-aware* principle — *invisible when safe, explicit when it matters*): + - the new object/view holds **only AI-seeded sample rows** → revert teardown is **silent** (recoverable in the trash window); zero friction. + - the new object holds **user-entered rows beyond the seed** → revert requires a **typed confirmation that names the impact** ("revert will remove object *X* and its **N** rows, **M** of them entered after publish") **and auto-cuts a `sys_package_version` named restore point first** (the escape hatch — the revert is itself undoable). +- This is the precise boundary of the §4 confirm gate: a human confirms **iff** the operation would destroy real user data that revert cannot resurrect. + +### 6. Substrate and restore points + +- **Commit log = `sys_metadata_history`** (framework, per-event, reuse). This is the per-turn substrate. No full-bundle copy per turn. +- **`sys_package_version` = named restore points** (cloud, full-bundle, heavy), cut only on: the §5 pre-destructive-revert escape hatch; a user-initiated "name a checkpoint" ("before I let the AI go wide"); and marketplace release. *Not* per turn. +- A "manual checkpoint" affordance is cheap (reuses the existing publish-version route) and may ship early for the *sense of control* it gives, independent of the commit machinery. + +### Open-core boundary + +The **mechanism** — commit grouping, turn-atomic apply, `revertCommit` / `rollbackToPackageCommit`, history/commit query, the soft-delete-on-revert rule — is **framework, open** (it is generic metadata versioning; it must work for CLI and human authoring, not just AI). The **intelligence/policy** — turn = commit assembly from an AI conversation, auto-commit policy per plan, the AI metadata stamped on commits, *when* a turn counts as data-destructive — stays in **cloud/EE** per ADR-0002. The **timeline UI** is generic SDUI in objectui. The cloud-side per-turn `/checkpoint` route (Option B) is explicitly rejected as a boundary violation. + +--- + +## Consequences + +**Gains** +- **One primitive.** "Is this additive or a mutation?" stops being a user-facing concept; every turn is a commit. ADR-0045's "two regimes must be explained" cost is paid down. +- **The friction is gone where it's pure friction.** Self-use authoring stops asking the user to approve their own AI's work; the timeline replaces the approve-stream. +- **Discard, undo, and audit unify.** Revert *is* discard; the commit log *is* the audit trail; ADR-0045's v1.1 trash-can becomes "revert the create commit." +- **The half-land class dies.** Turn-atomicity removes "staged but never materialized" / "Published! but empty" at the source, not by honest after-the-fact reporting. +- **Governance is untouched.** Governed orgs keep approval by policy; the change is purely in the default self-use path. + +**Costs** +- Turn-atomicity is a **SQL-driver** guarantee; in-memory loses it (documented, acceptable). +- Revert safety **depends on soft-delete discipline** — any path that hard-drops on a non-discard operation breaks the model; needs a test guard. +- **Schema migration**: `commit_id` on history + the `sys_metadata_commit` grouping row. +- The **timeline UI** is net-new (but replaces, not adds to, the publish-card/changes-panel surface). +- A wrong-but-lint-clean additive build now goes live with **no human in the loop** in the default path — acceptable only because it is one click to revert and (ADR-0038) lint-gated; governed orgs opt back into review. + +--- + +## Phases + +### v1 — atomic commits + the timeline (the founder's ask, minimally) + +Acceptance, browser-level: *build an app (commit 1) → ask the AI to change it (commit 2) → open the timeline → "revert to commit 1" → the app returns to its commit-1 state; the revert appears as commit 3; no per-change approve-prompt was shown.* + +1. **Framework**: turn-spanning transaction for an apply (Decision-2); `commit_id` grouping over `sys_metadata_history` + `sys_metadata_commit` (Decision-1); `revertCommit` / `rollbackToPackageCommit` reusing `restoreVersion` (Decision-3); `listCommits(packageId)` / HEAD. +2. **Cloud**: AI turn opens/seals a commit with the user prompt as message + model metadata; auto-commit replaces per-mutation drafting in the default path (kill-switch retained); governed path unchanged. +3. **objectui**: a **commit timeline** panel on the app/package (message · diff · actor · time + "revert to here"), replacing the publish-card/Changes-panel as the primary surface. + +### v1.1 — make destructive reversible, then narrow the gate + +4. Soft-by-default destructive ops + the §5 tiered confirmation + auto-snapshot escape hatch; only then let destructive mutations auto-commit. **Order is load-bearing: never auto-commit a destructive mutation before its revert is lossless.** +5. `sys_package_version` named restore points (manual checkpoint button; pre-destructive-revert auto-cut); quota on commits/snapshots via the ADR-0045 §6 entitlement pattern. + +### v2+ + +6. Per-artifact cherry-pick revert within a commit (power-user; referential-integrity-checked). +7. Commit **diff API** (deep-diff of two commits' bodies — both are JSON; library-grade). +8. Retention/GC of ancient commits; squash on marketplace release. + +## Resolved questions (decided 2026-06-24) + +1. **Revert granularity** → **whole-commit only in v1**, not per-artifact. "一层一层回退" is commit-by-commit; a commit is a referentially consistent set, so whole-commit revert preserves integrity by construction. Per-artifact cherry-pick (which can leave dangling references) is deferred to v2 (§Phase-6). +2. **Reverting an additive commit that holds real user data** → **allowed, tiered** (§Decision-5): silent when only sample data; typed-confirmation + auto-snapshot escape hatch when user-entered rows exist. Reversibility-by-recovery, not safety-by-prohibition — hard-blocking ("export first") is paternalistic and contradicts the friction-removal goal. +3. **Per-turn full-bundle snapshot** → **rejected** (Option B); `sys_metadata_history` is the commit substrate, `sys_package_version` is reserved for named restore points, cut in v1.1, not per turn. +4. **Does the draft workspace go away?** → **No, but it is demoted.** ADR-0027's draft + `?preview=draft` diff review stays as a *review affordance* (governed orgs, optional preview); it is no longer the *safety mechanism* for the default path. Revertibility is. diff --git a/packages/metadata-core/src/objects/index.ts b/packages/metadata-core/src/objects/index.ts index 7720d94c59..dc9a1fe42d 100644 --- a/packages/metadata-core/src/objects/index.ts +++ b/packages/metadata-core/src/objects/index.ts @@ -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'; diff --git a/packages/metadata-core/src/objects/sys-metadata-commit.object.ts b/packages/metadata-core/src/objects/sys-metadata-commit.object.ts new file mode 100644 index 0000000000..10e3f4b806 --- /dev/null +++ b/packages/metadata-core/src/objects/sys-metadata-commit.object.ts @@ -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, + }, +}); diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index c147250789..6c4c88a91d 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -7,6 +7,7 @@ import { StorageNameMapping } from '@objectstack/spec/system'; import { SysMetadataObject, SysMetadataHistoryObject, + SysMetadataCommitObject, SysMetadataAuditObject, SysViewDefinitionObject, } from '@objectstack/metadata-core'; @@ -196,6 +197,7 @@ export class ObjectQLPlugin implements Plugin { objects: [ SysMetadataObject, SysMetadataHistoryObject, + SysMetadataCommitObject, SysMetadataAuditObject, SysViewDefinitionObject, ], diff --git a/packages/objectql/src/protocol-commit-history.test.ts b/packages/objectql/src/protocol-commit-history.test.ts new file mode 100644 index 0000000000..de9003ee4e --- /dev/null +++ b/packages/objectql/src/protocol-commit-history.test.ts @@ -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 & { 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 }); + }); +}); diff --git a/packages/objectql/src/protocol.ts b/packages/objectql/src/protocol.ts index f7a981b4e7..4ec6f542cd 100644 --- a/packages/objectql/src/protocol.ts +++ b/packages/objectql/src/protocol.ts @@ -4100,6 +4100,10 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { packageId: string; organizationId?: string; actor?: string; + /** ADR-0067 — commit message (for AI turns: the user's instruction). */ + message?: string; + /** ADR-0067 — AI model that authored the turn (absent for human/CLI). */ + aiModel?: string; }): Promise<{ success: boolean; publishedCount: number; @@ -4117,6 +4121,8 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { * health surfaces; probes never fail the publish itself. */ probes?: import('./build-probes.js').BuildProbeReport; + /** ADR-0067 — id of the commit this publish recorded (absent if nothing published). */ + commitId?: string; }> { await this.ensureOverlayIndex(); const orgId = request.organizationId ?? null; @@ -4136,6 +4142,29 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { ]; const seedBodies: unknown[] = []; + // ADR-0067 — capture each artifact's PRE-publish state so this turn can + // be recorded as ONE revertible commit. existedBefore=false → the commit + // creates it (revert = soft-remove); true → it edits an existing artifact + // (revert = restoreVersion(prevVersion)). Best-effort: a capture failure + // just omits that item from the revert plan, never blocks the publish. + const commitItems: Array<{ type: string; name: string; existedBefore: boolean; prevVersion: number | null }> = []; + for (const d of ordered) { + try { + const activeRow = (await this.engine.findOne('sys_metadata', { + where: { organization_id: orgId, type: d.type, name: d.name, state: 'active' }, + })) as { version?: number } | null; + commitItems.push({ + type: d.type, + name: d.name, + existedBefore: !!activeRow, + prevVersion: activeRow && typeof activeRow.version === 'number' ? activeRow.version : null, + }); + } catch { + commitItems.push({ type: d.type, name: d.name, existedBefore: false, prevVersion: null }); + } + } + const publishedSeqs: number[] = []; + for (const d of ordered) { try { if (d.type === 'seed') { @@ -4155,6 +4184,7 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { _skipSeedApply: true, }); published.push({ type: d.type, name: d.name, version: r.version }); + if (typeof r.seq === 'number') publishedSeqs.push(r.seq); } catch (e: any) { failed.push({ type: d.type, @@ -4196,6 +4226,26 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { } } + // ADR-0067 — record this turn as ONE commit (best-effort; never fails + // the publish). Only artifacts that actually published are in the revert + // plan, so a partial publish reverts exactly what landed. + let commit: { commitId: string } | null = null; + if (published.length > 0) { + const publishedKeys = new Set(published.map((p) => `${p.type}/${p.name}`)); + commit = await this.recordPackageCommit({ + orgId, + packageId: request.packageId, + operation: 'apply', + ...(request.message ? { message: request.message } : {}), + ...(request.actor ? { actor: request.actor } : {}), + ...(request.aiModel ? { aiModel: request.aiModel } : {}), + items: commitItems.filter((it) => publishedKeys.has(`${it.type}/${it.name}`)), + ...(publishedSeqs.length + ? { eventSeqStart: Math.min(...publishedSeqs), eventSeqEnd: Math.max(...publishedSeqs) } + : {}), + }); + } + return { success: failed.length === 0 && published.length > 0, publishedCount: published.length, @@ -4204,6 +4254,7 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { failed, ...(seedApplied ? { seedApplied } : {}), ...(probes ? { probes } : {}), + ...(commit ? { commitId: commit.commitId } : {}), }; } @@ -4333,6 +4384,266 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { }; } + // ───────────────────────────────────────────────────────────────────── + // ADR-0067 — package-scoped commit history & rollback + // ───────────────────────────────────────────────────────────────────── + + /** + * 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. + */ + private async recordPackageCommit(args: { + orgId: string | null; + packageId: string; + operation: 'apply' | 'revert'; + message?: string; + actor?: string; + aiModel?: string; + parentCommitId?: string; + items: Array<{ type: string; name: string; existedBefore: boolean; prevVersion: number | null }>; + eventSeqStart?: number; + eventSeqEnd?: number; + }): Promise<{ commitId: string } | null> { + try { + 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', { + id: commitId, + package_id: args.packageId, + operation: args.operation, + ...(args.message ? { message: args.message } : {}), + ...(args.actor ? { actor: args.actor } : {}), + ...(args.aiModel ? { ai_model: args.aiModel } : {}), + ...(args.parentCommitId ? { parent_commit_id: args.parentCommitId } : {}), + ...(args.eventSeqStart !== undefined ? { event_seq_start: args.eventSeqStart } : {}), + ...(args.eventSeqEnd !== undefined ? { event_seq_end: args.eventSeqEnd } : {}), + items: JSON.stringify(args.items), + item_count: args.items.length, + organization_id: args.orgId, + 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. + return null; + } + } + + private parseCommitItems( + raw: unknown, + ): Array<{ type: string; name: string; existedBefore: boolean; prevVersion: number | null }> { + if (Array.isArray(raw)) return raw as Array<{ type: string; name: string; existedBefore: boolean; prevVersion: number | null }>; + if (typeof raw === 'string') { + try { + const p = JSON.parse(raw); + return Array.isArray(p) ? p : []; + } catch { + return []; + } + } + return []; + } + + /** + * List the commit timeline for a package, newest-first (ADR-0067). Returns + * [] if the commit store is unavailable. + */ + async listCommits(request: { + packageId: string; + organizationId?: string; + limit?: number; + }): Promise; + createdAt?: string; + }>> { + try { + const where: Record = { package_id: request.packageId }; + if (request.organizationId) where.organization_id = request.organizationId; + const rows = (await this.engine.find('sys_metadata_commit', { + where, + ...(request.limit ? { limit: request.limit } : {}), + })) as any[]; + const mapped = rows.map((r) => ({ + id: r.id, + operation: (r.operation ?? 'apply') as 'apply' | 'revert', + ...(r.message ? { message: r.message } : {}), + ...(r.actor ? { actor: r.actor } : {}), + ...(r.ai_model ? { aiModel: r.ai_model } : {}), + ...(r.parent_commit_id ? { parentCommitId: r.parent_commit_id } : {}), + itemCount: typeof r.item_count === 'number' ? r.item_count : 0, + items: this.parseCommitItems(r.items), + ...(r.created_at ? { createdAt: r.created_at } : {}), + })); + // Newest-first; tolerate drivers that don't order by returning + // insertion order, then sort by the ISO timestamp. + mapped.sort((a, b) => String(b.createdAt ?? '').localeCompare(String(a.createdAt ?? ''))); + return mapped; + } catch { + return []; + } + } + + /** + * Revert a single commit (ADR-0067): undo exactly the artifacts it touched. + * A created-by-this-commit artifact is soft-removed (metadata row deleted; + * the data table is NOT dropped — recoverable, per ADR-0067 §5); a modified + * artifact is restored to its pre-commit `prevVersion`. The revert is itself + * recorded as a NEW commit (operation='revert'), so history stays + * append-only and the revert is itself revertible. + */ + async revertCommit(request: { + commitId: string; + organizationId?: string; + actor?: string; + }): Promise<{ + success: boolean; + revertedCount: number; + failedCount: number; + reverted: Array<{ type: string; name: string; action: 'removed' | 'restored' }>; + failed: Array<{ type: string; name: string; error: string; code?: string }>; + revertCommitId?: string; + }> { + await this.ensureOverlayIndex(); + const orgId = request.organizationId ?? null; + const where: Record = { id: request.commitId }; + if (request.organizationId) where.organization_id = request.organizationId; + const row = (await this.engine.findOne('sys_metadata_commit', { where })) as any; + if (!row) { + const err: any = new Error(`[commit_not_found] No commit '${request.commitId}'.`); + err.code = 'commit_not_found'; + err.status = 404; + throw err; + } + const items = this.parseCommitItems(row.items); + const repo = this.getOverlayRepo(orgId); + const actor = request.actor ?? 'system'; + const reverted: Array<{ type: string; name: string; action: 'removed' | 'restored' }> = []; + const failed: Array<{ type: string; name: string; error: string; code?: string }> = []; + + // Reverse apply order so artifacts that depend on others (e.g. a view on + // a new object) are removed before the thing they reference. + for (const it of [...items].reverse()) { + const ref = { type: it.type, name: it.name, org: orgId ?? 'env' } as unknown as Parameters[0]; + try { + const current = await repo.get(ref, { state: 'active' }); + if (!it.existedBefore) { + // Created by this commit → soft-remove (metadata only; table stays). + if (current) { + await repo.delete(ref, { + parentVersion: current.hash, + actor, + source: 'protocol.revertCommit', + intent: 'override-artifact', + state: 'active', + }); + } + reverted.push({ type: it.type, name: it.name, action: 'removed' }); + } else if (it.prevVersion !== null && it.prevVersion !== undefined) { + // Edited an existing artifact → restore the pre-commit body. + await repo.restoreVersion(ref, it.prevVersion, { + actor, + source: 'protocol.revertCommit', + message: `revert commit ${request.commitId}`, + }); + reverted.push({ type: it.type, name: it.name, action: 'restored' }); + } + } catch (e: any) { + failed.push({ + type: it.type, + name: it.name, + error: e?.message ?? 'revert failed', + ...(e?.code ? { code: e.code } : {}), + }); + } + } + + // Record the revert as its own commit (append-only history). + const revertCommit = await this.recordPackageCommit({ + orgId, + packageId: row.package_id, + operation: 'revert', + message: `Revert: ${row.message ?? request.commitId}`, + ...(request.actor ? { actor: request.actor } : {}), + parentCommitId: request.commitId, + items: reverted.map((r) => ({ + type: r.type, + name: r.name, + existedBefore: r.action === 'restored', + prevVersion: null, + })), + }); + + return { + success: failed.length === 0 && reverted.length > 0, + revertedCount: reverted.length, + failedCount: failed.length, + reverted, + failed, + ...(revertCommit ? { revertCommitId: revertCommit.commitId } : {}), + }; + } + + /** + * Roll a package back THROUGH every `apply` commit newer than `commitId` + * (newest first), leaving the package as it was at that commit. Each step is + * an individual `revertCommit`, so the whole rollback is itself audited. + */ + async rollbackToPackageCommit(request: { + commitId: string; + organizationId?: string; + actor?: string; + }): Promise<{ + success: boolean; + revertedCommits: string[]; + failed: Array<{ commitId: string; error: string }>; + }> { + const where: Record = { id: request.commitId }; + if (request.organizationId) where.organization_id = request.organizationId; + const target = (await this.engine.findOne('sys_metadata_commit', { where })) as any; + if (!target) { + const err: any = new Error(`[commit_not_found] No commit '${request.commitId}'.`); + err.code = 'commit_not_found'; + err.status = 404; + throw err; + } + const all = await this.listCommits({ + packageId: target.package_id, + ...(request.organizationId ? { organizationId: request.organizationId } : {}), + }); + // listCommits is newest-first; revert every `apply` commit strictly newer + // than the target (by created_at). Revert commits are skipped (their + // effect is already captured by re-reverting the apply they undid). + const targetCreatedAt = String(target.created_at ?? ''); + const toRevert = all.filter( + (c) => String(c.createdAt ?? '') > targetCreatedAt && c.operation === 'apply', + ); + const revertedCommits: string[] = []; + const failed: Array<{ commitId: string; error: string }> = []; + for (const c of toRevert) { + try { + await this.revertCommit({ + commitId: c.id, + ...(request.organizationId ? { organizationId: request.organizationId } : {}), + ...(request.actor ? { actor: request.actor } : {}), + }); + revertedCommits.push(c.id); + } catch (e: any) { + failed.push({ commitId: c.id, error: e?.message ?? 'revert failed' }); + } + } + return { success: failed.length === 0, revertedCommits, failed }; + } + /** * Restore the body recorded at history `toVersion` as the new * live row. Writes a history event with `op='revert'`. 404 diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index 0659868aa9..e9b4693850 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -983,6 +983,79 @@ describe('HttpDispatcher', () => { expect(result.response?.status).toBe(501); }); + // ── ADR-0067: commit history & rollback routes ────────────────── + it('GET /packages/:id/commits routes to protocol.listCommits', async () => { + const listCommits = vi.fn().mockResolvedValue([ + { id: 'cmt_2', operation: 'apply', itemCount: 1, items: [], createdAt: '2026-06-24T00:00:02.000Z' }, + { id: 'cmt_1', operation: 'apply', itemCount: 2, items: [], createdAt: '2026-06-24T00:00:01.000Z' }, + ]); + (kernel as any).getService = vi.fn().mockImplementation((name: string) => { + if (name === 'protocol') return Promise.resolve({ listCommits }); + if (name === 'objectql') return Promise.resolve({ registry: { getAllPackages: vi.fn().mockReturnValue([]) } }); + return null; + }); + + const result = await dispatcher.handlePackages('/app.edu/commits', 'GET', {}, {}, { request: {} }); + + expect(result.handled).toBe(true); + expect(result.response?.status).toBe(200); + expect(listCommits).toHaveBeenCalledWith(expect.objectContaining({ packageId: 'app.edu' })); + expect((result.response as any)?.body?.data?.commits).toHaveLength(2); + }); + + it('POST /packages/:id/commits/:commitId/revert routes to protocol.revertCommit', async () => { + const revertCommit = vi.fn().mockResolvedValue({ success: true, revertedCount: 1, failedCount: 0, reverted: [], failed: [] }); + (kernel as any).getService = vi.fn().mockImplementation((name: string) => { + if (name === 'protocol') return Promise.resolve({ revertCommit }); + if (name === 'objectql') return Promise.resolve({ registry: { getAllPackages: vi.fn().mockReturnValue([]) } }); + return null; + }); + + const result = await dispatcher.handlePackages('/app.edu/commits/cmt_1/revert', 'POST', { actor: 'ai:claude' }, {}, { request: {} }); + + expect(result.handled).toBe(true); + expect(result.response?.status).toBe(200); + expect(revertCommit).toHaveBeenCalledWith(expect.objectContaining({ commitId: 'cmt_1', actor: 'ai:claude' })); + }); + + it('POST /packages/:id/rollback routes to protocol.rollbackToPackageCommit', async () => { + const rollbackToPackageCommit = vi.fn().mockResolvedValue({ success: true, revertedCommits: ['c2', 'c3'], failed: [] }); + (kernel as any).getService = vi.fn().mockImplementation((name: string) => { + if (name === 'protocol') return Promise.resolve({ rollbackToPackageCommit }); + if (name === 'objectql') return Promise.resolve({ registry: { getAllPackages: vi.fn().mockReturnValue([]) } }); + return null; + }); + + const result = await dispatcher.handlePackages('/app.edu/rollback', 'POST', { commitId: 'c1' }, {}, { request: {} }); + + expect(result.handled).toBe(true); + expect(result.response?.status).toBe(200); + expect(rollbackToPackageCommit).toHaveBeenCalledWith(expect.objectContaining({ commitId: 'c1' })); + }); + + it('POST /packages/:id/rollback returns 400 without a commitId', async () => { + (kernel as any).getService = vi.fn().mockImplementation((name: string) => { + if (name === 'protocol') return Promise.resolve({ rollbackToPackageCommit: vi.fn() }); + if (name === 'objectql') return Promise.resolve({ registry: { getAllPackages: vi.fn().mockReturnValue([]) } }); + return null; + }); + + const result = await dispatcher.handlePackages('/app.edu/rollback', 'POST', {}, {}, { request: {} }); + expect(result.handled).toBe(true); + expect(result.response?.status).toBe(400); + }); + + it('GET /packages/:id/commits returns 501 when protocol lacks listCommits', async () => { + (kernel as any).getService = vi.fn().mockImplementation((name: string) => { + if (name === 'protocol') return Promise.resolve({}); + if (name === 'objectql') return Promise.resolve({ registry: { getAllPackages: vi.fn().mockReturnValue([]) } }); + return null; + }); + const result = await dispatcher.handlePackages('/app.edu/commits', 'GET', {}, {}, { request: {} }); + expect(result.handled).toBe(true); + expect(result.response?.status).toBe(501); + }); + // Integration: publishing a `seed` draft must LOAD its rows. This // exercises applyPublishedSeeds end-to-end against the REAL // SeedLoaderService (only the engine/metadata are mocked), so it pins diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 0d9212343a..d36de8c072 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -1824,6 +1824,72 @@ export class HttpDispatcher { return { handled: true, response: this.error('Draft discarding not supported', 501) }; } + // ── ADR-0067: package-scoped commit history & rollback ────────── + + // GET /packages/:id/commits → the commit timeline (newest-first). + if (parts.length === 2 && parts[1] === 'commits' && m === 'GET') { + const id = decodeURIComponent(parts[0]); + const protocol = await this.resolveService('protocol'); + if (protocol && typeof (protocol as any).listCommits === 'function') { + try { + const organizationId = await this.resolveActiveOrganizationId(_context); + const commits = await (protocol as any).listCommits({ + packageId: id, + ...(organizationId ? { organizationId } : {}), + }); + return { handled: true, response: this.success({ commits }) }; + } catch (e: any) { + return { handled: true, response: this.error(e.message, e.statusCode || 500) }; + } + } + return { handled: true, response: this.error('Commit history not supported', 501) }; + } + + // POST /packages/:id/commits/:commitId/revert → revert ONE commit + // (ADR-0067). Created artifacts are soft-removed, edited ones are + // restored to their pre-commit version; the revert is itself a commit. + if (parts.length === 4 && parts[1] === 'commits' && parts[3] === 'revert' && m === 'POST') { + const commitId = decodeURIComponent(parts[2]); + const protocol = await this.resolveService('protocol'); + if (protocol && typeof (protocol as any).revertCommit === 'function') { + try { + const organizationId = await this.resolveActiveOrganizationId(_context); + const result = await (protocol as any).revertCommit({ + commitId, + ...(organizationId ? { organizationId } : {}), + ...(body?.actor ? { actor: body.actor } : {}), + }); + return { handled: true, response: this.success(result) }; + } catch (e: any) { + return { handled: true, response: this.error(e.message, e.statusCode || 500) }; + } + } + return { handled: true, response: this.error('Commit revert not supported', 501) }; + } + + // POST /packages/:id/rollback body { commitId } → roll the package + // back THROUGH every commit newer than `commitId` (ADR-0067). + if (parts.length === 2 && parts[1] === 'rollback' && m === 'POST') { + const protocol = await this.resolveService('protocol'); + if (protocol && typeof (protocol as any).rollbackToPackageCommit === 'function') { + if (!body?.commitId) { + return { handled: true, response: this.error('Body { commitId } is required', 400) }; + } + try { + const organizationId = await this.resolveActiveOrganizationId(_context); + const result = await (protocol as any).rollbackToPackageCommit({ + commitId: String(body.commitId), + ...(organizationId ? { organizationId } : {}), + ...(body?.actor ? { actor: body.actor } : {}), + }); + return { handled: true, response: this.success(result) }; + } catch (e: any) { + return { handled: true, response: this.error(e.message, e.statusCode || 500) }; + } + } + return { handled: true, response: this.error('Commit rollback not supported', 501) }; + } + // POST /packages/:id/revert → revert package to last published state if (parts.length === 2 && parts[1] === 'revert' && m === 'POST') { const id = decodeURIComponent(parts[0]);