feat(api): add fleet snapshots and durable event stream

This commit is contained in:
chick
2026-07-17 14:16:46 +08:00
parent 1cc4a995ee
commit b4ae28c8f0
12 changed files with 1777 additions and 1 deletions
@@ -0,0 +1,164 @@
import Database from 'better-sqlite3';
import { describe, expect, it, vi } from 'vitest';
import { EventJournal, formatServerSentEvent, type EventEnvelope } from './event-journal.js';
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
const at = '2026-07-17T12:34:56.789Z';
function event(kind: EventEnvelope['kind'], id = `${kind}-1`): EventEnvelope {
const common = {
kind,
id,
occurredAt: at,
requestId: 'request-1',
};
switch (kind) {
case 'instance':
return { ...common, kind, instanceId: 'instance-1' };
case 'job':
return { ...common, kind, jobId: 'job-1' };
case 'item':
return { ...common, kind, jobId: 'job-1', itemId: 'item-1' };
case 'attempt':
return { ...common, kind, jobId: 'job-1', attemptId: 'attempt-1' };
case 'audit':
return { ...common, kind, auditEventId: 'audit-event-1' };
}
}
function journal(database = new Database(':memory:')): EventJournal {
migrateDatabase(database);
return new EventJournal(database);
}
describe('EventJournal', () => {
it('requires its forward-migrated schema instead of creating it implicitly', () => {
const database = new Database(':memory:');
expect(() => new EventJournal(database)).toThrow(
/event_journal.*schema|schema.*event_journal/i,
);
expect(
database
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='event_journal'")
.get(),
).toBeUndefined();
});
it('durably appends the exact five envelope variants with stable integer sequence', () => {
const database = new Database(':memory:');
migrateDatabase(database);
const first = new EventJournal(database);
const envelopes = (['instance', 'job', 'item', 'attempt', 'audit'] as const).map((kind) =>
event(kind),
);
expect(envelopes.map((envelope) => first.append(envelope).sequence)).toEqual([1, 2, 3, 4, 5]);
const reopened = new EventJournal(database);
expect(reopened.readAfter()).toEqual({
status: 'current',
events: [],
latestEventId: 'audit-1',
});
expect(reopened.readAfter('instance-1')).toEqual({
status: 'found',
events: envelopes.slice(1).map((envelope, index) => ({ sequence: index + 2, envelope })),
latestEventId: 'audit-1',
});
});
it('enforces unique public IDs without consuming a sequence on failure', () => {
const subject = journal();
subject.append(event('job', 'same'));
expect(() => subject.append(event('audit', 'same'))).toThrow(/unique|already exists/i);
expect(subject.append(event('audit', 'next')).sequence).toBe(2);
});
it('reports current, found (strictly after), and unavailable cursors', () => {
const subject = journal();
subject.append(event('instance', 'one'));
subject.append(event('job', 'two'));
expect(subject.readAfter()).toEqual({ status: 'current', events: [], latestEventId: 'two' });
expect(subject.readAfter('two')).toEqual({ status: 'found', events: [], latestEventId: 'two' });
expect(subject.readAfter('one')).toEqual({
status: 'found',
events: [{ sequence: 2, envelope: event('job', 'two') }],
latestEventId: 'two',
});
expect(subject.readAfter('missing')).toEqual({
status: 'unavailable',
events: [],
latestEventId: 'two',
});
});
it.each([
[{ ...event('job'), kind: 'other' }, /kind/i],
[{ ...event('job'), type: 'job' }, /additional|field/i],
[{ ...event('job'), extra: true }, /additional|field/i],
[{ ...event('job'), occurredAt: 'Friday' }, /occurredAt|date-time/i],
[{ ...event('job'), id: 'bad\nvalue' }, /id|control/i],
[{ ...event('job'), requestId: 'bad\rvalue' }, /requestId|control/i],
[{ ...event('job'), jobId: 'bad\0value' }, /jobId|control/i],
[{ kind: 'job', id: 'job-1', occurredAt: at, requestId: 'request-1' }, /jobId|missing/i],
[{ ...event('item'), attemptId: 'attempt-1' }, /additional|field/i],
[{ ...event('job'), id: '' }, /id/i],
])('rejects invalid envelopes before persistence: %j', (invalid, message) => {
const subject = journal();
expect(() => subject.append(invalid as EventEnvelope)).toThrow(message);
expect(subject.readAfter()).toEqual({
status: 'current',
events: [],
latestEventId: undefined,
});
});
it('validates persisted envelope_json rather than trusting database contents', () => {
const database = new Database(':memory:');
migrateDatabase(database);
database
.prepare('INSERT INTO event_journal (public_id, envelope_json) VALUES (?, ?)')
.run('corrupt', '{"kind":"job"}');
expect(() => new EventJournal(database).readAfter('corrupt')).toThrow(
/persisted.*envelope|invalid/i,
);
});
it('notifies subscribers only after durable append and supports idempotent unsubscribe', () => {
const subject = journal();
const listener = vi.fn();
const unsubscribe = subject.subscribe(listener);
const appended = subject.append(event('attempt'));
expect(listener).toHaveBeenCalledOnce();
expect(listener).toHaveBeenCalledWith(appended);
unsubscribe();
unsubscribe();
subject.append(event('audit'));
expect(listener).toHaveBeenCalledOnce();
});
});
describe('formatServerSentEvent', () => {
it('formats exactly id, compact JSON data, and a blank line without an event field', () => {
const envelope = event('item', 'public-7');
expect(formatServerSentEvent(envelope)).toBe(
`id: public-7\ndata: ${JSON.stringify(envelope)}\n\n`,
);
expect(formatServerSentEvent(envelope)).not.toContain('\nevent:');
});
it('applies the same strict envelope validation at the wire boundary', () => {
expect(() =>
formatServerSentEvent({ ...event('audit'), requestId: 'inject\ndata: x' }),
).toThrow(/requestId|control/i);
expect(() =>
formatServerSentEvent({ ...event('audit'), unexpected: 1 } as unknown as EventEnvelope),
).toThrow(/additional|field/i);
});
});
@@ -0,0 +1,264 @@
import type Database from 'better-sqlite3';
const EVENT_KINDS = ['instance', 'job', 'item', 'attempt', 'audit'] as const;
const COMMON_EVENT_FIELDS = ['kind', 'id', 'occurredAt', 'requestId'] as const;
const CORRELATION_FIELDS = ['instanceId', 'jobId', 'itemId', 'attemptId', 'auditEventId'] as const;
const FORBIDDEN_ID_CHARACTERS = /[\r\n\0]/u;
const DATE_TIME =
/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(Z|[+-]\d{2}:\d{2})$/u;
export type EventKind = (typeof EVENT_KINDS)[number];
interface EventEnvelopeFields {
readonly id: string;
readonly occurredAt: string;
readonly requestId: string;
}
export type EventEnvelope =
| (EventEnvelopeFields & { readonly kind: 'instance'; readonly instanceId: string })
| (EventEnvelopeFields & { readonly kind: 'job'; readonly jobId: string })
| (EventEnvelopeFields & {
readonly kind: 'item';
readonly jobId: string;
readonly itemId: string;
})
| (EventEnvelopeFields & {
readonly kind: 'attempt';
readonly jobId: string;
readonly attemptId: string;
})
| (EventEnvelopeFields & { readonly kind: 'audit'; readonly auditEventId: string });
export interface JournalEvent {
readonly sequence: number;
readonly envelope: EventEnvelope;
}
export type EventJournalRead =
| {
readonly status: 'current';
readonly events: readonly [];
readonly latestEventId: string | undefined;
}
| {
readonly status: 'found';
readonly events: readonly JournalEvent[];
readonly latestEventId: string | undefined;
}
| {
readonly status: 'unavailable';
readonly events: readonly [];
readonly latestEventId: string | undefined;
};
export type EventJournalSubscriber = (event: JournalEvent) => void;
interface JournalRow {
sequence: number;
public_id: string;
envelope_json: string;
}
function validateIdentifier(value: unknown, field: string): asserts value is string {
if (typeof value !== 'string' || value.length === 0) {
throw new TypeError(`${field} must be a non-empty string`);
}
if (FORBIDDEN_ID_CHARACTERS.test(value)) {
throw new TypeError(`${field} contains a forbidden CR, LF, or NUL control character`);
}
}
function isValidDateTime(value: unknown): value is string {
if (typeof value !== 'string') return false;
const match = DATE_TIME.exec(value);
if (!match) return false;
const zone = match[8]!;
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
const hour = Number(match[4]);
const minute = Number(match[5]);
const second = Number(match[6]);
if (month < 1 || month > 12 || hour > 23 || minute > 59 || second > 59) return false;
const daysInMonth = new Date(Date.UTC(year, month, 0)).getUTCDate();
if (day < 1 || day > daysInMonth) return false;
if (zone !== 'Z') {
const zoneHour = Number(zone.slice(1, 3));
const zoneMinute = Number(zone.slice(4, 6));
if (zoneHour > 23 || zoneMinute > 59) return false;
}
return Number.isFinite(Date.parse(value));
}
export function validateEventEnvelope(value: unknown): asserts value is EventEnvelope {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new TypeError('event envelope must be an object');
}
const record = value as Record<string, unknown>;
if (!(EVENT_KINDS as readonly unknown[]).includes(record.kind)) {
throw new TypeError('event envelope kind must be instance, job, item, attempt, or audit');
}
const variantFields: Record<EventKind, readonly string[]> = {
instance: ['instanceId'],
job: ['jobId'],
item: ['jobId', 'itemId'],
attempt: ['jobId', 'attemptId'],
audit: ['auditEventId'],
};
const expected: readonly string[] = [
...COMMON_EVENT_FIELDS,
...variantFields[record.kind as EventKind],
];
const keys = Object.keys(record);
const additional = keys.filter((key) => !expected.includes(key));
const missing = expected.filter((key) => !Object.prototype.hasOwnProperty.call(record, key));
if (additional.length > 0) {
throw new TypeError(`event envelope has additional field: ${additional.join(', ')}`);
}
if (missing.length > 0) {
throw new TypeError(`event envelope is missing field: ${missing.join(', ')}`);
}
validateIdentifier(record.id, 'id');
validateIdentifier(record.requestId, 'requestId');
for (const field of CORRELATION_FIELDS) {
if (Object.prototype.hasOwnProperty.call(record, field))
validateIdentifier(record[field], field);
}
if (!isValidDateTime(record.occurredAt)) {
throw new TypeError('occurredAt must be a valid RFC 3339 date-time');
}
}
export function formatServerSentEvent(envelope: EventEnvelope): string {
validateEventEnvelope(envelope);
return `id: ${envelope.id}\ndata: ${JSON.stringify(envelope)}\n\n`;
}
export interface EventJournalOptions {
readonly retentionMs?: number;
readonly maxReplayEvents?: number;
}
export class EventJournal {
readonly #database: Database.Database;
readonly #subscribers = new Set<EventJournalSubscriber>();
readonly #retentionMs: number;
readonly #maxReplayEvents: number;
constructor(database: Database.Database, options: EventJournalOptions = {}) {
if (
options.retentionMs !== undefined &&
(!Number.isSafeInteger(options.retentionMs) || options.retentionMs <= 0)
) {
throw new RangeError('retentionMs must be a positive safe integer');
}
if (
options.maxReplayEvents !== undefined &&
(!Number.isSafeInteger(options.maxReplayEvents) || options.maxReplayEvents <= 0)
) {
throw new RangeError('maxReplayEvents must be a positive safe integer');
}
this.#database = database;
this.#retentionMs = options.retentionMs ?? 86_400_000;
this.#maxReplayEvents = options.maxReplayEvents ?? 1_000;
const columns = database.pragma('table_info(event_journal)') as Array<{ name: string }>;
const names = new Set(columns.map(({ name }) => name));
if (
columns.length === 0 ||
!names.has('sequence') ||
!names.has('public_id') ||
!names.has('envelope_json')
) {
throw new Error(
'event_journal schema is missing or incompatible; apply its forward migration',
);
}
}
append(envelope: EventEnvelope): JournalEvent {
validateEventEnvelope(envelope);
const serialized = JSON.stringify(envelope);
const result = this.#database
.prepare('INSERT INTO event_journal (public_id, envelope_json) VALUES (?, ?)')
.run(envelope.id, serialized);
const sequence = Number(result.lastInsertRowid);
if (!Number.isSafeInteger(sequence))
throw new Error('event journal sequence is not a safe integer');
this.#prune();
const appended = { sequence, envelope };
for (const subscriber of [...this.#subscribers]) subscriber(appended);
return appended;
}
readAfter(lastEventId?: string): EventJournalRead {
const latest = this.#database
.prepare('SELECT public_id FROM event_journal ORDER BY sequence DESC LIMIT 1')
.get() as { public_id: string } | undefined;
const latestEventId = latest?.public_id;
if (lastEventId === undefined) return { status: 'current', events: [], latestEventId };
validateIdentifier(lastEventId, 'lastEventId');
const cursor = this.#database
.prepare('SELECT sequence, public_id, envelope_json FROM event_journal WHERE public_id = ?')
.get(lastEventId) as JournalRow | undefined;
if (!cursor) return { status: 'unavailable', events: [], latestEventId };
this.#decodeRow(cursor);
const oldest = this.#database
.prepare('SELECT sequence FROM event_journal ORDER BY sequence ASC LIMIT 1')
.get() as { sequence: number } | undefined;
if (oldest && cursor.sequence < oldest.sequence) {
return { status: 'unavailable', events: [], latestEventId };
}
const rows = this.#database
.prepare(
'SELECT sequence, public_id, envelope_json FROM event_journal WHERE sequence > ? ORDER BY sequence ASC LIMIT ?',
)
.all(cursor.sequence, this.#maxReplayEvents + 1) as JournalRow[];
if (rows.length > this.#maxReplayEvents) {
return { status: 'unavailable', events: [], latestEventId };
}
return {
status: 'found',
events: rows.map((row) => this.#decodeRow(row)),
latestEventId,
};
}
subscribe(subscriber: EventJournalSubscriber): () => void {
this.#subscribers.add(subscriber);
let subscribed = true;
return () => {
if (!subscribed) return;
subscribed = false;
this.#subscribers.delete(subscriber);
};
}
#prune(): void {
const cutoff = new Date(Date.now() - this.#retentionMs).toISOString();
this.#database
.prepare("DELETE FROM event_journal WHERE json_extract(envelope_json, '$.occurredAt') < ?")
.run(cutoff);
}
#decodeRow(row: JournalRow): JournalEvent {
let envelope: unknown;
try {
envelope = JSON.parse(row.envelope_json);
validateEventEnvelope(envelope);
if (envelope.id !== row.public_id)
throw new TypeError('public ID does not match envelope ID');
} catch (error) {
const detail = error instanceof Error ? `: ${error.message}` : '';
throw new Error(`invalid persisted event envelope at sequence ${row.sequence}${detail}`, {
cause: error,
});
}
if (!Number.isSafeInteger(row.sequence) || row.sequence < 1) {
throw new Error(`invalid persisted event journal sequence: ${row.sequence}`);
}
return { sequence: row.sequence, envelope };
}
}
@@ -0,0 +1,144 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { FleetStatusCoordinator } from './fleet-status-coordinator.js';
import { StatusPollScheduler, type StatusPollSchedulerOptions } from './status-poll-scheduler.js';
const flush = async () => {
await Promise.resolve();
await Promise.resolve();
};
afterEach(() => {
vi.useRealTimers();
});
describe('FleetStatusCoordinator', () => {
it('starts by syncing enabled IDs and delegates scheduled polls to refreshHealth', async () => {
const refreshHealth = vi.fn(async (instanceId: string) => {
void instanceId;
});
const coordinator = new FleetStatusCoordinator({
source: { listEnabled: vi.fn(async () => ['alpha', 'beta']) },
snapshots: { refreshHealth },
intervalMs: 100,
scheduler: (options) => new StatusPollScheduler({ ...options, concurrency: 1 }),
});
await coordinator.start();
await flush();
expect(refreshHealth.mock.calls.map(([id]) => id)).toEqual(['alpha', 'beta']);
await coordinator.stop();
});
it('reconciles additions and removals without re-registering unchanged IDs', async () => {
let enabled: readonly string[] = ['alpha', 'beta'];
let task!: StatusPollSchedulerOptions['task'];
const add = vi.fn();
const remove = vi.fn(() => true);
const start = vi.fn();
const stop = vi.fn(async () => undefined);
const scheduler = { add, remove, start, stop };
const refreshHealth = vi.fn(async (instanceId: string) => {
void instanceId;
});
const coordinator = new FleetStatusCoordinator({
source: { listEnabled: async () => enabled },
snapshots: { refreshHealth },
intervalMs: 250,
scheduler: (options) => {
task = options.task;
return scheduler;
},
});
await coordinator.start();
expect(add.mock.calls).toEqual([
['alpha', 250],
['beta', 250],
]);
expect(start).toHaveBeenCalledTimes(1);
enabled = ['beta', 'gamma'];
await coordinator.reconcile();
expect(remove).toHaveBeenCalledWith('alpha');
expect(add).toHaveBeenLastCalledWith('gamma', 250);
expect(add).toHaveBeenCalledTimes(3);
await task('gamma', new AbortController().signal);
expect(refreshHealth).toHaveBeenCalledWith('gamma');
await coordinator.stop();
});
it('makes concurrent start calls idempotent', async () => {
let release!: (ids: readonly string[]) => void;
const listEnabled = vi.fn(
() =>
new Promise<readonly string[]>((resolve) => {
release = resolve;
}),
);
const scheduler = {
add: vi.fn(),
remove: vi.fn(() => true),
start: vi.fn(),
stop: vi.fn(async () => undefined),
};
const coordinator = new FleetStatusCoordinator({
source: { listEnabled },
snapshots: { refreshHealth: vi.fn(async () => undefined) },
intervalMs: 50,
scheduler: () => scheduler,
});
const first = coordinator.start();
const second = coordinator.start();
await flush();
release(['alpha']);
await Promise.all([first, second]);
expect(listEnabled).toHaveBeenCalledTimes(1);
expect(scheduler.add).toHaveBeenCalledTimes(1);
expect(scheduler.start).toHaveBeenCalledTimes(1);
await coordinator.stop();
expect(scheduler.remove).toHaveBeenCalledWith('alpha');
});
it('stop removes every key and prevents future polling', async () => {
vi.useFakeTimers();
const refreshHealth = vi.fn(async () => undefined);
const coordinator = new FleetStatusCoordinator({
source: { listEnabled: async () => ['alpha', 'beta'] },
snapshots: { refreshHealth },
intervalMs: 10,
scheduler: (options) => new StatusPollScheduler({ ...options, concurrency: 2 }),
});
await coordinator.start();
await flush();
expect(refreshHealth).toHaveBeenCalledTimes(2);
await coordinator.stop();
await vi.advanceTimersByTimeAsync(100);
expect(refreshHealth).toHaveBeenCalledTimes(2);
await coordinator.reconcile();
expect(refreshHealth).toHaveBeenCalledTimes(2);
});
it('isolates one failed refresh so another fleet member is still polled', async () => {
const refreshHealth = vi.fn(async (id: string) => {
if (id === 'bad') throw new Error('unreachable');
});
const coordinator = new FleetStatusCoordinator({
source: { listEnabled: async () => ['bad', 'good'] },
snapshots: { refreshHealth },
intervalMs: 1_000,
scheduler: (options) => new StatusPollScheduler({ ...options, concurrency: 1 }),
});
await coordinator.start();
await flush();
expect(refreshHealth.mock.calls.map(([id]) => id)).toEqual(['bad', 'good']);
await coordinator.stop();
});
});
@@ -0,0 +1,110 @@
import { StatusPollScheduler, type StatusPollSchedulerOptions } from './status-poll-scheduler.js';
export interface FleetStatusSource {
listEnabled(): Promise<readonly string[]>;
}
export interface FleetStatusSnapshots {
refreshHealth(instanceId: string): Promise<unknown>;
}
export interface FleetStatusScheduler {
add(instanceId: string, intervalMs?: number): void;
remove(instanceId: string): boolean;
start(): void;
stop(): Promise<void>;
}
export type FleetStatusSchedulerFactory = (
options: Pick<StatusPollSchedulerOptions, 'task' | 'intervalMs'>,
) => FleetStatusScheduler;
export interface FleetStatusCoordinatorOptions {
readonly source: FleetStatusSource;
readonly snapshots: FleetStatusSnapshots;
readonly intervalMs: number;
/** Primarily an injection seam for testing; the default uses the fleet scheduler. */
readonly scheduler?: FleetStatusSchedulerFactory;
}
/** Keeps the polling scheduler's registrations aligned with the enabled fleet. */
export class FleetStatusCoordinator {
readonly #source: FleetStatusSource;
readonly #intervalMs: number;
readonly #scheduler: FleetStatusScheduler;
readonly #registered = new Set<string>();
#started = false;
#stopped = false;
#startPromise: Promise<void> | undefined;
#reconcileTail: Promise<void> = Promise.resolve();
#stopPromise?: Promise<void>;
constructor(options: FleetStatusCoordinatorOptions) {
if (!Number.isFinite(options.intervalMs) || options.intervalMs <= 0) {
throw new RangeError('intervalMs must be greater than zero');
}
this.#source = options.source;
this.#intervalMs = options.intervalMs;
const createScheduler =
options.scheduler ??
((schedulerOptions) => new StatusPollScheduler({ ...schedulerOptions, concurrency: 4 }));
this.#scheduler = createScheduler({
intervalMs: options.intervalMs,
task: async (instanceId) => {
await options.snapshots.refreshHealth(instanceId);
},
});
}
start(): Promise<void> {
if (this.#started || this.#stopped) return this.#startPromise ?? Promise.resolve();
if (this.#startPromise) return this.#startPromise;
this.#startPromise = (async () => {
await this.reconcile();
if (this.#stopped) return;
this.#scheduler.start();
this.#started = true;
})().catch((error: unknown) => {
this.#startPromise = undefined;
throw error;
});
return this.#startPromise;
}
reconcile(): Promise<void> {
if (this.#stopped) return Promise.resolve();
const reconciliation = this.#reconcileTail.then(async () => {
if (this.#stopped) return;
const enabled = new Set(await this.#source.listEnabled());
if (this.#stopped) return;
for (const instanceId of this.#registered) {
if (!enabled.has(instanceId)) {
this.#scheduler.remove(instanceId);
this.#registered.delete(instanceId);
}
}
for (const instanceId of enabled) {
if (!this.#registered.has(instanceId)) {
this.#scheduler.add(instanceId, this.#intervalMs);
this.#registered.add(instanceId);
}
}
});
this.#reconcileTail = reconciliation.catch(() => undefined);
return reconciliation;
}
stop(): Promise<void> {
if (this.#stopPromise) return this.#stopPromise;
this.#stopped = true;
this.#stopPromise = (async () => {
await this.#reconcileTail;
for (const instanceId of this.#registered) this.#scheduler.remove(instanceId);
this.#registered.clear();
await this.#scheduler.stop();
})();
return this.#stopPromise;
}
}
@@ -0,0 +1,282 @@
import Database from 'better-sqlite3';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
import { UpstreamError } from '../../infrastructure/transport/upstream-error.js';
import {
StatusSnapshotError,
StatusSnapshotService,
type HealthTransportResponse,
} from './status-snapshot-service.js';
const databases: Database.Database[] = [];
afterEach(() => {
for (const db of databases.splice(0)) db.close();
});
const instant = (value: string) => () => new Date(value);
function fixture(
options: { response?: HealthTransportResponse; failure?: unknown; now?: () => Date } = {},
) {
const db = new Database(':memory:');
db.pragma('foreign_keys=ON');
migrateDatabase(db);
databases.push(db);
const created = '2026-07-17T10:00:00.000Z';
db.prepare(
'INSERT INTO instances (id,name,base_url,auth_mode,enabled,config_revision,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)',
).run('instance-1', 'LAN', 'http://192.168.1.20:3000', 'none', 1, 1, created, created);
const instances = {
get: vi.fn(async (id: string) =>
id === 'instance-1' ? { id, origin: 'http://192.168.1.20:3000' } : undefined,
),
};
const transport = {
get: vi.fn(async (url: string) => {
expect(url).toBe('http://192.168.1.20:3000/api/health');
if (options.failure !== undefined) throw options.failure;
return (
options.response ?? {
status: 200,
body: '{"status":"ok","version":"1.2.3","platform":"darwin","secret":"no"}',
}
);
}),
};
const service = new StatusSnapshotService({
db,
instances,
transport,
ttlMs: 60_000,
maxStaleMs: 300_000,
now: options.now ?? instant('2026-07-17T12:00:00.000Z'),
idFactory: () => 'snapshot-id',
});
return { db, instances, transport, service };
}
const row = (db: Database.Database) =>
db
.prepare(
'SELECT id,instance_id,category,state,payload_json,observed_at,expires_at,created_at FROM status_snapshots',
)
.get() as Record<string, unknown> | undefined;
const payload = (db: Database.Database) =>
JSON.parse(String(row(db)?.payload_json)) as Record<string, unknown>;
const seed = (db: Database.Database, overrides: { observedAt?: string; payload?: string } = {}) => {
const observedAt = overrides.observedAt ?? '2026-07-17T11:58:00.000Z';
db.prepare(
'INSERT INTO status_snapshots (id,instance_id,category,state,payload_json,observed_at,expires_at,created_at) VALUES (?,?,?,?,?,?,?,?)',
).run(
'original-id',
'instance-1',
'health',
'fresh',
overrides.payload ?? '{"status":"old","version":"1.0.0"}',
observedAt,
'2026-07-17T11:59:00.000Z',
'2026-07-17T11:58:00.000Z',
);
};
describe('StatusSnapshotService health snapshots', () => {
it('persists only a bounded allowlisted fresh envelope', async () => {
const { db, service } = fixture();
await expect(service.refreshHealth('instance-1')).resolves.toEqual({
instanceId: 'instance-1',
category: 'health',
state: 'fresh',
payload: { status: 'ok', version: '1.2.3', platform: 'darwin' },
observedAt: '2026-07-17T12:00:00.000Z',
expiresAt: '2026-07-17T12:01:00.000Z',
});
expect(payload(db)).toEqual({
schemaVersion: 1,
data: { status: 'ok', version: '1.2.3', platform: 'darwin' },
errorCode: null,
httpStatus: 200,
fetchedAt: '2026-07-17T12:00:00.000Z',
durationMs: 0,
freshness: 'fresh',
supported: true,
dataFetchedAt: '2026-07-17T12:00:00.000Z',
});
expect(JSON.stringify(row(db))).not.toContain('secret');
});
it.each([
[401, 'AUTH_REQUIRED'],
[403, 'AUTH_REQUIRED'],
[500, 'UPSTREAM_DEGRADED'],
[503, 'UPSTREAM_DEGRADED'],
] as const)('retains recent safe data as stale for HTTP %i', async (status, errorCode) => {
const { db, service } = fixture({ response: { status, body: 'password=never-store' } });
seed(db);
await expect(service.refreshHealth('instance-1')).resolves.toMatchObject({
state: 'stale',
payload: { status: 'old', version: '1.0.0' },
expiresAt: '2026-07-17T12:03:00.000Z',
});
expect(payload(db)).toEqual({
schemaVersion: 1,
data: { status: 'old', version: '1.0.0' },
errorCode,
httpStatus: status,
fetchedAt: '2026-07-17T12:00:00.000Z',
durationMs: 0,
freshness: 'stale',
supported: true,
dataFetchedAt: '2026-07-17T11:58:00.000Z',
});
expect(row(db)).toMatchObject({ id: 'original-id', created_at: '2026-07-17T11:58:00.000Z' });
expect(JSON.stringify(row(db))).not.toContain('password');
});
it('marks 404 unsupported without retaining upstream content', async () => {
const { db, service } = fixture({ response: { status: 404, body: '{"token":"never"}' } });
seed(db);
await expect(service.refreshHealth('instance-1')).resolves.toMatchObject({
state: 'unknown',
payload: {},
expiresAt: null,
});
expect(payload(db)).toEqual({
schemaVersion: 1,
data: {},
errorCode: 'UNSUPPORTED',
httpStatus: 404,
fetchedAt: '2026-07-17T12:00:00.000Z',
durationMs: 0,
freshness: 'unknown',
supported: false,
dataFetchedAt: null,
});
expect(JSON.stringify(row(db))).not.toContain('token');
});
it.each([
[new UpstreamError('UPSTREAM_TIMEOUT'), 'UPSTREAM_TIMEOUT'],
[new UpstreamError('UPSTREAM_RESPONSE_TOO_LARGE'), 'RESPONSE_TOO_LARGE'],
[new Error('private origin password'), 'UPSTREAM_UNAVAILABLE'],
] as const)('persists stable typed transport classification', async (failure, errorCode) => {
const { db, service } = fixture({ failure });
await service.refreshHealth('instance-1');
expect(payload(db)).toEqual({
schemaVersion: 1,
data: {},
errorCode,
httpStatus: null,
fetchedAt: '2026-07-17T12:00:00.000Z',
durationMs: 0,
freshness: 'unknown',
supported: null,
dataFetchedAt: null,
});
if (!(failure instanceof UpstreamError)) {
expect(JSON.stringify(row(db))).not.toContain(failure.message);
}
});
it.each([
{ status: 200, body: 'not-json' },
{ status: 200, body: '{"status":{"secret":"x"}}' },
{ status: 200, body: `{"version":"${'x'.repeat(129)}"}` },
{ status: 700, body: '{"status":"ok"}' },
])('treats invalid responses as unknown and never stores raw content', async (response) => {
const { db, service } = fixture({ response });
await expect(service.refreshHealth('instance-1')).resolves.toMatchObject({
state: 'unknown',
payload: {},
});
expect(payload(db)).toMatchObject({
data: {},
freshness: 'unknown',
errorCode: 'INVALID_RESPONSE',
});
expect(JSON.stringify(row(db))).not.toContain('secret');
});
it('does not retain data beyond max-stale', async () => {
const { db, service } = fixture({ response: { status: 503, body: 'private' } });
seed(db, { observedAt: '2026-07-17T11:54:59.999Z' });
await service.refreshHealth('instance-1');
expect(row(db)).toMatchObject({
id: 'original-id',
state: 'unknown',
expires_at: null,
created_at: '2026-07-17T11:58:00.000Z',
});
expect(payload(db)).toMatchObject({ data: {}, freshness: 'unknown' });
});
it('does not extend max-stale when repeated failures update observedAt', async () => {
const first = fixture({ response: { status: 503, body: 'private' } });
seed(first.db, {
payload: JSON.stringify({
schemaVersion: 1,
data: { status: 'old' },
errorCode: 'UPSTREAM_DEGRADED',
httpStatus: 503,
fetchedAt: '2026-07-17T11:59:30.000Z',
durationMs: 0,
freshness: 'stale',
supported: true,
dataFetchedAt: '2026-07-17T11:54:59.999Z',
}),
});
await first.service.refreshHealth('instance-1');
expect(payload(first.db)).toMatchObject({ data: {}, freshness: 'unknown' });
});
it('uses generation and observed-at fences for latest-wins', async () => {
const { db, service, transport } = fixture();
let resolveOld!: (value: HealthTransportResponse) => void;
const oldResponse = new Promise<HealthTransportResponse>((resolve) => {
resolveOld = resolve;
});
transport.get
.mockImplementationOnce(async () => oldResponse)
.mockResolvedValueOnce({ status: 200, body: '{"status":"new"}' });
const old = service.refreshHealth('instance-1');
await vi.waitFor(() => expect(transport.get).toHaveBeenCalledTimes(1));
await service.refreshHealth('instance-1');
resolveOld({ status: 200, body: '{"status":"old"}' });
await old;
expect(payload(db)).toMatchObject({ data: { status: 'new' } });
db.prepare(
"UPDATE status_snapshots SET observed_at=?,payload_json=? WHERE instance_id=? AND category='health'",
).run('2099-01-01T00:00:00.000Z', '{"status":"future"}', 'instance-1');
await service.refreshHealth('instance-1');
expect(row(db)).toMatchObject({
observed_at: '2099-01-01T00:00:00.000Z',
payload_json: '{"status":"future"}',
});
});
it('does no network/write for missing instances and no write after in-flight deletion', async () => {
const first = fixture();
await expect(first.service.refreshHealth('missing')).rejects.toEqual(
expect.objectContaining<Partial<StatusSnapshotError>>({ code: 'INSTANCE_NOT_FOUND' }),
);
expect(first.transport.get).not.toHaveBeenCalled();
expect(row(first.db)).toBeUndefined();
let resolve!: (value: HealthTransportResponse) => void;
first.transport.get.mockImplementationOnce(
async () =>
new Promise((done) => {
resolve = done;
}),
);
const running = first.service.refreshHealth('instance-1');
await vi.waitFor(() => expect(first.transport.get).toHaveBeenCalledTimes(1));
first.db.prepare('DELETE FROM instances WHERE id=?').run('instance-1');
resolve({ status: 200, body: '{"status":"ok"}' });
await running;
expect(row(first.db)).toBeUndefined();
});
});
@@ -0,0 +1,380 @@
import type Database from 'better-sqlite3';
import { randomUUID } from 'node:crypto';
import { UpstreamError } from '../../infrastructure/transport/upstream-error.js';
export interface HealthTransportResponse {
readonly status: number;
readonly body: string;
}
export type HealthSnapshotErrorCode =
| 'AUTH_REQUIRED'
| 'UNSUPPORTED'
| 'UPSTREAM_DEGRADED'
| 'UPSTREAM_TIMEOUT'
| 'UPSTREAM_UNAVAILABLE'
| 'RESPONSE_TOO_LARGE'
| 'INVALID_RESPONSE';
export interface HealthSnapshotInstance {
readonly id: string;
readonly origin: string;
}
export interface HealthEnvelope {
readonly status?: string;
readonly version?: string;
readonly platform?: string;
}
export type HealthSnapshotState = 'fresh' | 'stale' | 'unknown';
export interface HealthSnapshot {
readonly instanceId: string;
readonly category: 'health';
readonly state: HealthSnapshotState;
readonly payload: HealthEnvelope;
readonly observedAt: string;
readonly expiresAt: string | null;
}
export interface StatusSnapshotServiceOptions {
readonly db: Database.Database;
readonly instances: { get(instanceId: string): Promise<HealthSnapshotInstance | undefined> };
readonly transport: { get(url: string): Promise<HealthTransportResponse> };
readonly ttlMs: number;
readonly maxStaleMs: number;
readonly now?: () => Date;
readonly idFactory?: () => string;
}
export class StatusSnapshotError extends Error {
constructor(readonly code: 'INSTANCE_NOT_FOUND') {
super(code);
this.name = 'StatusSnapshotError';
}
}
interface SnapshotRow {
payload_json: string;
observed_at: string;
}
type SnapshotErrorCode = HealthSnapshotErrorCode;
interface PersistedHealthEnvelope {
readonly schemaVersion: 1;
readonly data: HealthEnvelope;
readonly errorCode: SnapshotErrorCode | null;
readonly httpStatus: number | null;
readonly fetchedAt: string;
readonly durationMs: number;
readonly freshness: HealthSnapshotState;
readonly supported: boolean | null;
readonly dataFetchedAt: string | null;
}
interface ClassifiedSnapshot {
readonly snapshot: HealthSnapshot;
readonly persisted: PersistedHealthEnvelope;
}
const MAX_FIELD_LENGTH = 128;
const SAFE_FIELD = /^[^\x00-\x1f\x7f]*$/;
function parseEnvelope(body: string): HealthEnvelope | undefined {
let parsed: unknown;
try {
parsed = JSON.parse(body);
} catch {
return undefined;
}
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined;
const source = parsed as Record<string, unknown>;
const envelope: { status?: string; version?: string; platform?: string } = {};
for (const key of ['status', 'version', 'platform'] as const) {
const value = source[key];
if (value === undefined) continue;
if (
typeof value !== 'string' ||
value.length === 0 ||
value.length > MAX_FIELD_LENGTH ||
!SAFE_FIELD.test(value)
)
return undefined;
envelope[key] = value;
}
return Object.freeze(envelope);
}
function safePreviousPayload(payloadJson: string): HealthEnvelope | undefined {
let candidate = payloadJson;
try {
const parsed = JSON.parse(payloadJson) as { data?: unknown };
if (parsed && typeof parsed === 'object' && 'data' in parsed)
candidate = JSON.stringify(parsed.data);
} catch {
// parseEnvelope below handles malformed legacy payloads.
}
const envelope = parseEnvelope(candidate);
return envelope && Object.keys(envelope).length > 0 ? envelope : undefined;
}
function previousDataFetchedAt(previous: SnapshotRow): string {
try {
const parsed = JSON.parse(previous.payload_json) as { dataFetchedAt?: unknown };
if (
typeof parsed.dataFetchedAt === 'string' &&
Number.isFinite(Date.parse(parsed.dataFetchedAt))
) {
return parsed.dataFetchedAt;
}
} catch {
// Legacy rows use observed_at as their data fetch time.
}
return previous.observed_at;
}
function responseErrorCode(response: HealthTransportResponse | undefined): SnapshotErrorCode {
if (
!response ||
!Number.isSafeInteger(response.status) ||
response.status < 100 ||
response.status > 599
) {
return 'INVALID_RESPONSE';
}
if (response.status === 401 || response.status === 403) return 'AUTH_REQUIRED';
if (response.status === 404) return 'UNSUPPORTED';
if (response.status >= 500) return 'UPSTREAM_DEGRADED';
return 'INVALID_RESPONSE';
}
function transportErrorCode(error: unknown): SnapshotErrorCode {
if (!(error instanceof UpstreamError)) return 'UPSTREAM_UNAVAILABLE';
if (error.code === 'UPSTREAM_TIMEOUT') return 'UPSTREAM_TIMEOUT';
if (error.code === 'UPSTREAM_RESPONSE_TOO_LARGE') return 'RESPONSE_TOO_LARGE';
return 'UPSTREAM_UNAVAILABLE';
}
const healthUrl = (origin: string): string => `${origin.replace(/\/$/, '')}/api/health`;
export class StatusSnapshotService {
readonly #db: Database.Database;
readonly #instances: StatusSnapshotServiceOptions['instances'];
readonly #transport: StatusSnapshotServiceOptions['transport'];
readonly #ttlMs: number;
readonly #maxStaleMs: number;
readonly #now: () => Date;
readonly #id: () => string;
readonly #latestGeneration = new Map<string, number>();
#nextGeneration = 0;
constructor(options: StatusSnapshotServiceOptions) {
if (!Number.isFinite(options.ttlMs) || options.ttlMs <= 0) {
throw new RangeError('ttlMs must be greater than zero');
}
if (!Number.isFinite(options.maxStaleMs) || options.maxStaleMs < options.ttlMs) {
throw new RangeError('maxStaleMs must be at least ttlMs');
}
this.#db = options.db;
this.#instances = options.instances;
this.#transport = options.transport;
this.#ttlMs = options.ttlMs;
this.#maxStaleMs = options.maxStaleMs;
this.#now = options.now ?? (() => new Date());
this.#id = options.idFactory ?? randomUUID;
}
async refreshHealth(instanceId: string): Promise<HealthSnapshot> {
const instance = await this.#instances.get(instanceId);
if (!instance) throw new StatusSnapshotError('INSTANCE_NOT_FOUND');
const generation = ++this.#nextGeneration;
this.#latestGeneration.set(instanceId, generation);
const started = this.#now();
let response: HealthTransportResponse | undefined;
let transportError: unknown;
try {
response = await this.#transport.get(healthUrl(instance.origin));
} catch (error) {
transportError = error;
}
const completed = this.#now();
const observedAt = completed.toISOString();
const previous = this.#previous(instanceId);
const classified = this.#classify(
instanceId,
response,
transportError,
previous,
completed,
observedAt,
Math.max(0, Math.min(Number.MAX_SAFE_INTEGER, completed.getTime() - started.getTime())),
);
const { snapshot } = classified;
if (this.#latestGeneration.get(instanceId) === generation) {
this.#persist(snapshot, classified.persisted);
this.#latestGeneration.delete(instanceId);
}
return Object.freeze({ ...snapshot, payload: Object.freeze({ ...snapshot.payload }) });
}
#previous(instanceId: string): SnapshotRow | undefined {
return this.#db
.prepare(
"SELECT payload_json,observed_at FROM status_snapshots WHERE instance_id=? AND category='health'",
)
.get(instanceId) as SnapshotRow | undefined;
}
#classify(
instanceId: string,
response: HealthTransportResponse | undefined,
transportError: unknown,
previous: SnapshotRow | undefined,
completed: Date,
observedAt: string,
durationMs: number,
): ClassifiedSnapshot {
if (
response &&
Number.isSafeInteger(response.status) &&
response.status >= 200 &&
response.status < 300 &&
typeof response.body === 'string'
) {
const payload = parseEnvelope(response.body);
if (payload) {
const snapshot: HealthSnapshot = {
instanceId,
category: 'health',
state: 'fresh',
payload,
observedAt,
expiresAt: new Date(completed.getTime() + this.#ttlMs).toISOString(),
};
return {
snapshot,
persisted: this.#envelope(snapshot, null, response.status, durationMs, true, observedAt),
};
}
}
if (response?.status === 404) {
const snapshot = this.#unknown(instanceId, observedAt);
return {
snapshot,
persisted: this.#envelope(snapshot, 'UNSUPPORTED', 404, durationMs, false, null),
};
}
const errorCode: SnapshotErrorCode = transportError
? transportErrorCode(transportError)
: responseErrorCode(response);
const httpStatus =
response &&
Number.isSafeInteger(response.status) &&
response.status >= 100 &&
response.status <= 599
? response.status
: null;
const previousPayload = previous && safePreviousPayload(previous.payload_json);
const previousTime = previous ? Date.parse(previousDataFetchedAt(previous)) : Number.NaN;
if (
previousPayload &&
Number.isFinite(previousTime) &&
completed.getTime() - previousTime <= this.#maxStaleMs
) {
const snapshot: HealthSnapshot = {
instanceId,
category: 'health',
state: 'stale',
payload: previousPayload,
observedAt,
expiresAt: new Date(previousTime + this.#maxStaleMs).toISOString(),
};
return {
snapshot,
persisted: this.#envelope(
snapshot,
errorCode,
httpStatus,
durationMs,
httpStatus === null ? null : true,
previousDataFetchedAt(previous),
),
};
}
const snapshot = this.#unknown(instanceId, observedAt);
return {
snapshot,
persisted: this.#envelope(
snapshot,
errorCode,
httpStatus,
durationMs,
httpStatus === null ? null : true,
null,
),
};
}
#unknown(instanceId: string, observedAt: string): HealthSnapshot {
return {
instanceId,
category: 'health',
state: 'unknown',
payload: {},
observedAt,
expiresAt: null,
};
}
#envelope(
snapshot: HealthSnapshot,
errorCode: SnapshotErrorCode | null,
httpStatus: number | null,
durationMs: number,
supported: boolean | null,
dataFetchedAt: string | null,
): PersistedHealthEnvelope {
return {
schemaVersion: 1,
data: snapshot.payload,
errorCode,
httpStatus,
fetchedAt: snapshot.observedAt,
durationMs,
freshness: snapshot.state,
supported,
dataFetchedAt,
};
}
#persist(snapshot: HealthSnapshot, persisted: PersistedHealthEnvelope): void {
this.#db
.prepare(
`INSERT INTO status_snapshots
(id,instance_id,category,state,payload_json,observed_at,expires_at,created_at)
SELECT ?,id,'health',?,?,?,?,? FROM instances WHERE id=?
ON CONFLICT(instance_id,category) DO UPDATE SET
state=excluded.state,
payload_json=excluded.payload_json,
observed_at=excluded.observed_at,
expires_at=excluded.expires_at
WHERE excluded.observed_at > status_snapshots.observed_at`,
)
.run(
this.#id(),
snapshot.state,
JSON.stringify(persisted),
snapshot.observedAt,
snapshot.expiresAt,
snapshot.observedAt,
snapshot.instanceId,
);
}
}
+57
View File
@@ -23,6 +23,63 @@ class Store implements SecretStore {
}
}
describe('buildControlPlaneApp', () => {
it('registers the durable event route fail-closed when no authentication dependency is supplied', async () => {
const db = new Database(':memory:');
db.pragma('foreign_keys=ON');
migrateDatabase(db);
dbs.push(db);
const app = buildControlPlaneApp({
db,
store: new Store(),
upstream: {
get: async () => ({ status: 200, headers: {}, body: '' }),
request: async () => ({ status: 200, headers: {}, body: '' }),
postNetworkRegisterAuto: async () => ({ status: 200 }),
},
});
const response = await app.inject({ method: 'GET', url: '/api/v1/events' });
expect(response.statusCode).toBe(401);
expect(response.headers['content-type']).toContain('application/problem+json');
expect(response.json()).toMatchObject({ status: 401, code: 'UNAUTHORIZED' });
await app.close();
});
it('passes an explicit event-stream authenticator to durable cursor replay', async () => {
const db = new Database(':memory:');
db.pragma('foreign_keys=ON');
migrateDatabase(db);
dbs.push(db);
const app = buildControlPlaneApp({
db,
store: new Store(),
upstream: {
get: async () => ({ status: 200, headers: {}, body: '' }),
request: async () => ({ status: 200, headers: {}, body: '' }),
postNetworkRegisterAuto: async () => ({ status: 200 }),
},
authenticateEventStream: (request) => request.headers.authorization === 'Bearer allowed',
});
const response = await app.inject({
method: 'GET',
url: '/api/v1/events',
headers: {
authorization: 'Bearer allowed',
'last-event-id': 'unknown-cursor',
},
});
expect(response.statusCode).toBe(409);
expect(response.headers['content-type']).toContain('application/problem+json');
expect(response.json()).toMatchObject({
status: 409,
code: 'EVENT_POSITION_UNAVAILABLE',
});
await app.close();
});
it('assembles instance CRUD, connection checks, login and logout without listening or reading external config', async () => {
const db = new Database(':memory:');
db.pragma('foreign_keys=ON');
+11 -1
View File
@@ -1,11 +1,12 @@
import type Database from 'better-sqlite3';
import { EventJournal } from './application/events/event-journal.js';
import { registerOperationRoutes } from './interface/http/operation-routes.js';
import { operationCatalogRegistry } from './application/operations/operation-catalog-data.js';
import {
SecureOperationExecution,
secureOperationRegistry,
} from './application/operations/secure-operation-execution.js';
import type { FastifyInstance } from 'fastify';
import type { FastifyInstance, FastifyRequest } from 'fastify';
import { buildApp, type BuildAppOptions } from './app.js';
import {
ConnectionProbe,
@@ -22,6 +23,7 @@ import { InstanceService } from './application/instances/instance-service.js';
import { DeleteInstanceOperation } from './application/operations/delete-instance-operation.js';
import type { SecretStore } from './infrastructure/secrets/secret-store.js';
import { registerInstanceRoutes } from './interface/http/instance-routes.js';
import { registerEventRoutes } from './interface/http/event-routes.js';
export interface SafeControlPlaneUpstream extends ConnectionTransport {
request: UpstreamSessionClientOptions['request'];
@@ -32,12 +34,14 @@ export interface ControlPlaneOptions {
readonly store: SecretStore;
readonly upstream: SafeControlPlaneUpstream;
readonly now?: () => Date;
readonly authenticateEventStream?: (request: FastifyRequest) => boolean;
readonly app?: Omit<BuildAppOptions, 'registerRoutes'>;
}
export interface ControlPlaneApp extends FastifyInstance {
retryPendingSecretCleanup(): Promise<unknown>;
}
export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlaneApp {
const eventJournal = new EventJournal(options.db);
const instances = options.now
? new InstanceService({ db: options.db, store: options.store, now: options.now })
: new InstanceService({ db: options.db, store: options.store });
@@ -82,6 +86,12 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
registerDeletionPreparationRoute: false,
});
registerOperationRoutes(app, operationCatalogRegistry, secureExecution, deletion);
registerEventRoutes(app, {
journal: eventJournal,
...(options.authenticateEventStream
? { authenticate: options.authenticateEventStream }
: {}),
});
},
});
Object.assign(app, {
@@ -62,6 +62,7 @@ describe('database migrations', () => {
'app_settings',
'audit_events',
'capabilities',
'event_journal',
'instance_tags',
'instances',
'job_attempts',
@@ -117,6 +118,7 @@ describe('database migrations', () => {
expect(indexes).toEqual(
expect.arrayContaining([
'idx_audit_events_created_at',
'idx_event_journal_sequence',
'idx_jobs_status_created_at',
'idx_status_snapshots_instance_observed_at',
]),
@@ -270,6 +270,18 @@ export const MIGRATIONS: readonly Migration[] = [
'CREATE INDEX idx_operation_preparations_status_expires_at ON operation_preparations(status, expires_at)',
],
},
{
id: 6,
name: 'durable-event-journal',
statements: [
`CREATE TABLE event_journal (
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
public_id TEXT NOT NULL UNIQUE,
envelope_json TEXT NOT NULL
)`,
'CREATE INDEX idx_event_journal_sequence ON event_journal(sequence)',
],
},
];
const createMigrationsTable = `CREATE TABLE schema_migrations (
@@ -0,0 +1,189 @@
import http from 'node:http';
import Database from 'better-sqlite3';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { buildApp } from '../../app.js';
import { EventJournal, type EventEnvelope } from '../../application/events/event-journal.js';
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
import { registerEventRoutes } from './event-routes.js';
const databases: Database.Database[] = [];
const apps: ReturnType<typeof buildApp>[] = [];
const at = '2026-07-17T12:34:56.789Z';
const event = (id: string): EventEnvelope => ({
kind: 'job',
id,
occurredAt: at,
requestId: `request-${id}`,
jobId: 'job-1',
});
function fixture(authenticate: ((request: unknown) => boolean) | null = () => true) {
const database = new Database(':memory:');
migrateDatabase(database);
databases.push(database);
const journal = new EventJournal(database);
const app = buildApp({
registerRoutes: (scope) =>
registerEventRoutes(scope, {
journal,
...(authenticate === null ? {} : { authenticate }),
}),
});
apps.push(app);
return { app, journal };
}
afterEach(async () => {
await Promise.all(apps.splice(0).map(async (app) => app.close()));
for (const database of databases.splice(0)) database.close();
});
describe('events HTTP route', () => {
it('requires the route-local authentication dependency and does not silently expose events', async () => {
for (const authenticate of [null, () => false]) {
const { app } = fixture(authenticate);
const response = await app.inject({ method: 'GET', url: '/api/v1/events' });
expect(response.statusCode).toBe(401);
expect(response.headers['content-type']).toContain('application/problem+json');
expect(response.headers['x-request-id']).toBeTruthy();
expect(response.json()).toMatchObject({
type: 'about:blank',
title: 'Unauthorized',
status: 401,
code: 'UNAUTHORIZED',
requestId: response.headers['x-request-id'],
});
}
});
it.each(['', '\0bad'])('rejects a malformed Last-Event-ID value (%j)', async (lastEventId) => {
const { app } = fixture();
const response = await app.inject({
method: 'GET',
url: '/api/v1/events',
headers: { 'last-event-id': lastEventId },
});
expect(response.statusCode).toBe(400);
expect(response.headers['content-type']).toContain('application/problem+json');
expect(response.json()).toMatchObject({ status: 400, code: 'VALIDATION_FAILED' });
expect(response.json().requestId).toBe(response.headers['x-request-id']);
});
it('returns the frozen conflict Problem Details for an unavailable cursor', async () => {
const { app, journal } = fixture();
journal.append(event('known'));
const response = await app.inject({
method: 'GET',
url: '/api/v1/events',
headers: { 'last-event-id': 'expired-or-unknown' },
});
expect(response.statusCode).toBe(409);
expect(response.headers['content-type']).toContain('application/problem+json');
expect(response.headers['x-request-id']).toBeTruthy();
expect(response.json()).toEqual({
type: 'about:blank',
title: 'Conflict',
status: 409,
code: 'EVENT_POSITION_UNAVAILABLE',
detail: 'The requested event position is no longer available.',
requestId: response.headers['x-request-id'],
});
});
it('replays strictly after the cursor, then streams live events without an invented event field', async () => {
const { app, journal } = fixture();
journal.append(event('one'));
journal.append(event('two'));
const address = await app.listen({ host: '127.0.0.1', port: 0 });
const chunks: string[] = [];
let response!: http.IncomingMessage;
await new Promise<void>((resolve, reject) => {
const request = http.get(
`${address}/api/v1/events`,
{ headers: { 'Last-Event-ID': 'one' } },
(incoming) => {
response = incoming;
incoming.setEncoding('utf8');
incoming.on('data', (chunk: string) => {
chunks.push(chunk);
if (chunks.join('').includes('id: three\n')) resolve();
});
incoming.on('error', reject);
journal.append(event('three'));
},
);
request.on('error', reject);
});
expect(response.statusCode).toBe(200);
expect(response.headers['content-type']).toContain('text/event-stream');
expect(response.headers['cache-control']).toBe('no-cache');
expect(response.headers['x-request-id']).toBeTruthy();
const body = chunks.join('');
expect(body).toBe(
`id: two\ndata: ${JSON.stringify(event('two'))}\n\n` +
`id: three\ndata: ${JSON.stringify(event('three'))}\n\n`,
);
expect(body).not.toContain('\nevent:');
response.destroy();
});
it('subscribes before replay so an event arriving during the read is neither lost nor duplicated', async () => {
const { app, journal } = fixture();
journal.append(event('one'));
const originalRead = journal.readAfter.bind(journal);
vi.spyOn(journal, 'readAfter').mockImplementation((cursor) => {
journal.append(event('during-read'));
return originalRead(cursor);
});
const address = await app.listen({ host: '127.0.0.1', port: 0 });
const body = await new Promise<string>((resolve, reject) => {
http
.get(`${address}/api/v1/events`, { headers: { 'Last-Event-ID': 'one' } }, (response) => {
response.setEncoding('utf8');
response.once('data', (chunk: string) => {
response.destroy();
resolve(chunk);
});
response.on('error', reject);
})
.on('error', reject);
});
const frame = `id: during-read\ndata: ${JSON.stringify(event('during-read'))}\n\n`;
expect(body).toBe(frame);
});
it('unsubscribes when the client disconnects', async () => {
const { app, journal } = fixture();
const unsubscribe = vi.fn();
const originalSubscribe = journal.subscribe.bind(journal);
vi.spyOn(journal, 'subscribe').mockImplementation((subscriber) => {
const originalUnsubscribe = originalSubscribe(subscriber);
return () => {
originalUnsubscribe();
unsubscribe();
};
});
const address = await app.listen({ host: '127.0.0.1', port: 0 });
await new Promise<void>((resolve, reject) => {
http
.get(`${address}/api/v1/events`, (response) => {
response.destroy();
resolve();
})
.on('error', reject);
});
await vi.waitFor(() => expect(unsubscribe).toHaveBeenCalledOnce());
});
});
+162
View File
@@ -0,0 +1,162 @@
import type { ServerResponse } from 'node:http';
import type { FastifyInstance, FastifyRequest } from 'fastify';
import {
EventJournal,
formatServerSentEvent,
type JournalEvent,
} from '../../application/events/event-journal.js';
const FORBIDDEN_CURSOR_CHARACTERS = /[\r\n\0]/u;
export interface EventRoutesOptions {
readonly journal: EventJournal;
/**
* Route-local until the control plane supplies its cookie authentication hook.
* Deliberately fail closed when omitted rather than exposing the event stream.
*/
readonly authenticate?: (request: FastifyRequest) => boolean;
}
const problem = (
request: FastifyRequest,
status: 400 | 401 | 409,
code: string,
detail: string,
): Record<string, unknown> => ({
type: 'about:blank',
title: status === 400 ? 'Bad Request' : status === 401 ? 'Unauthorized' : 'Conflict',
status,
code,
detail,
requestId: request.id,
});
function cursor(request: FastifyRequest): string | undefined {
const value = request.headers['last-event-id'];
if (value === undefined) return undefined;
if (typeof value !== 'string' || value.length === 0 || FORBIDDEN_CURSOR_CHARACTERS.test(value)) {
throw new TypeError('Last-Event-ID must be a non-empty event identifier');
}
return value;
}
function writeEvent(response: ServerResponse, event: JournalEvent, cleanup: () => void): void {
if (response.destroyed || response.writableEnded) {
cleanup();
return;
}
try {
response.write(formatServerSentEvent(event.envelope), (error) => {
if (error) cleanup();
});
} catch {
cleanup();
}
}
export function registerEventRoutes(app: FastifyInstance, options: EventRoutesOptions): void {
app.get('/api/v1/events', async (request, reply) => {
if (!options.authenticate?.(request)) {
return reply
.code(401)
.type('application/problem+json')
.send(
problem(
request,
401,
'UNAUTHORIZED',
'Authentication is required to access the event stream.',
),
);
}
let lastEventId: string | undefined;
try {
lastEventId = cursor(request);
} catch {
return reply
.code(400)
.type('application/problem+json')
.send(
problem(
request,
400,
'VALIDATION_FAILED',
'Last-Event-ID must be a non-empty valid event identifier.',
),
);
}
// Subscribe first. Events appended while the durable replay query runs are buffered;
// sequence de-duplication below closes the replay/subscription race.
const buffered: JournalEvent[] = [];
const stream: { response?: ServerResponse } = {};
let live = false;
let cleaned = false;
let unsubscribe = () => {};
const cleanup = () => {
if (cleaned) return;
cleaned = true;
unsubscribe();
const response = stream.response;
if (response && !response.destroyed && !response.writableEnded) response.destroy();
};
unsubscribe = options.journal.subscribe((event) => {
const response = stream.response;
if (!live || !response) {
buffered.push(event);
return;
}
writeEvent(response, event, cleanup);
});
let replay;
try {
replay = options.journal.readAfter(lastEventId);
} catch (error) {
cleanup();
throw error;
}
if (replay.status === 'unavailable') {
cleanup();
return reply
.code(409)
.type('application/problem+json')
.send(
problem(
request,
409,
'EVENT_POSITION_UNAVAILABLE',
'The requested event position is no longer available.',
),
);
}
reply.hijack();
const response = reply.raw;
stream.response = response;
response.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'X-Request-Id': request.id,
});
response.flushHeaders();
response.on('close', cleanup);
response.on('error', cleanup);
const replayedSequences = new Set<number>();
for (const event of replay.events) {
replayedSequences.add(event.sequence);
writeEvent(response, event, cleanup);
}
for (const event of buffered) {
if (!replayedSequences.has(event.sequence)) writeEvent(response, event, cleanup);
}
buffered.length = 0;
live = true;
return reply;
});
}