UNPKG

akm-cli

Version:

akm (Agent Knowledge Manager) — a portable, local-first capability library for AI agents. Discover, load, share, and improve reusable skills, scripts, workflows, and knowledge across any shell-capable coding agent, including Claude Code, OpenCode, and Cur

137 lines (136 loc) • 7.18 kB
// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. import { withImmediateTransaction } from "./sqlite-transaction.js"; /** * Reject a `MIGRATIONS` array containing a duplicate `id`. * * A compiled-in registry can't diverge at runtime, so this is a dev-time * invariant, not a per-open guard: each consumer (state.db, logs.db) calls it * once on its own array at module load, and `tests/storage/sqlite-migrations.test.ts` * pins the duplicate-detection behavior directly. {@link inspectMigrationLedger} * does NOT call this — re-scanning the same compiled-in array on every DB open * added no safety over the module-load check, only repeated O(n) cost. */ export function assertMigrationRegistry(migrations) { const seen = new Set(); for (const migration of migrations) { if (seen.has(migration.id)) throw new Error(`Migration registry contains duplicate ID ${migration.id}.`); seen.add(migration.id); } } export function migrationLedgerExists(db) { return !!db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'schema_migrations'").get(); } /** Inspect the database's applied IDs against the exact ordered registry prefix. */ function inspectLedgerAgainst(db, registryIds) { if (!migrationLedgerExists(db)) return { status: registryIds.length === 0 ? "current" : "old", migrationIds: [] }; const rows = db.prepare("SELECT id FROM schema_migrations ORDER BY rowid").all(); const migrationIds = rows.map((row) => row.id); for (const [index, row] of rows.entries()) { const expectedId = registryIds[index]; // Every id this binary knows matched in order and the ledger carries more: // the database was migrated by a newer akm. Nothing here is applicable — // this binary's whole registry is already applied — so this is version // skew, not divergence. if (!expectedId) { return { status: "newer", migrationIds, detail: `applied migration ID${rows.length - registryIds.length === 1 ? "" : "s"} ${migrationIds .slice(registryIds.length) .join(", ")} unknown to this akm`, }; } // A mismatch at a position this binary has a migration for is divergence, // whether or not the id is one this binary knows later: this binary's // migration at `index` was never applied, and something else was. if (row.id !== expectedId) { return { status: "inconsistent", migrationIds, detail: `migration ledger is not an exact ordered prefix at position ${index + 1} (found '${row.id}', expected '${expectedId}'). ` + `Applied, in order: [${migrationIds.join(", ")}]. This akm's expected order: [${registryIds.join(", ")}].`, }; } } return { status: rows.length === registryIds.length ? "current" : "old", migrationIds, }; } export function inspectMigrationLedger(db, migrations) { return inspectLedgerAgainst(db, migrations.map((migration) => migration.id)); } /** * Reject only a ledger this binary cannot reason about at all. * * A `newer` ledger — an exact ordered prefix of this binary's registry plus * migrations a later akm added — is NOT rejected. Two akm versions sharing one * data directory is a supported deployment (a bundled CLI alongside a newer * global install), and refusing the open bricked the older one for every * command while protecting nothing: its entire registry is already applied, so * it has no pending migration to run. Callers that want to tell an operator * about the skew read {@link MigrationLedgerState.status}. * * An `inconsistent` ledger is different: this binary has a migration that was * never applied and something else was applied in its place, so running the * pending set could conflict with schema it cannot see. That still refuses. */ export function assertMigrationLedger(db, migrations) { const state = inspectMigrationLedger(db, migrations); if (state.status === "inconsistent") { throw new Error(`Refusing a database whose migrations are not an exact ordered prefix: ${state.detail} ` + "Applying this binary's missing migration now could run it against a schema a later migration already " + "changed underneath it, which is a real risk of producing a wrong schema — not something akm can guess " + "its way out of safely. This usually means the database was migrated by an incompatible akm build or " + "fork, or schema_migrations was edited by hand. Restore this file from a backup taken before the " + "divergence, or — if there is no backup and the data is not needed — delete it and let akm rebuild it " + "from scratch (a derived index.db regenerates from your sources on the next 'akm index'; state.db loses " + "durable history such as improve/proposal state and must be treated as a last resort)."); } return state; } /** Create the migrations ledger table if it does not exist. */ export function ensureMigrationsTable(db) { db.exec(` CREATE TABLE IF NOT EXISTS schema_migrations ( id TEXT PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT (datetime('now')) ); `); } /** The registry entries not yet recorded in the ledger, in order. Throws on a divergent ledger. */ export function pendingMigrations(db, migrations) { return migrations.slice(assertMigrationLedger(db, migrations).migrationIds.length); } /** * Apply every pending migration in one `BEGIN IMMEDIATE` transaction. * * A database with nothing pending is only read, never write-locked. Otherwise * the write lock is taken up front — a second process bootstrapping the same * database WAITS for the first to commit instead of racing it — and the * pending set is re-read under that lock, so the process that lost the race * finds nothing left to do rather than re-running DDL. Each migration's ledger * row is inserted right after its SQL inside the same transaction: a failing * migration rolls back every migration this call applied, and their ledger * rows with them. Contention that outlasts every BEGIN retry surfaces as * `TransientError("STATE_DB_CONTENDED")` (`../sqlite-transaction`). Returns the * IDs this call applied, in order — empty when another process got there first. */ export function runMigrations(db, migrations) { if (pendingMigrations(db, migrations).length === 0) return []; return withImmediateTransaction(db, () => { ensureMigrationsTable(db); const applied = []; for (const migration of pendingMigrations(db, migrations)) { db.exec(migration.up); db.prepare("INSERT INTO schema_migrations (id) VALUES (?)").run(migration.id); applied.push(migration.id); } return applied; }); }