Spaces:
Running
Running
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 +23 -0
- src/lib/server/ledger/ledger.ts +243 -0
- tests/unit/ledger/ledger.test.ts +122 -0
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 |
+
});
|