Spaces:
Sleeping
Sleeping
assafvayner HF Staff
fix(ledger): heal truncated ledger, validate events on write, reconcile pending
664d562 Download src/lib/server/ledger/events.ts from assafvayner/splitwise: direct link, hf CLI and curl.
- Browser
- Download file 2.53 kB
-
https://huggingface.co/spaces/assafvayner/splitwise/resolve/main/src/lib/server/ledger/events.ts
- Command line
-
hf download hf://spaces/assafvayner/splitwise/src/lib/server/ledger/events.ts
-
curl -L -o events.ts https://huggingface.co/spaces/assafvayner/splitwise/resolve/main/src/lib/server/ledger/events.ts
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 }; | |
| } | |