Files
lab-console-git-branching-1/scripts/lib/card_state_db.mjs
T

162 lines
4.6 KiB
JavaScript

import { mkdirSync } from 'node:fs';
import path from 'node:path';
import { DatabaseSync } from 'node:sqlite';
const STATE_SCHEMA = 'stem-card-backend-state.v1';
export class CardStateDatabase {
constructor(file) {
this.file = path.resolve(file);
mkdirSync(path.dirname(this.file), { recursive: true });
this.database = new DatabaseSync(this.file);
this.database.exec(`
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
CREATE TABLE IF NOT EXISTS card_state (
key TEXT PRIMARY KEY,
schema_name TEXT NOT NULL,
revision INTEGER NOT NULL,
updated_at TEXT NOT NULL,
value_json TEXT NOT NULL
) STRICT;
CREATE TABLE IF NOT EXISTS card_event (
id INTEGER PRIMARY KEY AUTOINCREMENT,
occurred_at TEXT NOT NULL,
event_type TEXT NOT NULL,
actor TEXT NOT NULL,
task_id TEXT,
block_id TEXT,
phase_id TEXT,
step_id TEXT,
snapshot_ref TEXT,
evidence_ref TEXT,
payload_json TEXT NOT NULL
) STRICT;
CREATE INDEX IF NOT EXISTS card_event_occurred_at
ON card_event(occurred_at, id);
`);
this.readStatement = this.database.prepare(`
SELECT value_json
FROM card_state
WHERE key = ?
`);
this.writeStatement = this.database.prepare(`
INSERT INTO card_state (key, schema_name, revision, updated_at, value_json)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(key) DO UPDATE SET
schema_name = excluded.schema_name,
revision = excluded.revision,
updated_at = excluded.updated_at,
value_json = excluded.value_json
`);
this.appendEventStatement = this.database.prepare(`
INSERT INTO card_event (
occurred_at, event_type, actor,
task_id, block_id, phase_id, step_id, snapshot_ref,
evidence_ref, payload_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING id
`);
this.listEventsStatement = this.database.prepare(`
SELECT
id, occurred_at, event_type, actor,
task_id, block_id, phase_id, step_id, snapshot_ref,
evidence_ref, payload_json
FROM card_event
WHERE id > ?
ORDER BY id ASC
LIMIT ?
`);
}
read(key) {
const row = this.readStatement.get(key);
if (!row) return null;
try {
return JSON.parse(row.value_json);
} catch {
return null;
}
}
write(key, value) {
const revision = Number.isSafeInteger(Number(value?.revision))
? Number(value.revision)
: 0;
const updatedAt = String(value?.updated_at ?? value?.selected_at ?? new Date().toISOString());
this.writeStatement.run(
key,
STATE_SCHEMA,
revision,
updatedAt,
JSON.stringify(value)
);
return value;
}
appendEvent({
eventType,
actor = 'system',
navigation = null,
evidenceRef = null,
payload = {},
occurredAt = new Date().toISOString()
}) {
const row = this.appendEventStatement.get(
String(occurredAt),
String(eventType),
String(actor),
navigation?.task?.id ?? null,
navigation?.block?.id ?? null,
navigation?.phase?.id ?? null,
navigation?.step?.id ?? null,
navigation?.snapshot_ref ?? null,
evidenceRef == null ? null : String(evidenceRef),
JSON.stringify(payload ?? {})
);
return {
id: Number(row.id),
occurred_at: String(occurredAt),
event_type: String(eventType),
actor: String(actor),
task_id: navigation?.task?.id ?? null,
block_id: navigation?.block?.id ?? null,
phase_id: navigation?.phase?.id ?? null,
step_id: navigation?.step?.id ?? null,
snapshot_ref: navigation?.snapshot_ref ?? null,
evidence_ref: evidenceRef == null ? null : String(evidenceRef),
payload: payload ?? {}
};
}
listEvents({ afterId = 0, limit = 200 } = {}) {
return this.listEventsStatement.all(Number(afterId), Number(limit)).map(row => {
let payload = {};
try {
payload = JSON.parse(row.payload_json);
} catch {
// Preserve the audit row even if an old payload cannot be decoded.
}
return {
id: Number(row.id),
occurred_at: row.occurred_at,
event_type: row.event_type,
actor: row.actor,
task_id: row.task_id,
block_id: row.block_id,
phase_id: row.phase_id,
step_id: row.step_id,
snapshot_ref: row.snapshot_ref,
evidence_ref: row.evidence_ref,
payload
};
});
}
close() {
this.database.close();
}
}
export { STATE_SCHEMA };