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
150 lines (149 loc) • 6.36 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/.
import { isReadOnlyFilesystemError } from "../../core/system-error.js";
import { error } from "../../core/warn.js";
/**
* Convert a raw `EventRow` from the database to the public `EventEnvelope`
* interface used throughout the events module.
*/
export function eventRowToEnvelope(row) {
let metadata;
try {
const parsed = JSON.parse(row.metadata_json);
// Only attach metadata when the JSON blob is non-empty so downstream
// consumers that check `envelope.metadata !== undefined` keep working.
if (Object.keys(parsed).length > 0) {
metadata = parsed;
}
}
catch {
// Corrupt JSON in the DB — treat as no metadata.
}
return {
schemaVersion: 1,
id: row.id,
ts: row.ts,
eventType: row.event_type,
...(row.ref !== null ? { ref: row.ref } : {}),
...(metadata !== undefined ? { metadata } : {}),
};
}
/**
* Insert a single event. Returns the auto-assigned monotonic rowid, which
* callers can store as a "sinceId" cursor for future `readEventsSince` calls.
*
* Best-effort: mirrors the behaviour of the old `appendEvent` — errors are
* caught and logged to stderr rather than propagated so observability never
* breaks mutation.
*/
export function insertEvent(db, input) {
try {
return insertEventStrict(db, input);
}
catch (err) {
if (isReadOnlyFilesystemError(err))
return undefined;
const message = err instanceof Error ? err.message : String(err);
error(`akm: state.db event insert failed (${message})`);
return undefined;
}
}
/** Insert an event without best-effort error swallowing for transaction-coupled mutations. */
export function insertEventStrict(db, input) {
const result = db
.prepare(`INSERT INTO events (event_type, ts, ref, metadata_json)
VALUES (?, ?, ?, ?)
RETURNING id`)
.get(input.eventType, input.ts, input.ref ?? null, JSON.stringify(input.metadata ?? {}));
if (!result)
throw new Error(`Failed to persist ${input.eventType} event.`);
return result.id;
}
/** Strict idempotent event insert for durable mutation journals. */
export function insertEventOnce(db, input) {
const rows = db
.prepare("SELECT id, metadata_json FROM events WHERE event_type = ? AND ref IS ? ORDER BY id")
.all(input.eventType, input.ref ?? null);
for (const row of rows) {
try {
const metadata = JSON.parse(row.metadata_json);
if (metadata[input.idempotencyMetadataKey ?? "proposalTransactionId"] === input.idempotencyKey)
return row.id;
}
catch {
// Corrupt unrelated event metadata cannot satisfy this idempotency key.
}
}
const result = db
.prepare(`INSERT INTO events (event_type, ts, ref, metadata_json)
VALUES (?, ?, ?, ?)
RETURNING id`)
.get(input.eventType, input.ts, input.ref ?? null, JSON.stringify(input.metadata));
if (!result)
throw new Error(`Failed to persist ${input.eventType} event.`);
return result.id;
}
/**
* Read events from the database matching the filter. Returns events in
* ascending id order so consumers can process them in emission order —
* true even when `options.limit` selects the most-recent page (see
* {@link ReadStateEventsOptions.limit}).
*
* The returned `nextId` is the maximum id seen (or `sinceId` when no rows
* match), suitable as the next `sinceId` cursor value. This is unaffected by
* `limit`: it is always the id of the last row actually returned, which —
* because a `limit` page is the MOST RECENT matching rows — is the same
* value an unbounded read of the same filter would have produced.
*/
export function readStateEvents(db, options = {}) {
const conditions = [];
const params = [];
if (options.sinceId !== undefined && options.sinceId > 0) {
conditions.push("id > ?");
params.push(options.sinceId);
}
if (options.since) {
conditions.push("ts >= ?");
params.push(options.since);
}
if (options.type) {
conditions.push("event_type = ?");
params.push(options.type);
}
if (options.ref) {
conditions.push("ref = ?");
params.push(options.ref);
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const hasLimit = options.limit !== undefined && options.limit > 0;
const orderAndLimit = hasLimit ? "ORDER BY id DESC LIMIT ?" : "ORDER BY id ASC";
const queryParams = hasLimit ? [...params, options.limit] : params;
const rows = db
.prepare(`SELECT id, event_type, ts, ref, metadata_json FROM events ${where} ${orderAndLimit}`)
.all(...queryParams);
// A DESC-ordered LIMIT page comes back newest-first; reverse it so the
// returned array is always ascending by id, matching the unbounded path.
const events = (hasLimit ? rows.slice().reverse() : rows).map(eventRowToEnvelope);
const nextId = events.length > 0 ? events[events.length - 1].id : (options.sinceId ?? 0);
return { events, nextId };
}
/**
* Delete events older than `retentionDays` (default: 90). Safe to call from
* a maintenance cron; uses a single DELETE with an index-covered ts predicate.
*
* Returns the number of rows actually deleted so callers can emit an
* `events_purged` observability event. A non-positive or non-finite
* `retentionDays` is treated as "disabled" and returns 0 without scanning.
*/
export function purgeOldEvents(db, retentionDays = 90) {
if (!Number.isFinite(retentionDays) || retentionDays <= 0)
return 0;
const cutoff = new Date(Date.now() - retentionDays * 86_400_000).toISOString();
const result = db.prepare("DELETE FROM events WHERE ts < ?").run(cutoff);
// bun:sqlite's run() returns { changes, lastInsertRowid }. `changes` may be
// a number or bigint depending on the underlying lib; coerce to number for
// the metadata payload.
const changes = result.changes ?? 0;
return typeof changes === "bigint" ? Number(changes) : changes;
}