@pithy-sh/ledger 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +17 -0
- package/package.json +49 -0
- package/pithy.manifest.json +30 -0
- package/src/admin/read.ts +150 -0
- package/src/audit/actions.ts +30 -0
- package/src/capability.ts +97 -0
- package/src/cloudflare-test.d.ts +12 -0
- package/src/config/config.ts +73 -0
- package/src/data/account.ts +43 -0
- package/src/data/hold.ts +39 -0
- package/src/data/tables.ts +43 -0
- package/src/data/transaction.ts +48 -0
- package/src/error/errors.ts +119 -0
- package/src/http/guards.ts +79 -0
- package/src/http/responses.ts +108 -0
- package/src/http/routes.ts +336 -0
- package/src/http/schemas.ts +107 -0
- package/src/http/scopes.ts +95 -0
- package/src/http/view.ts +74 -0
- package/src/index.ts +23 -0
- package/src/ledger.ts +324 -0
- package/src/migrations/0001_accounts.ts +75 -0
- package/src/seeds/example.ts +65 -0
- package/src/version.generated.ts +16 -0
package/src/ledger.ts
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { D1Database, D1PreparedStatement } from "@cloudflare/workers-types";
|
|
5
|
+
import { type CompiledQuery, sql } from "kysely";
|
|
6
|
+
import type { Balance } from "./data/account";
|
|
7
|
+
import { LEDGER_ACCOUNTS_TABLE, LEDGER_HOLDS_TABLE, LEDGER_TRANSACTIONS_TABLE, ledgerDatabase } from "./data/tables";
|
|
8
|
+
import { LedgerTransaction } from "./data/transaction";
|
|
9
|
+
import {
|
|
10
|
+
LedgerHoldNotFoundError,
|
|
11
|
+
LedgerHoldNotOpenError,
|
|
12
|
+
LedgerInsufficientFundsError,
|
|
13
|
+
LedgerInvalidAmountError,
|
|
14
|
+
} from "./error/errors";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The ledger — every balance movement, made correct by the database rather than by hope.
|
|
18
|
+
*
|
|
19
|
+
* Three invariants hold no matter how operations interleave or how many times they are delivered:
|
|
20
|
+
*
|
|
21
|
+
* - **Atomic.** Each operation is a single `DB.batch`, which D1 runs as one transaction: the ledger entry
|
|
22
|
+
* and the balance change commit together or not at all. The statements are built with Kysely (so
|
|
23
|
+
* `CamelCasePlugin` owns the snake_case columns — no hand-written SQL identifiers) and compiled to D1
|
|
24
|
+
* prepared statements; only the `batch()` call itself is D1-native, because Kysely has no equivalent for
|
|
25
|
+
* D1's transaction primitive.
|
|
26
|
+
* - **Idempotent.** Every operation carries a caller-supplied `ref`, written as a `UNIQUE` ledger row. A
|
|
27
|
+
* replay inserts a duplicate `ref`, which aborts the batch; the operation catches that and returns the
|
|
28
|
+
* current balance unchanged. A payout delivered twice pays once.
|
|
29
|
+
* - **Overdraft-safe.** A debit or hold is applied by an `UPDATE` guarded by the account's `CHECK (balance
|
|
30
|
+
* >= 0 AND held >= 0 AND held <= balance)`. If the movement would break solvency — even against a balance
|
|
31
|
+
* another concurrent operation just lowered — the `CHECK` aborts the batch, and the operation surfaces
|
|
32
|
+
* `ledger/insufficient_funds`. The guard is in SQLite, so no race can slip past it.
|
|
33
|
+
*
|
|
34
|
+
* This runs in-process (a game model, a trusted server handler) against the `DB` binding — the ledger is a
|
|
35
|
+
* server-authoritative primitive, not a client-facing API.
|
|
36
|
+
*/
|
|
37
|
+
export interface Ledger {
|
|
38
|
+
/** A player's position in a currency. Zero across the board when they have no account yet. */
|
|
39
|
+
balance(userId: string, currency: string): Promise<Balance>;
|
|
40
|
+
/** Add funds (a grant, a reward, a buy-in, a payout). Opens the account if needed. */
|
|
41
|
+
credit(userId: string, currency: string, amount: number, ref: string, options?: { memo?: string }): Promise<Balance>;
|
|
42
|
+
/** Remove funds. Fails `insufficient_funds` if the available balance cannot cover it. */
|
|
43
|
+
debit(userId: string, currency: string, amount: number, ref: string, options?: { memo?: string }): Promise<Balance>;
|
|
44
|
+
/** Move funds between two players atomically (a pot payout, a table settle-up). */
|
|
45
|
+
transfer(
|
|
46
|
+
from: string,
|
|
47
|
+
to: string,
|
|
48
|
+
currency: string,
|
|
49
|
+
amount: number,
|
|
50
|
+
ref: string,
|
|
51
|
+
options?: { memo?: string },
|
|
52
|
+
): Promise<void>;
|
|
53
|
+
/** Reserve funds for a pending wager. `ref` identifies the hold, for later release or capture. */
|
|
54
|
+
hold(userId: string, currency: string, amount: number, ref: string): Promise<Balance>;
|
|
55
|
+
/** Cancel a hold, returning the reserved funds to the player. Idempotent; refuses a resolved hold. */
|
|
56
|
+
release(holdRef: string): Promise<Balance>;
|
|
57
|
+
/** Finalize a hold: spend `amount` of it (default: all), returning any remainder. Idempotent. */
|
|
58
|
+
capture(holdRef: string, options?: { amount?: number; memo?: string }): Promise<Balance>;
|
|
59
|
+
/** A player's recent ledger entries in a currency, newest first. */
|
|
60
|
+
transactions(userId: string, currency: string, limit: number): Promise<LedgerTransaction[]>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A positive integer in a currency's minor unit — the only kind of amount the ledger accepts. */
|
|
64
|
+
function assertAmount(amount: number, context: string): void {
|
|
65
|
+
if (!Number.isInteger(amount) || amount <= 0) {
|
|
66
|
+
throw new LedgerInvalidAmountError({ detail: `${context}: amount ${amount} is not a positive integer.` });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Whether a D1 error is a specific constraint violation — how the batch's abort is classified. */
|
|
71
|
+
function isViolation(error: unknown, constraint: "UNIQUE" | "CHECK"): boolean {
|
|
72
|
+
return new RegExp(`${constraint} constraint failed`, "i").test(
|
|
73
|
+
String((error as { message?: unknown })?.message ?? ""),
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function openLedger(d1: D1Database, now: () => number = () => Date.now()): Ledger {
|
|
78
|
+
const db = ledgerDatabase(d1);
|
|
79
|
+
/** Compile a Kysely query to a D1 prepared statement, so it can join a `DB.batch` transaction. */
|
|
80
|
+
const prepared = (query: { compile(): CompiledQuery }): D1PreparedStatement => {
|
|
81
|
+
const compiled = query.compile();
|
|
82
|
+
return d1.prepare(compiled.sql).bind(...(compiled.parameters as unknown[]));
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const ensureAccount = (userId: string, currency: string, at: number) =>
|
|
86
|
+
db
|
|
87
|
+
.insertInto(LEDGER_ACCOUNTS_TABLE)
|
|
88
|
+
// biome-ignore lint/suspicious/noExplicitAny: the row is the schema's z.input side; Kysely's insert type derives from it.
|
|
89
|
+
.values({ userId, currency, balance: 0, held: 0, createdAt: at, updatedAt: at } as any)
|
|
90
|
+
.onConflict((oc) => oc.columns(["userId", "currency"]).doNothing());
|
|
91
|
+
|
|
92
|
+
const entry = (
|
|
93
|
+
ref: string,
|
|
94
|
+
userId: string,
|
|
95
|
+
currency: string,
|
|
96
|
+
kind: string,
|
|
97
|
+
amount: number,
|
|
98
|
+
relatedRef: string | null,
|
|
99
|
+
memo: string | null,
|
|
100
|
+
at: number,
|
|
101
|
+
) =>
|
|
102
|
+
db
|
|
103
|
+
.insertInto(LEDGER_TRANSACTIONS_TABLE)
|
|
104
|
+
// biome-ignore lint/suspicious/noExplicitAny: as above — the encoded row is the z.input side.
|
|
105
|
+
.values({ ref, userId, currency, kind, amount, relatedRef, memo, createdAt: at } as any);
|
|
106
|
+
|
|
107
|
+
const adjust = (userId: string, currency: string, set: Record<string, unknown>, at: number) =>
|
|
108
|
+
db
|
|
109
|
+
.updateTable(LEDGER_ACCOUNTS_TABLE)
|
|
110
|
+
// biome-ignore lint/suspicious/noExplicitAny: `set` mixes column values and `sql` fragments (balance ± amount).
|
|
111
|
+
.set({ ...set, updatedAt: at } as any)
|
|
112
|
+
.where("userId", "=", userId)
|
|
113
|
+
.where("currency", "=", currency);
|
|
114
|
+
|
|
115
|
+
const readBalance = async (userId: string, currency: string): Promise<Balance> => {
|
|
116
|
+
const row = await db
|
|
117
|
+
.selectFrom(LEDGER_ACCOUNTS_TABLE)
|
|
118
|
+
.select(["balance", "held"])
|
|
119
|
+
.where("userId", "=", userId)
|
|
120
|
+
.where("currency", "=", currency)
|
|
121
|
+
.executeTakeFirst();
|
|
122
|
+
if (!row) return { balance: 0, held: 0, available: 0 };
|
|
123
|
+
return { balance: row.balance, held: row.held, available: row.balance - row.held };
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/** Run a funding batch; a UNIQUE abort is an idempotent replay, a CHECK abort is insolvency. */
|
|
127
|
+
const applyFunding = async (
|
|
128
|
+
statements: D1PreparedStatement[],
|
|
129
|
+
userId: string,
|
|
130
|
+
currency: string,
|
|
131
|
+
insufficientDetail: string,
|
|
132
|
+
): Promise<Balance> => {
|
|
133
|
+
try {
|
|
134
|
+
await d1.batch(statements);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
if (isViolation(error, "UNIQUE")) return readBalance(userId, currency); // replay → no-op
|
|
137
|
+
if (isViolation(error, "CHECK"))
|
|
138
|
+
throw new LedgerInsufficientFundsError({ detail: insufficientDetail }, { cause: error });
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
return readBalance(userId, currency);
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
balance: readBalance,
|
|
146
|
+
|
|
147
|
+
async credit(userId, currency, amount, ref, options) {
|
|
148
|
+
assertAmount(amount, "credit");
|
|
149
|
+
const at = now();
|
|
150
|
+
const statements = [
|
|
151
|
+
prepared(ensureAccount(userId, currency, at)),
|
|
152
|
+
prepared(entry(ref, userId, currency, "credit", amount, null, options?.memo ?? null, at)),
|
|
153
|
+
prepared(adjust(userId, currency, { balance: sql`balance + ${amount}` }, at)),
|
|
154
|
+
];
|
|
155
|
+
return applyFunding(statements, userId, currency, "");
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
async debit(userId, currency, amount, ref, options) {
|
|
159
|
+
assertAmount(amount, "debit");
|
|
160
|
+
const at = now();
|
|
161
|
+
const statements = [
|
|
162
|
+
prepared(ensureAccount(userId, currency, at)),
|
|
163
|
+
prepared(entry(ref, userId, currency, "debit", amount, null, options?.memo ?? null, at)),
|
|
164
|
+
prepared(adjust(userId, currency, { balance: sql`balance - ${amount}` }, at)),
|
|
165
|
+
];
|
|
166
|
+
return applyFunding(
|
|
167
|
+
statements,
|
|
168
|
+
userId,
|
|
169
|
+
currency,
|
|
170
|
+
`debit of ${amount} ${currency} for ${userId} exceeds available balance`,
|
|
171
|
+
);
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
async transfer(from, to, currency, amount, ref, options) {
|
|
175
|
+
assertAmount(amount, "transfer");
|
|
176
|
+
const at = now();
|
|
177
|
+
const statements = [
|
|
178
|
+
prepared(ensureAccount(from, currency, at)),
|
|
179
|
+
prepared(ensureAccount(to, currency, at)),
|
|
180
|
+
prepared(entry(`${ref}:out`, from, currency, "transfer_out", amount, `${ref}:in`, options?.memo ?? null, at)),
|
|
181
|
+
prepared(entry(`${ref}:in`, to, currency, "transfer_in", amount, `${ref}:out`, options?.memo ?? null, at)),
|
|
182
|
+
prepared(adjust(from, currency, { balance: sql`balance - ${amount}` }, at)),
|
|
183
|
+
prepared(adjust(to, currency, { balance: sql`balance + ${amount}` }, at)),
|
|
184
|
+
];
|
|
185
|
+
try {
|
|
186
|
+
await d1.batch(statements);
|
|
187
|
+
} catch (error) {
|
|
188
|
+
if (isViolation(error, "UNIQUE")) return; // replay → no-op
|
|
189
|
+
if (isViolation(error, "CHECK"))
|
|
190
|
+
throw new LedgerInsufficientFundsError(
|
|
191
|
+
{ detail: `transfer of ${amount} ${currency} from ${from} exceeds available balance` },
|
|
192
|
+
{ cause: error },
|
|
193
|
+
);
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
|
|
198
|
+
async hold(userId, currency, amount, ref) {
|
|
199
|
+
assertAmount(amount, "hold");
|
|
200
|
+
const at = now();
|
|
201
|
+
const statements = [
|
|
202
|
+
prepared(ensureAccount(userId, currency, at)),
|
|
203
|
+
prepared(entry(ref, userId, currency, "hold", amount, null, null, at)),
|
|
204
|
+
prepared(
|
|
205
|
+
db
|
|
206
|
+
.insertInto(LEDGER_HOLDS_TABLE)
|
|
207
|
+
// biome-ignore lint/suspicious/noExplicitAny: the row is the schema's z.input side; Kysely's insert type derives from it.
|
|
208
|
+
.values({ ref, userId, currency, amount, status: "open", createdAt: at, resolvedAt: null } as any),
|
|
209
|
+
),
|
|
210
|
+
prepared(adjust(userId, currency, { held: sql`held + ${amount}` }, at)),
|
|
211
|
+
];
|
|
212
|
+
return applyFunding(
|
|
213
|
+
statements,
|
|
214
|
+
userId,
|
|
215
|
+
currency,
|
|
216
|
+
`hold of ${amount} ${currency} for ${userId} exceeds available balance`,
|
|
217
|
+
);
|
|
218
|
+
},
|
|
219
|
+
|
|
220
|
+
async release(holdRef) {
|
|
221
|
+
const hold = await requireOpenHold(db, holdRef);
|
|
222
|
+
const at = now();
|
|
223
|
+
const statements = [
|
|
224
|
+
// One resolution per hold, whichever wins the race — the `:resolve` ref is unique.
|
|
225
|
+
prepared(entry(`${holdRef}:resolve`, hold.userId, hold.currency, "release", hold.amount, holdRef, null, at)),
|
|
226
|
+
prepared(
|
|
227
|
+
db
|
|
228
|
+
.updateTable(LEDGER_HOLDS_TABLE)
|
|
229
|
+
.set({ status: "released", resolvedAt: at })
|
|
230
|
+
.where("ref", "=", holdRef)
|
|
231
|
+
.where("status", "=", "open"),
|
|
232
|
+
),
|
|
233
|
+
prepared(adjust(hold.userId, hold.currency, { held: sql`held - ${hold.amount}` }, at)),
|
|
234
|
+
];
|
|
235
|
+
return resolveHold(d1, statements, hold.userId, hold.currency, readBalance);
|
|
236
|
+
},
|
|
237
|
+
|
|
238
|
+
async capture(holdRef, options) {
|
|
239
|
+
const hold = await requireOpenHold(db, holdRef);
|
|
240
|
+
const captured = options?.amount ?? hold.amount;
|
|
241
|
+
if (!Number.isInteger(captured) || captured < 0 || captured > hold.amount) {
|
|
242
|
+
throw new LedgerInvalidAmountError({
|
|
243
|
+
detail: `capture of ${captured} exceeds the ${hold.amount} held on ${holdRef}`,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
const at = now();
|
|
247
|
+
const statements = [
|
|
248
|
+
prepared(
|
|
249
|
+
entry(
|
|
250
|
+
`${holdRef}:resolve`,
|
|
251
|
+
hold.userId,
|
|
252
|
+
hold.currency,
|
|
253
|
+
"capture",
|
|
254
|
+
captured,
|
|
255
|
+
holdRef,
|
|
256
|
+
options?.memo ?? null,
|
|
257
|
+
at,
|
|
258
|
+
),
|
|
259
|
+
),
|
|
260
|
+
prepared(
|
|
261
|
+
db
|
|
262
|
+
.updateTable(LEDGER_HOLDS_TABLE)
|
|
263
|
+
.set({ status: "captured", resolvedAt: at })
|
|
264
|
+
.where("ref", "=", holdRef)
|
|
265
|
+
.where("status", "=", "open"),
|
|
266
|
+
),
|
|
267
|
+
// Release the whole reservation from `held`, and spend the captured part from `balance`.
|
|
268
|
+
prepared(
|
|
269
|
+
adjust(
|
|
270
|
+
hold.userId,
|
|
271
|
+
hold.currency,
|
|
272
|
+
{ held: sql`held - ${hold.amount}`, balance: sql`balance - ${captured}` },
|
|
273
|
+
at,
|
|
274
|
+
),
|
|
275
|
+
),
|
|
276
|
+
];
|
|
277
|
+
return resolveHold(d1, statements, hold.userId, hold.currency, readBalance);
|
|
278
|
+
},
|
|
279
|
+
|
|
280
|
+
async transactions(userId, currency, limit) {
|
|
281
|
+
const rows = await db
|
|
282
|
+
.selectFrom(LEDGER_TRANSACTIONS_TABLE)
|
|
283
|
+
.selectAll()
|
|
284
|
+
.where("userId", "=", userId)
|
|
285
|
+
.where("currency", "=", currency)
|
|
286
|
+
.orderBy("id", "desc")
|
|
287
|
+
.limit(limit)
|
|
288
|
+
.execute();
|
|
289
|
+
return rows.map((row) => LedgerTransaction.parse(row));
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** The open hold with this ref, or a typed error — the pre-check that turns a bad-state resolution into a clean 404/409. */
|
|
295
|
+
async function requireOpenHold(
|
|
296
|
+
db: ReturnType<typeof ledgerDatabase>,
|
|
297
|
+
ref: string,
|
|
298
|
+
): Promise<{ userId: string; currency: string; amount: number }> {
|
|
299
|
+
const row = await db
|
|
300
|
+
.selectFrom(LEDGER_HOLDS_TABLE)
|
|
301
|
+
.select(["userId", "currency", "amount", "status"])
|
|
302
|
+
.where("ref", "=", ref)
|
|
303
|
+
.executeTakeFirst();
|
|
304
|
+
if (!row) throw new LedgerHoldNotFoundError({ detail: `No hold with ref ${ref}.` });
|
|
305
|
+
if (row.status !== "open") throw new LedgerHoldNotOpenError({ detail: `Hold ${ref} is ${row.status}.` });
|
|
306
|
+
return { userId: row.userId, currency: row.currency, amount: row.amount };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Run a hold-resolution batch; a UNIQUE abort means another resolution already won the race — a no-op. */
|
|
310
|
+
async function resolveHold(
|
|
311
|
+
d1: D1Database,
|
|
312
|
+
statements: D1PreparedStatement[],
|
|
313
|
+
userId: string,
|
|
314
|
+
currency: string,
|
|
315
|
+
readBalance: (u: string, c: string) => Promise<Balance>,
|
|
316
|
+
): Promise<Balance> {
|
|
317
|
+
try {
|
|
318
|
+
await d1.batch(statements);
|
|
319
|
+
} catch (error) {
|
|
320
|
+
if (isViolation(error, "UNIQUE")) return readBalance(userId, currency); // already resolved → no-op
|
|
321
|
+
throw error;
|
|
322
|
+
}
|
|
323
|
+
return readBalance(userId, currency);
|
|
324
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { type Kysely, sql } from "kysely";
|
|
5
|
+
import type { Migration } from "kysely/migration";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The ledger's tables: accounts, the append-only transaction log, and holds.
|
|
9
|
+
*
|
|
10
|
+
* camelCase identifiers; `CamelCasePlugin` snake-cases them in the DDL. `down` is the tested inverse.
|
|
11
|
+
*
|
|
12
|
+
* The load-bearing detail is the `CHECK` constraint on accounts: `balance >= 0 AND held >= 0 AND held <=
|
|
13
|
+
* balance`. It is the overdraft guard, enforced by SQLite itself — a debit past zero or a hold past the
|
|
14
|
+
* available balance violates it, which aborts the statement (and, inside a `DB.batch`, the whole
|
|
15
|
+
* transaction), so the ledger cannot record a spend it cannot cover. Correctness lives in the schema, not
|
|
16
|
+
* in a hopeful application-level check that a race could skip.
|
|
17
|
+
*/
|
|
18
|
+
export const ledger_0001_accounts: Migration = {
|
|
19
|
+
up: async (db: Kysely<unknown>): Promise<void> => {
|
|
20
|
+
await db.schema
|
|
21
|
+
.createTable("pithyLedgerAccounts")
|
|
22
|
+
.addColumn("id", "integer", (c) => c.primaryKey())
|
|
23
|
+
.addColumn("userId", "text", (c) => c.notNull())
|
|
24
|
+
.addColumn("currency", "text", (c) => c.notNull())
|
|
25
|
+
.addColumn("balance", "integer", (c) => c.notNull().defaultTo(0))
|
|
26
|
+
.addColumn("held", "integer", (c) => c.notNull().defaultTo(0))
|
|
27
|
+
.addColumn("createdAt", "integer", (c) => c.notNull())
|
|
28
|
+
.addColumn("updatedAt", "integer", (c) => c.notNull())
|
|
29
|
+
// One account per player per currency — the upsert-on-open conflict target.
|
|
30
|
+
.addUniqueConstraint("pithyLedgerAccountsOwnerIdx", ["userId", "currency"])
|
|
31
|
+
// The overdraft guard: never negative, never over-reserved. SQLite aborts any statement that breaks it.
|
|
32
|
+
.addCheckConstraint("pithyLedgerAccountsSolvent", sql`balance >= 0 AND held >= 0 AND held <= balance`)
|
|
33
|
+
.execute();
|
|
34
|
+
|
|
35
|
+
await db.schema
|
|
36
|
+
.createTable("pithyLedgerTransactions")
|
|
37
|
+
.addColumn("id", "integer", (c) => c.primaryKey())
|
|
38
|
+
// ref is UNIQUE across the whole ledger — the idempotency anchor. A replay's insert violates it, which
|
|
39
|
+
// aborts the retry's batch, so the movement applies exactly once.
|
|
40
|
+
.addColumn("ref", "text", (c) => c.notNull().unique())
|
|
41
|
+
.addColumn("userId", "text", (c) => c.notNull())
|
|
42
|
+
.addColumn("currency", "text", (c) => c.notNull())
|
|
43
|
+
.addColumn("kind", "text", (c) => c.notNull())
|
|
44
|
+
.addColumn("amount", "integer", (c) => c.notNull())
|
|
45
|
+
.addColumn("relatedRef", "text")
|
|
46
|
+
.addColumn("memo", "text")
|
|
47
|
+
.addColumn("createdAt", "integer", (c) => c.notNull())
|
|
48
|
+
.execute();
|
|
49
|
+
|
|
50
|
+
// The audit read: a player's history in a currency, newest first.
|
|
51
|
+
await db.schema
|
|
52
|
+
.createIndex("pithyLedgerTransactionsOwnerIdx")
|
|
53
|
+
.on("pithyLedgerTransactions")
|
|
54
|
+
.columns(["userId", "currency", "id"])
|
|
55
|
+
.execute();
|
|
56
|
+
|
|
57
|
+
await db.schema
|
|
58
|
+
.createTable("pithyLedgerHolds")
|
|
59
|
+
.addColumn("id", "integer", (c) => c.primaryKey())
|
|
60
|
+
.addColumn("ref", "text", (c) => c.notNull().unique())
|
|
61
|
+
.addColumn("userId", "text", (c) => c.notNull())
|
|
62
|
+
.addColumn("currency", "text", (c) => c.notNull())
|
|
63
|
+
.addColumn("amount", "integer", (c) => c.notNull())
|
|
64
|
+
.addColumn("status", "text", (c) => c.notNull().defaultTo("open"))
|
|
65
|
+
.addColumn("createdAt", "integer", (c) => c.notNull())
|
|
66
|
+
.addColumn("resolvedAt", "integer")
|
|
67
|
+
.execute();
|
|
68
|
+
},
|
|
69
|
+
down: async (db: Kysely<unknown>): Promise<void> => {
|
|
70
|
+
await db.schema.dropTable("pithyLedgerHolds").execute();
|
|
71
|
+
await db.schema.dropIndex("pithyLedgerTransactionsOwnerIdx").execute();
|
|
72
|
+
await db.schema.dropTable("pithyLedgerTransactions").execute();
|
|
73
|
+
await db.schema.dropTable("pithyLedgerAccounts").execute();
|
|
74
|
+
},
|
|
75
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { EXAMPLE_ADA, EXAMPLE_ALAN, EXAMPLE_GRACE } from "@pithy-sh/core/src/seed/exampleIdentities";
|
|
5
|
+
import { d1SeedGroup, defineSeed, type SeedSet } from "@pithy-sh/core/src/seed/seed";
|
|
6
|
+
import { LedgerAccount } from "../data/account";
|
|
7
|
+
import { LEDGER_ACCOUNTS_TABLE } from "../data/tables";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Where the example set sorts among the whole project's seed registry. It runs after `auth` (100),
|
|
11
|
+
* whose example seeds the users these accounts belong to, so the owning identities exist first — the
|
|
12
|
+
* order encodes that dependency, exactly like the migration registry. It need not line up with
|
|
13
|
+
* {@link LEDGER_MIGRATION_ORDER} (a different registry, composed separately by `pithy seed`).
|
|
14
|
+
*/
|
|
15
|
+
const LEDGER_EXAMPLE_SEED_ORDER = 200;
|
|
16
|
+
|
|
17
|
+
const now = () => new Date();
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A demo balance for each canonical example user ({@link EXAMPLE_ADA} et al.), in a demo currency
|
|
21
|
+
* named `coins`. The `userId`s are the shared cast from `@pithy-sh/core`, so these balances belong to
|
|
22
|
+
* the same users `auth` seeds and `leaderboard`/`multiplayer` also reference: `pithy seed` fills a
|
|
23
|
+
* fresh backend with connected data, not isolated rows. `held` stays `0` — the demo accounts have no
|
|
24
|
+
* open holds, satisfying the ledger's `CHECK (balance >= 0 AND held >= 0 AND held <= balance)`
|
|
25
|
+
* invariant trivially. Composed in only when the project turns on `seed.includeExamples`
|
|
26
|
+
* (`pithy.config.ts`), and only for `dev` and `staging` — an example fixture never targets
|
|
27
|
+
* production, regardless of that setting.
|
|
28
|
+
*/
|
|
29
|
+
export const ledgerExampleSeed: SeedSet = defineSeed({
|
|
30
|
+
name: "example",
|
|
31
|
+
order: LEDGER_EXAMPLE_SEED_ORDER,
|
|
32
|
+
environments: ["dev", "staging"],
|
|
33
|
+
example: true,
|
|
34
|
+
d1: [
|
|
35
|
+
d1SeedGroup("app", LEDGER_ACCOUNTS_TABLE, LedgerAccount, [
|
|
36
|
+
{
|
|
37
|
+
id: 1,
|
|
38
|
+
userId: EXAMPLE_ADA.id,
|
|
39
|
+
currency: "coins",
|
|
40
|
+
balance: 1000,
|
|
41
|
+
held: 0,
|
|
42
|
+
createdAt: now(),
|
|
43
|
+
updatedAt: now(),
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: 2,
|
|
47
|
+
userId: EXAMPLE_GRACE.id,
|
|
48
|
+
currency: "coins",
|
|
49
|
+
balance: 500,
|
|
50
|
+
held: 0,
|
|
51
|
+
createdAt: now(),
|
|
52
|
+
updatedAt: now(),
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
id: 3,
|
|
56
|
+
userId: EXAMPLE_ALAN.id,
|
|
57
|
+
currency: "coins",
|
|
58
|
+
balance: 750,
|
|
59
|
+
held: 0,
|
|
60
|
+
createdAt: now(),
|
|
61
|
+
updatedAt: now(),
|
|
62
|
+
},
|
|
63
|
+
]),
|
|
64
|
+
],
|
|
65
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
// GENERATED by scripts/stampVersions.ts — do not edit by hand. Regenerate with `bun run stamp-versions`.
|
|
5
|
+
//
|
|
6
|
+
// A Worker cannot read its own package.json, so this is how @pithy-sh/ledger knows its own version at
|
|
7
|
+
// runtime. The capability attaches it, and `GET /control-plane/manifest` reports it per capability —
|
|
8
|
+
// which is what answers "should this project upgrade" and "is this customer exposed to what we just
|
|
9
|
+
// fixed". Those questions are only answerable per module, because a project composes some capabilities
|
|
10
|
+
// and not others.
|
|
11
|
+
|
|
12
|
+
/** This package's npm name — the join key against a release feed. */
|
|
13
|
+
export const PACKAGE_NAME = "@pithy-sh/ledger";
|
|
14
|
+
|
|
15
|
+
/** This package's version, stamped from its own package.json at generation time. */
|
|
16
|
+
export const PACKAGE_VERSION = "0.1.0";
|