splitwise / src /lib /server /ledger /events.ts
assafvayner's picture
assafvayner HF Staff
fix(ledger): heal truncated ledger, validate events on write, reconcile pending
664d562
Raw History Blame Contribute Delete
2.53 kB
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<typeof LedgerEventSchema>;
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 };
}