blob: 8f465f0eba1497d8a4a573118c33478ee8583805 [file]
import type { DatabaseSync } from 'node:sqlite';
import type { ExecutionLogCursor } from '@maka/core';
import { acquireOperationalStateDatabase, type OperationalStateDatabaseLease } from '@maka/storage';
import {
assertStorageRootLease,
runWithStorageRootLease,
type StorageRootLease,
} from '@maka/storage/root-authority';
import { chainWrite } from '@maka/storage/write-queue';
import type { TaskEvent } from './task-contracts.js';
import { taskRunLocator } from './task-run-identity.js';
import { projectTaskRun, type TaskRunProjection } from './task-run-projection.js';
export interface TaskRunReader {
listTaskRunIds(): Promise<string[]>;
readEventRecords(taskRunId: string): Promise<TaskEventLedgerEntry[]>;
readEvents(taskRunId: string): Promise<TaskEvent[]>;
project(taskRunId: string): Promise<TaskRunProjection>;
}
export interface TaskRunWriter extends TaskRunReader {
appendEvent(taskRunId: string, event: TaskEvent): Promise<void>;
}
export interface TaskEventLedgerEntry {
event: TaskEvent;
cursor: ExecutionLogCursor;
}
export function createInMemoryTaskRunStore(
initialEvents: readonly TaskEvent[] = [],
): TaskRunWriter {
return new InMemoryTaskRunStore(initialEvents);
}
export async function openHeadlessTaskRunReader(
lease: StorageRootLease<'headless', 'read'>,
): Promise<TaskRunReader> {
await assertStorageRootLease(lease, 'headless', 'read');
return taskRunReaderFacade(new SqliteTaskRunStore(lease.canonicalPath), (operation) =>
runWithStorageRootLease(lease, 'headless', 'read', operation),
);
}
export async function openHeadlessTaskRunWriter(
lease: StorageRootLease<'headless', 'write'>,
): Promise<TaskRunWriter> {
await assertStorageRootLease(lease, 'headless', 'write');
return taskRunWriterFacade(new SqliteTaskRunStore(lease.canonicalPath), (operation) =>
runWithStorageRootLease(lease, 'headless', 'write', operation),
);
}
type RunTaskRunOperation = <T>(operation: () => Promise<T>) => Promise<T>;
function taskRunReaderFacade(store: SqliteTaskRunStore, run: RunTaskRunOperation): TaskRunReader {
return Object.freeze(taskRunReaderMethods(store, run));
}
function taskRunReaderMethods(store: SqliteTaskRunStore, run: RunTaskRunOperation): TaskRunReader {
return {
listTaskRunIds: () => run(() => store.listTaskRunIds()),
readEventRecords: (taskRunId) => run(() => store.readEventRecords(taskRunId)),
readEvents: (taskRunId) => run(() => store.readEvents(taskRunId)),
project: (taskRunId) => run(() => store.project(taskRunId)),
};
}
function taskRunWriterFacade(store: SqliteTaskRunStore, run: RunTaskRunOperation): TaskRunWriter {
return Object.freeze({
...taskRunReaderMethods(store, run),
appendEvent: (taskRunId: string, event: TaskEvent) =>
run(() => store.appendEvent(taskRunId, event)),
});
}
class InMemoryTaskRunStore implements TaskRunWriter {
private readonly events = new Map<string, TaskEvent[]>();
private readonly queues = new Map<string, Promise<void>>();
constructor(initialEvents: readonly TaskEvent[]) {
for (const event of initialEvents) {
const events = this.events.get(event.taskRunId) ?? [];
events.push(event);
this.events.set(event.taskRunId, events);
}
}
async appendEvent(taskRunId: string, event: TaskEvent): Promise<void> {
assertTaskRunEventIdentity(taskRunId, event);
await chainWrite(this.queues, taskRunId, async () => {
const events = this.events.get(taskRunId) ?? [];
events.push(event);
this.events.set(taskRunId, events);
});
}
async listTaskRunIds(): Promise<string[]> {
return [...this.events.keys()].sort();
}
async readEvents(taskRunId: string): Promise<TaskEvent[]> {
return (await this.readEventRecords(taskRunId)).map((record) => record.event);
}
async readEventRecords(taskRunId: string): Promise<TaskEventLedgerEntry[]> {
return (this.events.get(taskRunId) ?? []).map((event, sequence) => ({
event,
cursor: taskEventCursor(taskRunId, sequence, event.id),
}));
}
async project(taskRunId: string): Promise<TaskRunProjection> {
return projectTaskRun(await this.readEvents(taskRunId), taskRunId);
}
}
class SqliteTaskRunStore implements TaskRunWriter {
constructor(private readonly storageRoot: string) {}
async appendEvent(taskRunId: string, event: TaskEvent): Promise<void> {
assertTaskRunEventIdentity(taskRunId, event);
this.withDatabase((lease) =>
lease.transaction('write', () => {
const row = lease.database
.prepare(`
SELECT COALESCE(MAX(sequence), -1) AS lastSequence
FROM headless_task_run_events
WHERE task_run_id = ?
`)
.get(taskRunId) as { lastSequence?: unknown };
if (
typeof row.lastSequence !== 'number' ||
!Number.isSafeInteger(row.lastSequence) ||
row.lastSequence < -1
) {
throw new Error(`Invalid TaskRun sequence for ${taskRunId}`);
}
lease.database
.prepare(`
INSERT INTO headless_task_run_events(
task_run_id, sequence, event_id, record_json
) VALUES (?, ?, ?, ?)
`)
.run(taskRunId, row.lastSequence + 1, event.id, JSON.stringify(event));
}),
);
}
async listTaskRunIds(): Promise<string[]> {
return this.withDatabase((lease) =>
(
lease.database
.prepare(`
SELECT DISTINCT task_run_id AS taskRunId
FROM headless_task_run_events
ORDER BY task_run_id
`)
.all() as Array<{ taskRunId: string }>
).map((row) => row.taskRunId),
);
}
async readEvents(taskRunId: string): Promise<TaskEvent[]> {
return (await this.readEventRecords(taskRunId)).map((record) => record.event);
}
async readEventRecords(taskRunId: string): Promise<TaskEventLedgerEntry[]> {
taskRunLocator(taskRunId);
return this.withDatabase((lease) =>
readTaskRunRows(lease.database, taskRunId).map((row) => {
const event = decodeTaskEvent(row.recordJson, taskRunId);
if (event.id !== row.eventId) {
throw new Error(`TaskRun ${taskRunId} event identity does not match SQLite metadata`);
}
return {
event,
cursor: taskEventCursor(taskRunId, row.sequence, event.id),
};
}),
);
}
async project(taskRunId: string): Promise<TaskRunProjection> {
return projectTaskRun(await this.readEvents(taskRunId), taskRunId);
}
private withDatabase<T>(operation: (lease: OperationalStateDatabaseLease) => T): T {
const lease = acquireOperationalStateDatabase(this.storageRoot);
try {
return operation(lease);
} finally {
lease.close();
}
}
}
interface TaskRunEventRow {
sequence: number;
eventId: string;
recordJson: string;
}
function readTaskRunRows(database: DatabaseSync, taskRunId: string): TaskRunEventRow[] {
return database
.prepare(`
SELECT sequence, event_id AS eventId, record_json AS recordJson
FROM headless_task_run_events
WHERE task_run_id = ?
ORDER BY sequence
`)
.all(taskRunId) as unknown as TaskRunEventRow[];
}
function decodeTaskEvent(recordJson: string, taskRunId: string): TaskEvent {
const event = JSON.parse(recordJson) as TaskEvent;
assertTaskRunEventIdentity(taskRunId, event);
return event;
}
function assertTaskRunEventIdentity(taskRunId: string, event: TaskEvent): void {
taskRunLocator(taskRunId);
if (event.taskRunId !== taskRunId) {
throw new Error(`taskRunId mismatch: append target ${taskRunId}, event ${event.taskRunId}`);
}
}
function taskEventCursor(taskRunId: string, sequence: number, eventId: string): ExecutionLogCursor {
return { ledger: 'task_event', streamId: taskRunId, sequence, eventId };
}