import { z } from 'zod'; import { ExpenseSchema, IsoTimestamp, SettlementSchema } from '../../domain/schemas'; import { decodeText, encodeText } from '../storage/store'; const ULID_RE = /^[0-9A-HJKMNP-TV-Z]{26}$/; const base = { id: z.string().regex(ULID_RE), ts: IsoTimestamp, actor: z.string().min(1), entityId: z.string().min(1), version: z.number().int().positive() }; export const LedgerEventSchema = z.union([ z.object({ ...base, kind: z.literal('expense'), op: z.literal('upsert'), data: ExpenseSchema }), z.object({ ...base, kind: z.literal('expense'), op: z.literal('delete') }), z.object({ ...base, kind: z.literal('settlement'), op: z.literal('upsert'), data: SettlementSchema }), z.object({ ...base, kind: z.literal('settlement'), op: z.literal('delete') }) ]); export type LedgerEvent = z.infer; export const EVENTS_PREFIX = 'events/'; export const LEDGER_PATH = 'ledger.jsonl'; export const eventKey = (id: string): string => `${EVENTS_PREFIX}${id}.json`; export const EVENT_KEY_RE = /^events\/([0-9A-HJKMNP-TV-Z]{26})\.json$/; export function encodeEvent(e: LedgerEvent): Uint8Array { return encodeText(JSON.stringify(e)); } export function decodeEvent(bytes: Uint8Array): LedgerEvent { return LedgerEventSchema.parse(JSON.parse(decodeText(bytes))); } export function encodeJsonl(events: LedgerEvent[]): Uint8Array { return encodeText(events.map((e) => JSON.stringify(e)).join('\n') + (events.length ? '\n' : '')); } export interface ParsedJsonl { events: LedgerEvent[]; /** True when a trailing partial line was skipped, so the file must be rewritten rather than appended to. */ truncated: boolean; } /** * Parses one event per line. A final line without a trailing newline that fails to parse is * treated as a truncated partial write and skipped (its event file still exists in events/, * because event files are only deleted after a successful append); any other bad line throws. */ export function parseJsonl(text: string): ParsedJsonl { const events: LedgerEvent[] = []; let truncated = false; const lines = text.split('\n'); const lastIndex = lines.length - 1; for (const [i, line] of lines.entries()) { if (line.trim() === '') continue; try { events.push(LedgerEventSchema.parse(JSON.parse(line))); } catch (e) { if (i === lastIndex && !text.endsWith('\n')) { console.warn('[ledger] ignoring truncated trailing line in ledger.jsonl'); truncated = true; continue; } throw e; } } return { events, truncated }; }