162 lines
4.6 KiB
JavaScript
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 };
|