assafvayner HF Staff commited on
Commit
3bfa0fc
·
1 Parent(s): a896df9

feat(ledger): add Ledger service with event write path and compaction

Browse files
src/lib/server/ledger/errors.ts ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export class ConflictError extends Error {
2
+ constructor(
3
+ public readonly entityId: string,
4
+ public readonly currentVersion: number
5
+ ) {
6
+ super(`Entity ${entityId} was modified (current version ${currentVersion})`);
7
+ this.name = 'ConflictError';
8
+ }
9
+ }
10
+
11
+ export class NotFoundError extends Error {
12
+ constructor(public readonly entityId: string) {
13
+ super(`Entity ${entityId} not found`);
14
+ this.name = 'NotFoundError';
15
+ }
16
+ }
17
+
18
+ export class ReadOnlyError extends Error {
19
+ constructor() {
20
+ super('The app is in read-only mode: another instance holds the writer lock');
21
+ this.name = 'ReadOnlyError';
22
+ }
23
+ }
src/lib/server/ledger/ledger.ts ADDED
@@ -0,0 +1,243 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ulid as defaultUlid } from '../../domain/ulid';
2
+ import type { Expense, ExpenseBody, Settlement, SettlementBody } from '../../domain/types';
3
+ import { decodeText, type BucketStore } from '../storage/store';
4
+ import { ConflictError, NotFoundError, ReadOnlyError } from './errors';
5
+ import { decodeEvent, encodeEvent, encodeJsonl, EVENT_KEY_RE, EVENTS_PREFIX, eventKey, LEDGER_PATH, parseJsonl, type LedgerEvent } from './events';
6
+ import { applyEvent, foldEvents, type FoldedState } from './fold';
7
+
8
+ export type Clock = () => Date;
9
+
10
+ export interface LedgerOptions {
11
+ clock?: Clock;
12
+ newId?: () => string;
13
+ }
14
+
15
+ export interface LedgerStats {
16
+ pendingEvents: number;
17
+ ledgerEvents: number;
18
+ ledgerExists: boolean;
19
+ }
20
+
21
+ type DistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never;
22
+ /** A LedgerEvent before the Ledger assigns id, ts and actor. */
23
+ export type NewEvent = DistributiveOmit<LedgerEvent, 'id' | 'ts' | 'actor'>;
24
+
25
+ export interface CompactionResult {
26
+ /** Events appended to the ledger. */
27
+ appended: number;
28
+ /** Event files deleted. */
29
+ deleted: number;
30
+ backedUp: boolean;
31
+ }
32
+
33
+ /**
34
+ * In-memory view of all expenses and settlements, backed by an append-only event log.
35
+ * All mutations go through a single async queue so writes never interleave.
36
+ */
37
+ export class Ledger {
38
+ readOnly = false;
39
+ private queue: Promise<unknown> = Promise.resolve();
40
+ private lastBackupDay: string | null = null;
41
+
42
+ private constructor(
43
+ private readonly store: BucketStore,
44
+ private readonly state: FoldedState,
45
+ /** Ids of events already present in ledger.jsonl. */
46
+ private readonly ledgerIds: Set<string>,
47
+ private ledgerExists: boolean,
48
+ /** Event files in events/ that have not been compacted yet, keyed by object path. */
49
+ private readonly pending: Map<string, LedgerEvent>,
50
+ private readonly clock: Clock,
51
+ private readonly newId: () => string
52
+ ) {}
53
+
54
+ static async load(store: BucketStore, opts: LedgerOptions = {}): Promise<Ledger> {
55
+ const clock = opts.clock ?? (() => new Date());
56
+ const newId = opts.newId ?? defaultUlid;
57
+
58
+ const listing = await store.list(EVENTS_PREFIX);
59
+ let ledgerBytes = await store.get(LEDGER_PATH);
60
+ const pending = new Map<string, LedgerEvent>();
61
+ let sawMissing = false;
62
+ for (const entry of listing) {
63
+ if (!EVENT_KEY_RE.test(entry.path)) continue;
64
+ const bytes = await store.get(entry.path);
65
+ if (!bytes) {
66
+ sawMissing = true;
67
+ continue;
68
+ }
69
+ pending.set(entry.path, decodeEvent(bytes));
70
+ }
71
+ // A compactor deleted an event after publishing a ledger that contains it: re-read the ledger.
72
+ if (sawMissing) ledgerBytes = await store.get(LEDGER_PATH);
73
+
74
+ const ledgerEvents = ledgerBytes ? parseJsonl(decodeText(ledgerBytes)) : [];
75
+ const ledgerIds = new Set(ledgerEvents.map((e) => e.id));
76
+ const sortedPending = [...pending.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
77
+ const state = foldEvents([...ledgerEvents, ...sortedPending]);
78
+ return new Ledger(store, state, ledgerIds, ledgerBytes !== null, pending, clock, newId);
79
+ }
80
+
81
+ // ---- reads -------------------------------------------------------------
82
+
83
+ getExpense(id: string): Expense | undefined {
84
+ return this.state.expenses.get(id);
85
+ }
86
+ listExpenses(): Expense[] {
87
+ return [...this.state.expenses.values()];
88
+ }
89
+ getSettlement(id: string): Settlement | undefined {
90
+ return this.state.settlements.get(id);
91
+ }
92
+ listSettlements(): Settlement[] {
93
+ return [...this.state.settlements.values()];
94
+ }
95
+ history(entityId: string): LedgerEvent[] {
96
+ return [...(this.state.history.get(entityId) ?? [])];
97
+ }
98
+ stats(): LedgerStats {
99
+ return { pendingEvents: this.pending.size, ledgerEvents: this.ledgerIds.size, ledgerExists: this.ledgerExists };
100
+ }
101
+
102
+ // ---- writes ------------------------------------------------------------
103
+
104
+ /** `id` may be supplied so callers can store a photo under the expense id before the event is written. */
105
+ createExpense(actor: string, body: ExpenseBody, id = `e_${this.newId()}`): Promise<Expense> {
106
+ return this.run(async () => {
107
+ const now = this.clock().toISOString();
108
+ const expense: Expense = { ...body, id, version: 1, createdBy: actor, createdAt: now, updatedBy: actor, updatedAt: now };
109
+ await this.writeEvent({ kind: 'expense', op: 'upsert', entityId: expense.id, version: 1, data: expense }, actor, now);
110
+ return expense;
111
+ });
112
+ }
113
+
114
+ updateExpense(actor: string, id: string, body: ExpenseBody, expectedVersion: number): Promise<Expense> {
115
+ return this.run(async () => {
116
+ const current = this.requireExpense(id, expectedVersion);
117
+ const now = this.clock().toISOString();
118
+ const expense: Expense = {
119
+ ...body, id, version: current.version + 1,
120
+ createdBy: current.createdBy, createdAt: current.createdAt, updatedBy: actor, updatedAt: now
121
+ };
122
+ await this.writeEvent({ kind: 'expense', op: 'upsert', entityId: id, version: expense.version, data: expense }, actor, now);
123
+ return expense;
124
+ });
125
+ }
126
+
127
+ deleteExpense(actor: string, id: string, expectedVersion: number): Promise<void> {
128
+ return this.run(async () => {
129
+ const current = this.requireExpense(id, expectedVersion);
130
+ await this.writeEvent({ kind: 'expense', op: 'delete', entityId: id, version: current.version + 1 }, actor, this.clock().toISOString());
131
+ });
132
+ }
133
+
134
+ createSettlement(actor: string, body: SettlementBody): Promise<Settlement> {
135
+ return this.run(async () => {
136
+ const now = this.clock().toISOString();
137
+ const settlement: Settlement = { ...body, id: `s_${this.newId()}`, version: 1, createdBy: actor, createdAt: now, updatedBy: actor, updatedAt: now };
138
+ await this.writeEvent({ kind: 'settlement', op: 'upsert', entityId: settlement.id, version: 1, data: settlement }, actor, now);
139
+ return settlement;
140
+ });
141
+ }
142
+
143
+ updateSettlement(actor: string, id: string, body: SettlementBody, expectedVersion: number): Promise<Settlement> {
144
+ return this.run(async () => {
145
+ const current = this.requireSettlement(id, expectedVersion);
146
+ const now = this.clock().toISOString();
147
+ const settlement: Settlement = {
148
+ ...body, id, version: current.version + 1,
149
+ createdBy: current.createdBy, createdAt: current.createdAt, updatedBy: actor, updatedAt: now
150
+ };
151
+ await this.writeEvent({ kind: 'settlement', op: 'upsert', entityId: id, version: settlement.version, data: settlement }, actor, now);
152
+ return settlement;
153
+ });
154
+ }
155
+
156
+ deleteSettlement(actor: string, id: string, expectedVersion: number): Promise<void> {
157
+ return this.run(async () => {
158
+ const current = this.requireSettlement(id, expectedVersion);
159
+ await this.writeEvent({ kind: 'settlement', op: 'delete', entityId: id, version: current.version + 1 }, actor, this.clock().toISOString());
160
+ });
161
+ }
162
+
163
+ // ---- compaction --------------------------------------------------------
164
+
165
+ /**
166
+ * Appends every pending event to ledger.jsonl, then deletes the event files.
167
+ * Safe to crash at any point: nothing acknowledged is ever lost, and duplicates are removed by the fold.
168
+ */
169
+ compact(): Promise<CompactionResult> {
170
+ return this.run(async () => {
171
+ if (this.readOnly) throw new ReadOnlyError();
172
+ const listing = await this.store.list(EVENTS_PREFIX);
173
+ const keys = listing.map((e) => e.path).filter((p) => EVENT_KEY_RE.test(p)).sort();
174
+
175
+ const toAppend: LedgerEvent[] = [];
176
+ const toDelete: string[] = [];
177
+ for (const key of keys) {
178
+ let event = this.pending.get(key);
179
+ if (!event) {
180
+ const bytes = await this.store.get(key);
181
+ if (!bytes) continue;
182
+ event = decodeEvent(bytes);
183
+ applyEvent(this.state, event);
184
+ this.pending.set(key, event);
185
+ }
186
+ toDelete.push(key);
187
+ if (!this.ledgerIds.has(event.id)) toAppend.push(event);
188
+ }
189
+ if (toDelete.length === 0) return { appended: 0, deleted: 0, backedUp: false };
190
+
191
+ let backedUp = false;
192
+ const day = this.clock().toISOString().slice(0, 10);
193
+ if (this.ledgerExists && this.lastBackupDay !== day) {
194
+ await this.store.copy(LEDGER_PATH, `backups/ledger-${day}.jsonl`);
195
+ this.lastBackupDay = day;
196
+ backedUp = true;
197
+ }
198
+
199
+ if (toAppend.length > 0) {
200
+ const bytes = encodeJsonl(toAppend);
201
+ if (this.ledgerExists) await this.store.append(LEDGER_PATH, bytes);
202
+ else await this.store.put(LEDGER_PATH, bytes, 'application/x-ndjson');
203
+ this.ledgerExists = true;
204
+ for (const e of toAppend) this.ledgerIds.add(e.id);
205
+ }
206
+
207
+ await this.store.delete(toDelete);
208
+ for (const key of toDelete) this.pending.delete(key);
209
+ return { appended: toAppend.length, deleted: toDelete.length, backedUp };
210
+ });
211
+ }
212
+
213
+ // ---- internals ---------------------------------------------------------
214
+
215
+ private run<T>(fn: () => Promise<T>): Promise<T> {
216
+ const next = this.queue.then(fn, fn);
217
+ this.queue = next.catch(() => undefined);
218
+ return next;
219
+ }
220
+
221
+ private requireExpense(id: string, expectedVersion: number): Expense {
222
+ const current = this.state.expenses.get(id);
223
+ if (!current) throw new NotFoundError(id);
224
+ if (current.version !== expectedVersion) throw new ConflictError(id, current.version);
225
+ return current;
226
+ }
227
+
228
+ private requireSettlement(id: string, expectedVersion: number): Settlement {
229
+ const current = this.state.settlements.get(id);
230
+ if (!current) throw new NotFoundError(id);
231
+ if (current.version !== expectedVersion) throw new ConflictError(id, current.version);
232
+ return current;
233
+ }
234
+
235
+ private async writeEvent(partial: NewEvent, actor: string, ts: string): Promise<void> {
236
+ if (this.readOnly) throw new ReadOnlyError();
237
+ const event = { ...partial, id: this.newId(), ts, actor } as LedgerEvent;
238
+ const key = eventKey(event.id);
239
+ await this.store.put(key, encodeEvent(event), 'application/json');
240
+ applyEvent(this.state, event);
241
+ this.pending.set(key, event);
242
+ }
243
+ }
tests/unit/ledger/ledger.test.ts ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { mkdtemp, rm } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5
+ import type { ExpenseBody, SettlementBody } from '../../../src/lib/domain/types';
6
+ import { ConflictError, NotFoundError, ReadOnlyError } from '../../../src/lib/server/ledger/errors';
7
+ import { Ledger } from '../../../src/lib/server/ledger/ledger';
8
+ import { LocalFsStore } from '../../../src/lib/server/storage/local-fs-store';
9
+ import { decodeText, encodeText } from '../../../src/lib/server/storage/store';
10
+
11
+ const body: ExpenseBody = {
12
+ description: 'Pizza', amountCents: 2000, currency: 'USD', date: '2026-08-01', paidBy: 'u_a',
13
+ split: { mode: 'equal', participants: ['u_a', 'u_b'] }, shares: { u_a: 1000, u_b: 1000 }
14
+ };
15
+ const settlementBody: SettlementBody = { from: 'u_b', to: 'u_a', amountCents: 1000, date: '2026-08-02' };
16
+
17
+ describe('Ledger', () => {
18
+ let dir: string;
19
+ let store: LocalFsStore;
20
+ let clockMs = Date.UTC(2026, 7, 29, 12, 0, 0);
21
+ const clock = () => new Date(clockMs);
22
+
23
+ beforeEach(async () => {
24
+ dir = await mkdtemp(join(tmpdir(), 'splitwise-ledger-'));
25
+ store = new LocalFsStore(dir);
26
+ });
27
+ afterEach(() => rm(dir, { recursive: true, force: true }));
28
+
29
+ it('loads empty from an empty store', async () => {
30
+ const ledger = await Ledger.load(store, { clock });
31
+ expect(ledger.listExpenses()).toEqual([]);
32
+ expect(ledger.stats()).toEqual({ pendingEvents: 0, ledgerEvents: 0, ledgerExists: false });
33
+ });
34
+
35
+ it('creates expenses, writes one event file each, and survives reload', async () => {
36
+ const ledger = await Ledger.load(store, { clock });
37
+ const created = await ledger.createExpense('u_a', body);
38
+ expect(created.version).toBe(1);
39
+ expect(created.createdAt).toBe(clock().toISOString());
40
+ expect(created.id).toMatch(/^e_[0-9A-Z]{26}$/);
41
+ const withId = await ledger.createExpense('u_a', body, 'e_CUSTOM');
42
+ expect(withId.id).toBe('e_CUSTOM');
43
+
44
+ const files = await store.list('events/');
45
+ expect(files).toHaveLength(2);
46
+ expect(JSON.parse(decodeText((await store.get(files[0].path))!)).entityId).toBe(created.id);
47
+
48
+ const reloaded = await Ledger.load(store, { clock });
49
+ expect(reloaded.getExpense(created.id)).toEqual(created);
50
+ expect(reloaded.stats().pendingEvents).toBe(2);
51
+ });
52
+
53
+ it('updates with version check and records history', async () => {
54
+ const ledger = await Ledger.load(store, { clock });
55
+ const e = await ledger.createExpense('u_a', body);
56
+ clockMs += 1000;
57
+ const updated = await ledger.updateExpense('u_b', e.id, { ...body, description: 'Pizza night' }, 1);
58
+ expect(updated.version).toBe(2);
59
+ expect(updated.updatedBy).toBe('u_b');
60
+ expect(updated.createdBy).toBe('u_a');
61
+ expect(updated.createdAt).toBe(e.createdAt);
62
+ await expect(ledger.updateExpense('u_a', e.id, body, 1)).rejects.toBeInstanceOf(ConflictError);
63
+ expect(ledger.history(e.id).map((ev) => ev.version)).toEqual([1, 2]);
64
+ });
65
+
66
+ it('deletes with version check; deleted items disappear but history remains', async () => {
67
+ const ledger = await Ledger.load(store, { clock });
68
+ const e = await ledger.createExpense('u_a', body);
69
+ await expect(ledger.deleteExpense('u_a', e.id, 5)).rejects.toBeInstanceOf(ConflictError);
70
+ await ledger.deleteExpense('u_a', e.id, 1);
71
+ expect(ledger.getExpense(e.id)).toBeUndefined();
72
+ await expect(ledger.deleteExpense('u_a', e.id, 2)).rejects.toBeInstanceOf(NotFoundError);
73
+ const reloaded = await Ledger.load(store, { clock });
74
+ expect(reloaded.getExpense(e.id)).toBeUndefined();
75
+ expect(reloaded.history(e.id)).toHaveLength(2);
76
+ });
77
+
78
+ it('handles settlements the same way', async () => {
79
+ const ledger = await Ledger.load(store, { clock });
80
+ const s = await ledger.createSettlement('u_b', settlementBody);
81
+ expect(s.id).toMatch(/^s_/);
82
+ const s2 = await ledger.updateSettlement('u_b', s.id, { ...settlementBody, amountCents: 500 }, 1);
83
+ expect(s2.amountCents).toBe(500);
84
+ await ledger.deleteSettlement('u_b', s.id, 2);
85
+ expect(ledger.listSettlements()).toEqual([]);
86
+ });
87
+
88
+ it('refuses writes in read-only mode and leaves state untouched on store failure', async () => {
89
+ const ledger = await Ledger.load(store, { clock });
90
+ ledger.readOnly = true;
91
+ await expect(ledger.createExpense('u_a', body)).rejects.toBeInstanceOf(ReadOnlyError);
92
+ ledger.readOnly = false;
93
+
94
+ const failing = new LocalFsStore(dir);
95
+ failing.put = async () => {
96
+ throw new Error('network down');
97
+ };
98
+ const l2 = await Ledger.load(failing, { clock });
99
+ await expect(l2.createExpense('u_a', body)).rejects.toThrow('network down');
100
+ expect(l2.listExpenses()).toEqual([]);
101
+ expect(l2.stats().pendingEvents).toBe(0);
102
+ });
103
+
104
+ it('serialises concurrent writes', async () => {
105
+ const ledger = await Ledger.load(store, { clock });
106
+ const results = await Promise.all(Array.from({ length: 10 }, (_, i) => ledger.createExpense('u_a', { ...body, description: `e${i}` })));
107
+ expect(new Set(results.map((r) => r.id)).size).toBe(10);
108
+ expect((await store.list('events/')).length).toBe(10);
109
+ });
110
+
111
+ it('loads the ledger file plus pending events, deduplicating', async () => {
112
+ const ledger = await Ledger.load(store, { clock });
113
+ const e = await ledger.createExpense('u_a', body);
114
+ const [file] = await store.list('events/');
115
+ const eventBytes = (await store.get(file.path))!;
116
+ // Simulate a compaction that appended but crashed before deleting the event file.
117
+ await store.put('ledger.jsonl', encodeText(decodeText(eventBytes) + '\n'));
118
+ const reloaded = await Ledger.load(store, { clock });
119
+ expect(reloaded.getExpense(e.id)).toEqual(e);
120
+ expect(reloaded.stats()).toEqual({ pendingEvents: 1, ledgerEvents: 1, ledgerExists: true });
121
+ });
122
+ });