File size: 2,527 Bytes
a896df9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
664d562
 
 
 
 
 
a896df9
 
 
 
 
664d562
 
 
a896df9
 
 
 
 
664d562
a896df9
 
 
664d562
a896df9
 
 
 
 
664d562
a896df9
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
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 };
}