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
200 lines (199 loc) • 9.42 kB
JavaScript
// 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/.
/**
* `index.db` connection lifecycle for the storage layer.
*
* Opens/closes the index database, running `ensureSchema` on the managed
* (writable) open path. This module lives BELOW the
* indexer, so the storage loan helpers (`index-db.ts`, `registry-index-cache-repository.ts`)
* import their opener from a sibling here instead of reaching up into the
* indexer — inverting the old storage→indexer arrow.
*/
import fs from "node:fs";
import { ConfigError } from "../../core/errors.js";
import { classifyPathAccess, describeInaccessiblePath } from "../../core/path-access.js";
import { getDbPath } from "../../core/paths.js";
import { warn, warnOnce } from "../../core/warn.js";
import { openDatabase } from "../database.js";
import { openManagedDatabase } from "../managed-db.js";
import { SQLITE_BUSY_TIMEOUT_MS } from "../sqlite-pragmas.js";
import { openSqliteReadSnapshot, SqliteReadSnapshotUnavailableError } from "../sqlite-read-snapshot.js";
import { CANONICAL_INDEX_DB_VERSION } from "./index-entry-schema.js";
import { ensureSchema, newerIndexLayoutError } from "./index-schema.js";
/**
* Whether `error` is SQLite reporting on-disk corruption (`SQLITE_CORRUPT`,
* "database disk image is malformed") rather than a permission, lock, or
* schema problem. Matched on both `code` (bun:sqlite, better-sqlite3) and
* message text, since driver error shapes are not perfectly uniform.
*/
function isCorruptionError(error) {
const code = error?.code;
if (code === "SQLITE_CORRUPT")
return true;
const message = error instanceof Error ? error.message : String(error);
return message.includes("database disk image is malformed") || message.includes("SQLITE_CORRUPT");
}
export function openIndexDatabase(dbPath, options) {
const resolvedPath = dbPath ?? getDbPath();
const spec = {
path: resolvedPath,
init: (db) => {
// Source update uses this narrow lifecycle seam to ATTACH state.db and
// open its coordinator-owned outer transaction before ensureSchema or
// any indexer write can mutate the live generation.
options?.beforeSchema?.(db);
ensureSchema(db);
},
};
try {
return openManagedDatabase(spec);
}
catch (error) {
// index.db is a derived cache, fully regenerable from the stash on disk
// (see src/core/state-db.ts's "Why a separate database from index.db"
// note) — so real on-disk corruption is recovered by deleting the file
// and rebuilding, not by surfacing a raw SQLITE_CORRUPT to the caller or
// quietly falling through to an unreadable index (#865). This is the ONE
// from-scratch rebuild: an older layout is migrated in place by
// ensureSchema, never dropped.
if (!isCorruptionError(error))
throw error;
warn(`Index database is corrupt at ${resolvedPath} — rebuilding.`);
for (const suffix of ["", "-wal", "-shm"]) {
try {
fs.rmSync(`${resolvedPath}${suffix}`, { force: true });
}
catch {
// Best-effort cleanup; the retried open below still fails loudly if
// the file could not actually be removed.
}
}
return openManagedDatabase(spec);
}
}
export function openExistingDatabase(dbPath) {
// Existing-DB callers do not mutate schema or embedding metadata on open;
// they serve an older layout as-is and refuse a newer one (see checkIndexLayout).
//
// "Existing" is load-bearing: a missing file throws instead of being
// created. Create-on-open used to leave a schema-less index.db behind (a
// fire-and-forget telemetry read was enough), which every later opener then
// saw as an existing-but-broken index ("no such table: entries") — the
// curate→proposal file-order failure pinned by
// tests/storage/open-existing-database-no-create.test.ts. `create: false`
// below is the race-free backstop for this pre-check.
const resolvedPath = dbPath ?? getDbPath();
assertIndexPathReadable(resolvedPath);
if (classifyPathAccess(resolvedPath).access === "absent") {
throw new Error(`Index database not found at ${resolvedPath}. Run 'akm index' to build it.`);
}
const db = openManagedDatabase({ path: resolvedPath, create: false });
try {
checkIndexLayout(db, resolvedPath);
return db;
}
catch (error) {
db.close();
throw error;
}
}
/**
* A reader serves an older layout as-is (the FTS readers understand both
* layouts, and a missing table degrades at the caller — keyword-only search,
* an inline rebuild, or a "run akm index" notice) and names it once per
* process; the next writable open migrates it in place. A newer layout is
* refused, naming the upgrade ({@link newerIndexLayoutError}).
*/
function checkIndexLayout(db, resolvedPath) {
let stored;
try {
const row = db.prepare("SELECT value FROM index_meta WHERE key = 'version'").get();
if (!row)
return;
stored = Number(row.value);
}
catch {
return;
}
if (!Number.isFinite(stored) || stored === CANONICAL_INDEX_DB_VERSION)
return;
if (stored > CANONICAL_INDEX_DB_VERSION)
throw newerIndexLayoutError(stored, resolvedPath);
warnOnce(`index-db-layout:${resolvedPath}`, `Index database at ${resolvedPath} uses an older layout (${stored}; this akm writes ${CANONICAL_INDEX_DB_VERSION}). ` +
"Serving it as-is; the next 'akm index' migrates it in place.");
}
/**
* Refuse to treat an UNREADABLE index as a missing one (#791).
*
* `fs.existsSync()` — which every one of these gates used to call — returns
* `false` for `EACCES` exactly as for `ENOENT`, so an index this process cannot
* read looked identical to one that had never been built. Callers then took
* their "no index yet" branch: `search`/`curate` returned no hits at exit 0 and
* told the user to run `akm index`, which would not have helped and which they
* may not have permission to do either.
*
* A `ConfigError` here exits 78 through the standard `{ok:false, error, code}`
* envelope, so both a human and a machine caller can tell "nothing indexed"
* from "I cannot see the index".
*/
export function assertIndexPathReadable(resolvedPath) {
const { access, code } = classifyPathAccess(resolvedPath);
if (access !== "inaccessible")
return;
throw new ConfigError(`Index database exists but is not readable: ${describeInaccessiblePath(resolvedPath, code)}.`, "DATA_DIR_UNREADABLE");
}
function openPlainReadonly(resolvedPath) {
return openDatabase(resolvedPath, { readonly: true, create: false });
}
function openIsolatedSnapshotOrFallBack(resolvedPath) {
try {
return openSqliteReadSnapshot(resolvedPath);
}
catch (error) {
if (!(error instanceof SqliteReadSnapshotUnavailableError))
throw error;
warnOnce(`index-read-snapshot-unavailable:${resolvedPath}`, `Could not take a non-mutating snapshot of ${resolvedPath} (${error.message}) — falling back to a plain ` +
"read-only open of the index database.");
return openPlainReadonly(resolvedPath);
}
}
/**
* Open an existing index for queries without changing the source database or
* running schema initialization. The default path attaches read-only to the
* source. `isolatedSnapshot` instead opens a disposable main/WAL copy so even
* SQLite's read-lock bookkeeping cannot touch the source SHM file.
*/
export function openReadonlyExistingDatabase(dbPath, options) {
const resolvedPath = dbPath ?? getDbPath();
// `undefined` means "no index" — reserve it for a genuinely absent one, and
// let an unreadable index raise instead of masquerading as absent (#791).
assertIndexPathReadable(resolvedPath);
if (classifyPathAccess(resolvedPath).access === "absent")
return undefined;
const db = options?.isolatedSnapshot ? openIsolatedSnapshotOrFallBack(resolvedPath) : openPlainReadonly(resolvedPath);
if (!db)
return undefined;
// This opener bypasses openManagedDatabase/applyStandardPragmas by design (no
// journal or schema work on a read-only handle), but that also left
// busy_timeout at SQLite's default of 0. In WAL that is harmless — readers
// never block — but in the DELETE/TRUNCATE modes the network-FS fallback and
// AKM_SQLITE_JOURNAL_MODE can select, a concurrent writer makes every read
// fail instantly with SQLITE_BUSY. busy_timeout is legal on a read-only
// connection, so apply just that one. `busyTimeoutMs` defaults to the
// shared 30s constant; a caller that must never sit behind another akm
// process's write lock for long (e.g. `akm info`) can pass a much shorter
// bound instead.
try {
db.exec(`PRAGMA busy_timeout = ${options?.busyTimeoutMs ?? SQLITE_BUSY_TIMEOUT_MS}`);
checkIndexLayout(db, resolvedPath);
return db;
}
catch (error) {
db.close();
throw error;
}
}
export function closeDatabase(db) {
db.close();
}