153 lines
4.9 KiB
TypeScript
153 lines
4.9 KiB
TypeScript
import crypto from 'node:crypto';
|
|
import fs from 'node:fs';
|
|
import {
|
|
ACTIVITY_JOURNAL_MAX_EVENTS,
|
|
ACTIVITY_JOURNAL_RETENTION_DAYS,
|
|
normalizeActivityEventInput,
|
|
normalizeStoredActivityEvent,
|
|
type ActivityJournalEvent,
|
|
type ActivityJournalEventInput,
|
|
type ActivityJournalPage,
|
|
} from '../../shared/activityJournal.js';
|
|
import { createJsonStore } from './stateStore.js';
|
|
|
|
interface JournalState {
|
|
schemaVersion: 1;
|
|
events: ActivityJournalEvent[];
|
|
}
|
|
|
|
const migrateJournal = (value: unknown): JournalState => {
|
|
const candidate = value && typeof value === 'object' && !Array.isArray(value)
|
|
? value as Record<string, unknown>
|
|
: {};
|
|
return {
|
|
schemaVersion: 1,
|
|
events: (Array.isArray(candidate.events) ? candidate.events : [])
|
|
.map(normalizeStoredActivityEvent)
|
|
.filter((event): event is ActivityJournalEvent => Boolean(event)),
|
|
};
|
|
};
|
|
|
|
export function createActivityJournalService({
|
|
filePath,
|
|
now = () => new Date(),
|
|
}: {
|
|
filePath: string;
|
|
now?: () => Date;
|
|
}) {
|
|
const store = createJsonStore<JournalState>({
|
|
filePath,
|
|
defaultValue: { schemaVersion: 1, events: [] },
|
|
migrate: migrateJournal,
|
|
});
|
|
let recoveryRecorded = false;
|
|
let writeFailed = false;
|
|
|
|
function retained(events: ActivityJournalEvent[]) {
|
|
const cutoff = now().getTime() - ACTIVITY_JOURNAL_RETENTION_DAYS * 86_400_000;
|
|
return events
|
|
.filter(({ occurredAt }) => Date.parse(occurredAt) >= cutoff)
|
|
.slice(-ACTIVITY_JOURNAL_MAX_EVENTS);
|
|
}
|
|
|
|
function append(value: ActivityJournalEventInput) {
|
|
const input = normalizeActivityEventInput(value);
|
|
const storedInput = input.dedupeKey ? {
|
|
...input,
|
|
dedupeKey: `${input.type}:sha256:${crypto.createHash('sha256').update(input.dedupeKey).digest('hex')}`,
|
|
} : input;
|
|
let appended: ActivityJournalEvent | null = null;
|
|
try {
|
|
store.update((state) => {
|
|
const events = retained(state.events);
|
|
if (storedInput.dedupeKey && events.some(({ dedupeKey }) => dedupeKey === storedInput.dedupeKey)) {
|
|
return { schemaVersion: 1, events };
|
|
}
|
|
appended = {
|
|
id: crypto.randomUUID(),
|
|
occurredAt: now().toISOString(),
|
|
...storedInput,
|
|
};
|
|
return { schemaVersion: 1, events: retained([...events, appended]) };
|
|
});
|
|
writeFailed = false;
|
|
} catch (error) {
|
|
writeFailed = true;
|
|
throw error;
|
|
}
|
|
return appended;
|
|
}
|
|
|
|
function ensureRecoveryEvent() {
|
|
if (!store.recovery || recoveryRecorded) return;
|
|
append({
|
|
type: 'journal.recovered',
|
|
severity: 'warning',
|
|
source: 'storage',
|
|
dedupeKey: `journal.recovered:${store.recovery.recoveredAt}`,
|
|
data: {},
|
|
});
|
|
recoveryRecorded = true;
|
|
}
|
|
|
|
function page(limitValue: unknown = 50, cursorValue: unknown = null): ActivityJournalPage {
|
|
try {
|
|
let state = store.read();
|
|
ensureRecoveryEvent();
|
|
if (store.recovery) state = store.read();
|
|
const retainedEvents = retained(state.events);
|
|
if (retainedEvents.length !== state.events.length) {
|
|
state = store.update(() => ({ schemaVersion: 1, events: retainedEvents }));
|
|
}
|
|
const events = [...state.events].reverse();
|
|
const limit = Math.min(100, Math.max(1, Number.isSafeInteger(limitValue) ? Number(limitValue) : 50));
|
|
const cursor = typeof cursorValue === 'string' ? cursorValue : '';
|
|
const cursorIndex = cursor ? events.findIndex(({ id }) => id === cursor) : -1;
|
|
if (cursor && cursorIndex < 0) return {
|
|
events: [],
|
|
nextCursor: null,
|
|
retentionDays: 30,
|
|
generatedAt: now().toISOString(),
|
|
storage: writeFailed
|
|
? { status: 'error', errorCode: 'JOURNAL_UNAVAILABLE' }
|
|
: { status: 'ready', errorCode: null },
|
|
};
|
|
const safeStart = cursorIndex + 1;
|
|
const selected = events.slice(safeStart, safeStart + limit);
|
|
return {
|
|
events: selected.map((event) => ({ ...event, dedupeKey: null })),
|
|
nextCursor: safeStart + selected.length < events.length ? selected.at(-1)?.id || null : null,
|
|
retentionDays: 30,
|
|
generatedAt: now().toISOString(),
|
|
storage: writeFailed
|
|
? { status: 'error', errorCode: 'JOURNAL_UNAVAILABLE' }
|
|
: { status: 'ready', errorCode: null },
|
|
};
|
|
} catch {
|
|
return {
|
|
events: [],
|
|
nextCursor: null,
|
|
retentionDays: 30,
|
|
generatedAt: now().toISOString(),
|
|
storage: { status: 'error', errorCode: 'JOURNAL_UNAVAILABLE' },
|
|
};
|
|
}
|
|
}
|
|
|
|
if (fs.existsSync(filePath)) {
|
|
try {
|
|
const state = store.read();
|
|
const retainedEvents = retained(state.events);
|
|
if (retainedEvents.length !== state.events.length) {
|
|
store.update(() => ({ schemaVersion: 1, events: retainedEvents }));
|
|
}
|
|
} catch {
|
|
writeFailed = true;
|
|
}
|
|
}
|
|
|
|
return { append, page };
|
|
}
|
|
|
|
export type ActivityJournalService = ReturnType<typeof createActivityJournalService>;
|