@crewhaus/durable-state 0.1.4 → 0.1.5

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.
@@ -0,0 +1,168 @@
1
+ import { CrewhausError } from "@crewhaus/errors";
2
+ export declare class DurableStateError extends CrewhausError {
3
+ readonly name = "DurableStateError";
4
+ constructor(message: string, cause?: unknown);
5
+ }
6
+ export interface DedupStore {
7
+ /**
8
+ * Atomic check-and-record. Returns `true` when `key` was already recorded
9
+ * (caller should treat the event as a duplicate and skip it), `false` when
10
+ * this call recorded it first. MUST be atomic across concurrent callers —
11
+ * for the same key, exactly one concurrent `remember` returns `false`.
12
+ */
13
+ remember(key: string): Promise<boolean>;
14
+ /** Drop all recorded keys. Tests/operator tooling. */
15
+ clear(): Promise<void>;
16
+ /** Release any underlying resources. Idempotent. */
17
+ close(): Promise<void>;
18
+ }
19
+ export type InMemoryDedupStoreOptions = {
20
+ /** Max keys remembered before oldest-first eviction. Default 10 000. */
21
+ readonly capacity?: number;
22
+ };
23
+ /**
24
+ * Bounded insertion-ordered set — the exact structure the channel-bot
25
+ * gateway used inline. Volatile: restarts forget everything (the adapters'
26
+ * signature replay-windows bound that exposure).
27
+ */
28
+ export declare class InMemoryDedupStore implements DedupStore {
29
+ private readonly seen;
30
+ private readonly capacity;
31
+ constructor(opts?: InMemoryDedupStoreOptions);
32
+ remember(key: string): Promise<boolean>;
33
+ clear(): Promise<void>;
34
+ close(): Promise<void>;
35
+ }
36
+ export type SqliteDedupStoreOptions = {
37
+ /** Database file path. All processes on the host must use the same path. */
38
+ readonly path: string;
39
+ /**
40
+ * How long a recorded key stays a duplicate. Default 24 h — comfortably
41
+ * past every adapter's signature replay-window, which is the window an
42
+ * attacker can replay a captured webhook within anyway.
43
+ */
44
+ readonly ttlMs?: number;
45
+ /** SQLite busy timeout — how long a writer waits for the lock. Default 5 s. */
46
+ readonly busyTimeoutMs?: number;
47
+ /** Test seam: clock. */
48
+ readonly _now?: () => number;
49
+ };
50
+ /**
51
+ * Cross-process dedup on one host. `remember` runs prune + insert in a
52
+ * single IMMEDIATE transaction: the write lock is held across both, so two
53
+ * processes racing on the same key serialize — exactly one inserts
54
+ * (`changes === 1`) and the other observes the existing row. The prune
55
+ * inside the same transaction also closes the check/prune/insert race the
56
+ * design review flagged.
57
+ */
58
+ export declare class SqliteDedupStore implements DedupStore {
59
+ private readonly db;
60
+ private readonly ttlMs;
61
+ private readonly now;
62
+ private readonly rememberTx;
63
+ private closed;
64
+ constructor(opts: SqliteDedupStoreOptions);
65
+ remember(key: string): Promise<boolean>;
66
+ clear(): Promise<void>;
67
+ close(): Promise<void>;
68
+ }
69
+ export type UsageDelta = {
70
+ readonly input: number;
71
+ readonly output: number;
72
+ };
73
+ export type BudgetLimits = {
74
+ readonly maxInputTokens: number;
75
+ readonly maxOutputTokens: number;
76
+ };
77
+ export type TryReserveResult = {
78
+ readonly ok: true;
79
+ } | {
80
+ /** Which dimension exceeded, with the totals for the error message. */
81
+ readonly ok: false;
82
+ readonly reason: "input" | "output";
83
+ readonly total: number;
84
+ readonly limit: number;
85
+ };
86
+ export interface BudgetStore {
87
+ /**
88
+ * Atomically reserve `delta` for `tenantId` if recorded + reserved +
89
+ * delta stays under `limits` on BOTH dimensions; nothing is reserved on
90
+ * failure. MUST be atomic across concurrent callers — two reservations
91
+ * that individually fit but jointly exceed must not both succeed.
92
+ */
93
+ tryReserve(tenantId: string, delta: UsageDelta, limits: BudgetLimits): Promise<TryReserveResult>;
94
+ /** Release a reservation made by `tryReserve` (clamped at zero). */
95
+ release(tenantId: string, delta: UsageDelta): Promise<void>;
96
+ /** Add actual usage to the tenant's recorded running total. */
97
+ recordUsage(tenantId: string, delta: UsageDelta): Promise<void>;
98
+ /** Recorded usage (excludes in-flight reservations). */
99
+ usage(tenantId: string): Promise<{
100
+ input: number;
101
+ output: number;
102
+ }>;
103
+ /**
104
+ * Zero ALL reservations. Crash recovery for the durable backend: a process
105
+ * that died mid-request leaks its reservation. Call at boot when this
106
+ * process is the only writer, or from operator tooling after draining.
107
+ */
108
+ clearReservations(): Promise<void>;
109
+ /** Drop all state. Tests. */
110
+ clear(): Promise<void>;
111
+ /** Release any underlying resources. Idempotent. */
112
+ close(): Promise<void>;
113
+ }
114
+ /**
115
+ * The exact recorded/reserved maps gateway-server used inline (PR #206),
116
+ * factored behind the interface. Single-process semantics are unchanged:
117
+ * synchronous map operations make every method trivially atomic.
118
+ */
119
+ export declare class InMemoryBudgetStore implements BudgetStore {
120
+ private readonly recorded;
121
+ private readonly reserved;
122
+ tryReserve(tenantId: string, delta: UsageDelta, limits: BudgetLimits): Promise<TryReserveResult>;
123
+ release(tenantId: string, delta: UsageDelta): Promise<void>;
124
+ recordUsage(tenantId: string, delta: UsageDelta): Promise<void>;
125
+ usage(tenantId: string): Promise<{
126
+ input: number;
127
+ output: number;
128
+ }>;
129
+ clearReservations(): Promise<void>;
130
+ clear(): Promise<void>;
131
+ close(): Promise<void>;
132
+ }
133
+ export type SqliteBudgetStoreOptions = {
134
+ /** Database file path. All processes on the host must use the same path. */
135
+ readonly path: string;
136
+ /** SQLite busy timeout — how long a writer waits for the lock. Default 5 s. */
137
+ readonly busyTimeoutMs?: number;
138
+ };
139
+ /**
140
+ * Cross-process budget accounting on one host. `tryReserve` runs
141
+ * read-check-update in a single IMMEDIATE transaction, so two processes
142
+ * whose reservations individually fit but jointly exceed serialize on the
143
+ * write lock — the second sees the first's reservation and is refused.
144
+ */
145
+ export declare class SqliteBudgetStore implements BudgetStore {
146
+ private readonly db;
147
+ private readonly reserveTx;
148
+ private readonly releaseTx;
149
+ private closed;
150
+ constructor(opts: SqliteBudgetStoreOptions);
151
+ tryReserve(tenantId: string, delta: UsageDelta, limits: BudgetLimits): Promise<TryReserveResult>;
152
+ release(tenantId: string, delta: UsageDelta): Promise<void>;
153
+ recordUsage(tenantId: string, delta: UsageDelta): Promise<void>;
154
+ usage(tenantId: string): Promise<{
155
+ input: number;
156
+ output: number;
157
+ }>;
158
+ clearReservations(): Promise<void>;
159
+ clear(): Promise<void>;
160
+ close(): Promise<void>;
161
+ }
162
+ /** Build a DedupStore from a spec string: `"memory"` or `"sqlite:<path>"`. */
163
+ export declare function createDedupStore(spec: string, opts?: {
164
+ capacity?: number;
165
+ ttlMs?: number;
166
+ }): DedupStore;
167
+ /** Build a BudgetStore from a spec string: `"memory"` or `"sqlite:<path>"`. */
168
+ export declare function createBudgetStore(spec: string): BudgetStore;
package/dist/index.js ADDED
@@ -0,0 +1,314 @@
1
+ /**
2
+ * `@crewhaus/durable-state` — pluggable dedup + budget stores (audit
3
+ * follow-up R3).
4
+ *
5
+ * Two pieces of security-relevant state were in-memory per process:
6
+ *
7
+ * 1. Webhook replay-dedup (the channel-bot gateway's LRU `Set` of
8
+ * idempotency keys). A daemon restart or a second replica forgets every
9
+ * seen key, so a captured webhook can be replayed against the fresh
10
+ * process inside its signature replay-window.
11
+ * 2. Tenant budget accounting (gateway-server's recorded + in-flight
12
+ * reservation maps, PR #206). Each replica enforces the budget against
13
+ * its own counters only — N replicas multiply every tenant budget by N.
14
+ *
15
+ * This package defines the store interfaces those consumers now accept, plus
16
+ * two built-in backends:
17
+ *
18
+ * - In-memory (default, behavior-preserving): exactly the structures the
19
+ * consumers used inline, factored behind the interface.
20
+ * - `bun:sqlite` (zero new dependencies — built into Bun): durable across
21
+ * restarts and shared across processes ON ONE HOST. WAL journaling, a
22
+ * busy timeout, and IMMEDIATE transactions make the check-and-record /
23
+ * check-and-reserve operations atomic across concurrent processes (the
24
+ * write lock is held across the read and the write).
25
+ *
26
+ * Multi-HOST deployments need a network store (Redis, Postgres); implement
27
+ * these interfaces against it — the consumers don't care. Follows the
28
+ * checkpoint-store pluggable-adapter pattern.
29
+ *
30
+ * Crash residual (documented): a process that dies between `tryReserve` and
31
+ * `release` leaks its reservation in the sqlite backend (in-memory state
32
+ * dies with the process; durable state doesn't). Reservations are
33
+ * request-scoped and small; single-writer deployments can call
34
+ * `clearReservations()` at boot, and operators can do the same from tooling
35
+ * after draining traffic.
36
+ */
37
+ import { Database } from "bun:sqlite";
38
+ import { CrewhausError } from "@crewhaus/errors";
39
+ export class DurableStateError extends CrewhausError {
40
+ name = "DurableStateError";
41
+ constructor(message, cause) {
42
+ super("config", message, cause);
43
+ }
44
+ }
45
+ /**
46
+ * Bounded insertion-ordered set — the exact structure the channel-bot
47
+ * gateway used inline. Volatile: restarts forget everything (the adapters'
48
+ * signature replay-windows bound that exposure).
49
+ */
50
+ export class InMemoryDedupStore {
51
+ seen = new Set();
52
+ capacity;
53
+ constructor(opts = {}) {
54
+ this.capacity = opts.capacity ?? 10_000;
55
+ }
56
+ async remember(key) {
57
+ if (this.seen.has(key))
58
+ return true;
59
+ this.seen.add(key);
60
+ if (this.seen.size > this.capacity) {
61
+ const oldest = this.seen.values().next().value;
62
+ if (oldest !== undefined)
63
+ this.seen.delete(oldest);
64
+ }
65
+ return false;
66
+ }
67
+ async clear() {
68
+ this.seen.clear();
69
+ }
70
+ async close() {
71
+ // nothing to release
72
+ }
73
+ }
74
+ /**
75
+ * Cross-process dedup on one host. `remember` runs prune + insert in a
76
+ * single IMMEDIATE transaction: the write lock is held across both, so two
77
+ * processes racing on the same key serialize — exactly one inserts
78
+ * (`changes === 1`) and the other observes the existing row. The prune
79
+ * inside the same transaction also closes the check/prune/insert race the
80
+ * design review flagged.
81
+ */
82
+ export class SqliteDedupStore {
83
+ db;
84
+ ttlMs;
85
+ now;
86
+ rememberTx;
87
+ closed = false;
88
+ constructor(opts) {
89
+ this.db = new Database(opts.path);
90
+ this.db.run("PRAGMA journal_mode = WAL");
91
+ this.db.run(`PRAGMA busy_timeout = ${opts.busyTimeoutMs ?? 5000}`);
92
+ this.db.run("CREATE TABLE IF NOT EXISTS dedup (key TEXT PRIMARY KEY, expires_at INTEGER NOT NULL)");
93
+ this.ttlMs = opts.ttlMs ?? 24 * 60 * 60 * 1000;
94
+ this.now = opts._now ?? Date.now;
95
+ const prune = this.db.prepare("DELETE FROM dedup WHERE expires_at <= ?");
96
+ const insert = this.db.prepare("INSERT OR IGNORE INTO dedup (key, expires_at) VALUES (?, ?)");
97
+ const tx = this.db.transaction((key) => {
98
+ const t = this.now();
99
+ prune.run(t);
100
+ const r = insert.run(key, t + this.ttlMs);
101
+ return r.changes === 0; // 0 changes = row existed = already seen
102
+ });
103
+ this.rememberTx = (key) => tx.immediate(key);
104
+ }
105
+ async remember(key) {
106
+ return this.rememberTx(key);
107
+ }
108
+ async clear() {
109
+ this.db.run("DELETE FROM dedup");
110
+ }
111
+ async close() {
112
+ if (this.closed)
113
+ return;
114
+ this.closed = true;
115
+ this.db.close();
116
+ }
117
+ }
118
+ /**
119
+ * The exact recorded/reserved maps gateway-server used inline (PR #206),
120
+ * factored behind the interface. Single-process semantics are unchanged:
121
+ * synchronous map operations make every method trivially atomic.
122
+ */
123
+ export class InMemoryBudgetStore {
124
+ recorded = new Map();
125
+ reserved = new Map();
126
+ async tryReserve(tenantId, delta, limits) {
127
+ const used = this.recorded.get(tenantId) ?? { input: 0, output: 0 };
128
+ const res = this.reserved.get(tenantId) ?? { input: 0, output: 0 };
129
+ const totalInput = used.input + res.input + delta.input;
130
+ const totalOutput = used.output + res.output + delta.output;
131
+ if (totalInput >= limits.maxInputTokens) {
132
+ return { ok: false, reason: "input", total: totalInput, limit: limits.maxInputTokens };
133
+ }
134
+ if (totalOutput >= limits.maxOutputTokens) {
135
+ return { ok: false, reason: "output", total: totalOutput, limit: limits.maxOutputTokens };
136
+ }
137
+ if (delta.input !== 0 || delta.output !== 0) {
138
+ this.reserved.set(tenantId, {
139
+ input: res.input + delta.input,
140
+ output: res.output + delta.output,
141
+ });
142
+ }
143
+ return { ok: true };
144
+ }
145
+ async release(tenantId, delta) {
146
+ if (delta.input === 0 && delta.output === 0)
147
+ return;
148
+ const cur = this.reserved.get(tenantId) ?? { input: 0, output: 0 };
149
+ const next = {
150
+ input: Math.max(0, cur.input - delta.input),
151
+ output: Math.max(0, cur.output - delta.output),
152
+ };
153
+ if (next.input === 0 && next.output === 0)
154
+ this.reserved.delete(tenantId);
155
+ else
156
+ this.reserved.set(tenantId, next);
157
+ }
158
+ async recordUsage(tenantId, delta) {
159
+ const cur = this.recorded.get(tenantId) ?? { input: 0, output: 0 };
160
+ this.recorded.set(tenantId, {
161
+ input: cur.input + delta.input,
162
+ output: cur.output + delta.output,
163
+ });
164
+ }
165
+ async usage(tenantId) {
166
+ return this.recorded.get(tenantId) ?? { input: 0, output: 0 };
167
+ }
168
+ async clearReservations() {
169
+ this.reserved.clear();
170
+ }
171
+ async clear() {
172
+ this.recorded.clear();
173
+ this.reserved.clear();
174
+ }
175
+ async close() {
176
+ // nothing to release
177
+ }
178
+ }
179
+ /**
180
+ * Cross-process budget accounting on one host. `tryReserve` runs
181
+ * read-check-update in a single IMMEDIATE transaction, so two processes
182
+ * whose reservations individually fit but jointly exceed serialize on the
183
+ * write lock — the second sees the first's reservation and is refused.
184
+ */
185
+ export class SqliteBudgetStore {
186
+ db;
187
+ reserveTx;
188
+ releaseTx;
189
+ closed = false;
190
+ constructor(opts) {
191
+ this.db = new Database(opts.path);
192
+ this.db.run("PRAGMA journal_mode = WAL");
193
+ this.db.run(`PRAGMA busy_timeout = ${opts.busyTimeoutMs ?? 5000}`);
194
+ this.db.run(`CREATE TABLE IF NOT EXISTS budget (
195
+ tenant_id TEXT PRIMARY KEY,
196
+ recorded_input INTEGER NOT NULL DEFAULT 0,
197
+ recorded_output INTEGER NOT NULL DEFAULT 0,
198
+ reserved_input INTEGER NOT NULL DEFAULT 0,
199
+ reserved_output INTEGER NOT NULL DEFAULT 0
200
+ )`);
201
+ const select = this.db.prepare("SELECT recorded_input, recorded_output, reserved_input, reserved_output FROM budget WHERE tenant_id = ?");
202
+ const upsertReserve = this.db.prepare(`INSERT INTO budget
203
+ (tenant_id, reserved_input, reserved_output) VALUES (?, ?, ?)
204
+ ON CONFLICT(tenant_id) DO UPDATE SET
205
+ reserved_input = reserved_input + excluded.reserved_input,
206
+ reserved_output = reserved_output + excluded.reserved_output`);
207
+ const releaseUpdate = this.db.prepare(`UPDATE budget SET
208
+ reserved_input = MAX(0, reserved_input - ?),
209
+ reserved_output = MAX(0, reserved_output - ?)
210
+ WHERE tenant_id = ?`);
211
+ const reserveTxn = this.db.transaction((tenantId, delta, limits) => {
212
+ const row = select.get(tenantId) ?? {
213
+ recorded_input: 0,
214
+ recorded_output: 0,
215
+ reserved_input: 0,
216
+ reserved_output: 0,
217
+ };
218
+ const totalInput = row.recorded_input + row.reserved_input + delta.input;
219
+ const totalOutput = row.recorded_output + row.reserved_output + delta.output;
220
+ if (totalInput >= limits.maxInputTokens) {
221
+ return { ok: false, reason: "input", total: totalInput, limit: limits.maxInputTokens };
222
+ }
223
+ if (totalOutput >= limits.maxOutputTokens) {
224
+ return {
225
+ ok: false,
226
+ reason: "output",
227
+ total: totalOutput,
228
+ limit: limits.maxOutputTokens,
229
+ };
230
+ }
231
+ if (delta.input !== 0 || delta.output !== 0) {
232
+ upsertReserve.run(tenantId, delta.input, delta.output);
233
+ }
234
+ return { ok: true };
235
+ });
236
+ this.reserveTx = (tenantId, delta, limits) => reserveTxn.immediate(tenantId, delta, limits);
237
+ const releaseTxn = this.db.transaction((tenantId, delta) => {
238
+ releaseUpdate.run(delta.input, delta.output, tenantId);
239
+ });
240
+ this.releaseTx = (tenantId, delta) => {
241
+ releaseTxn.immediate(tenantId, delta);
242
+ };
243
+ }
244
+ async tryReserve(tenantId, delta, limits) {
245
+ return this.reserveTx(tenantId, delta, limits);
246
+ }
247
+ async release(tenantId, delta) {
248
+ if (delta.input === 0 && delta.output === 0)
249
+ return;
250
+ this.releaseTx(tenantId, delta);
251
+ }
252
+ async recordUsage(tenantId, delta) {
253
+ this.db
254
+ .prepare(`INSERT INTO budget
255
+ (tenant_id, recorded_input, recorded_output) VALUES (?, ?, ?)
256
+ ON CONFLICT(tenant_id) DO UPDATE SET
257
+ recorded_input = recorded_input + excluded.recorded_input,
258
+ recorded_output = recorded_output + excluded.recorded_output`)
259
+ .run(tenantId, delta.input, delta.output);
260
+ }
261
+ async usage(tenantId) {
262
+ const row = this.db
263
+ .prepare("SELECT recorded_input, recorded_output FROM budget WHERE tenant_id = ?")
264
+ .get(tenantId);
265
+ return row === null
266
+ ? { input: 0, output: 0 }
267
+ : { input: row.recorded_input, output: row.recorded_output };
268
+ }
269
+ async clearReservations() {
270
+ this.db.run("UPDATE budget SET reserved_input = 0, reserved_output = 0");
271
+ }
272
+ async clear() {
273
+ this.db.run("DELETE FROM budget");
274
+ }
275
+ async close() {
276
+ if (this.closed)
277
+ return;
278
+ this.closed = true;
279
+ this.db.close();
280
+ }
281
+ }
282
+ // ---------------------------------------------------------------------------
283
+ // Spec-string factories — env-driven backend selection for generated daemons.
284
+ // ---------------------------------------------------------------------------
285
+ function parseSpec(spec) {
286
+ if (spec === "memory")
287
+ return { backend: "memory" };
288
+ if (spec.startsWith("sqlite:")) {
289
+ const path = spec.slice("sqlite:".length);
290
+ if (path === "") {
291
+ throw new DurableStateError(`invalid store spec "${spec}": sqlite: needs a file path`);
292
+ }
293
+ return { backend: "sqlite", path };
294
+ }
295
+ throw new DurableStateError(`invalid store spec "${spec}": expected "memory" or "sqlite:<path>"`);
296
+ }
297
+ /** Build a DedupStore from a spec string: `"memory"` or `"sqlite:<path>"`. */
298
+ export function createDedupStore(spec, opts = {}) {
299
+ const parsed = parseSpec(spec);
300
+ if (parsed.backend === "memory") {
301
+ return new InMemoryDedupStore(opts.capacity !== undefined ? { capacity: opts.capacity } : {});
302
+ }
303
+ return new SqliteDedupStore({
304
+ path: parsed.path,
305
+ ...(opts.ttlMs !== undefined ? { ttlMs: opts.ttlMs } : {}),
306
+ });
307
+ }
308
+ /** Build a BudgetStore from a spec string: `"memory"` or `"sqlite:<path>"`. */
309
+ export function createBudgetStore(spec) {
310
+ const parsed = parseSpec(spec);
311
+ if (parsed.backend === "memory")
312
+ return new InMemoryBudgetStore();
313
+ return new SqliteBudgetStore({ path: parsed.path });
314
+ }
package/package.json CHANGED
@@ -1,18 +1,21 @@
1
1
  {
2
2
  "name": "@crewhaus/durable-state",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "description": "Pluggable dedup + budget stores (audit follow-up R3) — in-memory defaults, bun:sqlite cross-process backend",
6
- "main": "src/index.ts",
7
- "types": "src/index.ts",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
8
  "exports": {
9
- ".": "./src/index.ts"
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
10
13
  },
11
14
  "scripts": {
12
15
  "test": "bun test src"
13
16
  },
14
17
  "dependencies": {
15
- "@crewhaus/errors": "0.1.4"
18
+ "@crewhaus/errors": "0.1.5"
16
19
  },
17
20
  "license": "Apache-2.0",
18
21
  "author": {
@@ -32,5 +35,5 @@
32
35
  "publishConfig": {
33
36
  "access": "public"
34
37
  },
35
- "files": ["src", "README.md", "LICENSE", "NOTICE"]
38
+ "files": ["dist", "README.md", "LICENSE", "NOTICE"]
36
39
  }
package/src/index.test.ts DELETED
@@ -1,236 +0,0 @@
1
- /**
2
- * Audit follow-up R3 — store contract tests. The SAME behavioral suite runs
3
- * against both backends so the sqlite store is interchangeable with the
4
- * in-memory default; sqlite-only suites then cover what the in-memory store
5
- * can't promise: cross-instance (cross-process) visibility and atomicity.
6
- */
7
- import { afterEach, describe, expect, test } from "bun:test";
8
- import { mkdtempSync, rmSync } from "node:fs";
9
- import { tmpdir } from "node:os";
10
- import * as path from "node:path";
11
- import {
12
- type BudgetStore,
13
- type DedupStore,
14
- InMemoryBudgetStore,
15
- InMemoryDedupStore,
16
- SqliteBudgetStore,
17
- SqliteDedupStore,
18
- createBudgetStore,
19
- createDedupStore,
20
- } from "./index";
21
-
22
- let tmp: string | undefined;
23
- const opened: Array<DedupStore | BudgetStore> = [];
24
-
25
- afterEach(async () => {
26
- while (opened.length > 0) {
27
- await opened.pop()?.close();
28
- }
29
- if (tmp !== undefined) {
30
- rmSync(tmp, { recursive: true, force: true });
31
- tmp = undefined;
32
- }
33
- });
34
-
35
- function dbPath(name: string): string {
36
- if (tmp === undefined) tmp = mkdtempSync(path.join(tmpdir(), "durable-state-"));
37
- return path.join(tmp, name);
38
- }
39
-
40
- function track<T extends DedupStore | BudgetStore>(store: T): T {
41
- opened.push(store);
42
- return store;
43
- }
44
-
45
- const LIMITS = { maxInputTokens: 100, maxOutputTokens: 100 };
46
-
47
- // ---------------------------------------------------------------------------
48
- // Backend-agnostic contracts
49
- // ---------------------------------------------------------------------------
50
-
51
- const dedupBackends: Array<[string, () => DedupStore]> = [
52
- ["InMemoryDedupStore", () => track(new InMemoryDedupStore())],
53
- ["SqliteDedupStore", () => track(new SqliteDedupStore({ path: dbPath("dedup.db") }))],
54
- ];
55
-
56
- describe.each(dedupBackends)("DedupStore contract — %s", (_name, make) => {
57
- test("first remember returns false, repeat returns true", async () => {
58
- const store = make();
59
- expect(await store.remember("evt_1")).toBe(false);
60
- expect(await store.remember("evt_1")).toBe(true);
61
- expect(await store.remember("evt_2")).toBe(false);
62
- });
63
-
64
- test("clear() forgets recorded keys", async () => {
65
- const store = make();
66
- await store.remember("evt_1");
67
- await store.clear();
68
- expect(await store.remember("evt_1")).toBe(false);
69
- });
70
- });
71
-
72
- const budgetBackends: Array<[string, () => BudgetStore]> = [
73
- ["InMemoryBudgetStore", () => track(new InMemoryBudgetStore())],
74
- ["SqliteBudgetStore", () => track(new SqliteBudgetStore({ path: dbPath("budget.db") }))],
75
- ];
76
-
77
- describe.each(budgetBackends)("BudgetStore contract — %s", (_name, make) => {
78
- test("reserve within limits succeeds; usage stays recorded-only", async () => {
79
- const store = make();
80
- expect(await store.tryReserve("t1", { input: 40, output: 10 }, LIMITS)).toEqual({ ok: true });
81
- expect(await store.usage("t1")).toEqual({ input: 0, output: 0 });
82
- });
83
-
84
- test("recorded + reserved + delta over the limit is refused with totals", async () => {
85
- const store = make();
86
- await store.recordUsage("t1", { input: 60, output: 0 });
87
- expect(await store.tryReserve("t1", { input: 30, output: 0 }, LIMITS)).toEqual({ ok: true });
88
- const refused = await store.tryReserve("t1", { input: 20, output: 0 }, LIMITS);
89
- expect(refused).toEqual({ ok: false, reason: "input", total: 110, limit: 100 });
90
- });
91
-
92
- test("a refused reserve reserves NOTHING", async () => {
93
- const store = make();
94
- await store.recordUsage("t1", { input: 95, output: 0 });
95
- expect((await store.tryReserve("t1", { input: 50, output: 0 }, LIMITS)).ok).toBe(false);
96
- // A small reserve that fits must still succeed — nothing leaked.
97
- expect(await store.tryReserve("t1", { input: 2, output: 0 }, LIMITS)).toEqual({ ok: true });
98
- });
99
-
100
- test("a zero-delta reserve still refuses when recorded usage is at the cap", async () => {
101
- const store = make();
102
- await store.recordUsage("t1", { input: 100, output: 0 });
103
- const refused = await store.tryReserve("t1", { input: 0, output: 0 }, LIMITS);
104
- expect(refused).toEqual({ ok: false, reason: "input", total: 100, limit: 100 });
105
- });
106
-
107
- test("the output dimension is enforced independently", async () => {
108
- const store = make();
109
- await store.recordUsage("t1", { input: 0, output: 99 });
110
- const refused = await store.tryReserve("t1", { input: 1, output: 5 }, LIMITS);
111
- expect(refused).toEqual({ ok: false, reason: "output", total: 104, limit: 100 });
112
- });
113
-
114
- test("release frees reserved capacity and clamps at zero", async () => {
115
- const store = make();
116
- expect(await store.tryReserve("t1", { input: 90, output: 0 }, LIMITS)).toEqual({ ok: true });
117
- expect((await store.tryReserve("t1", { input: 90, output: 0 }, LIMITS)).ok).toBe(false);
118
- await store.release("t1", { input: 90, output: 0 });
119
- expect(await store.tryReserve("t1", { input: 90, output: 0 }, LIMITS)).toEqual({ ok: true });
120
- // Over-release must clamp, not go negative.
121
- await store.release("t1", { input: 9999, output: 9999 });
122
- expect((await store.tryReserve("t1", { input: 99, output: 0 }, LIMITS)).ok).toBe(true);
123
- });
124
-
125
- test("recordUsage accumulates and usage() reads it back", async () => {
126
- const store = make();
127
- await store.recordUsage("t1", { input: 10, output: 5 });
128
- await store.recordUsage("t1", { input: 7, output: 3 });
129
- expect(await store.usage("t1")).toEqual({ input: 17, output: 8 });
130
- expect(await store.usage("other")).toEqual({ input: 0, output: 0 });
131
- });
132
-
133
- test("tenants are isolated", async () => {
134
- const store = make();
135
- await store.recordUsage("t1", { input: 99, output: 0 });
136
- expect(await store.tryReserve("t2", { input: 50, output: 0 }, LIMITS)).toEqual({ ok: true });
137
- });
138
-
139
- test("clearReservations frees in-flight capacity but keeps recorded usage", async () => {
140
- const store = make();
141
- await store.recordUsage("t1", { input: 50, output: 0 });
142
- expect(await store.tryReserve("t1", { input: 49, output: 0 }, LIMITS)).toEqual({ ok: true });
143
- expect((await store.tryReserve("t1", { input: 49, output: 0 }, LIMITS)).ok).toBe(false);
144
- await store.clearReservations();
145
- expect(await store.tryReserve("t1", { input: 49, output: 0 }, LIMITS)).toEqual({ ok: true });
146
- expect(await store.usage("t1")).toEqual({ input: 50, output: 0 });
147
- });
148
- });
149
-
150
- // ---------------------------------------------------------------------------
151
- // SQLite-only: cross-instance (stand-in for cross-process) guarantees
152
- // ---------------------------------------------------------------------------
153
-
154
- describe("SqliteDedupStore — cross-instance", () => {
155
- test("a key remembered by one instance is a duplicate for another on the same file", async () => {
156
- const file = dbPath("shared-dedup.db");
157
- const a = track(new SqliteDedupStore({ path: file }));
158
- const b = track(new SqliteDedupStore({ path: file }));
159
- expect(await a.remember("evt_shared")).toBe(false);
160
- expect(await b.remember("evt_shared")).toBe(true);
161
- });
162
-
163
- test("concurrent remember on the same key: exactly one instance wins", async () => {
164
- const file = dbPath("race-dedup.db");
165
- const a = track(new SqliteDedupStore({ path: file }));
166
- const b = track(new SqliteDedupStore({ path: file }));
167
- const results = await Promise.all([a.remember("evt_race"), b.remember("evt_race")]);
168
- expect(results.filter((seen) => seen === false).length).toBe(1);
169
- });
170
-
171
- test("expired keys can be remembered again (TTL prune inside the txn)", async () => {
172
- let t = 1_000_000;
173
- const store = track(
174
- new SqliteDedupStore({ path: dbPath("ttl-dedup.db"), ttlMs: 60_000, _now: () => t }),
175
- );
176
- expect(await store.remember("evt_ttl")).toBe(false);
177
- expect(await store.remember("evt_ttl")).toBe(true);
178
- t += 60_001; // past expiry
179
- expect(await store.remember("evt_ttl")).toBe(false);
180
- });
181
-
182
- test("survives instance restart (durability)", async () => {
183
- const file = dbPath("restart-dedup.db");
184
- const first = new SqliteDedupStore({ path: file });
185
- await first.remember("evt_durable");
186
- await first.close();
187
- const second = track(new SqliteDedupStore({ path: file }));
188
- expect(await second.remember("evt_durable")).toBe(true);
189
- });
190
- });
191
-
192
- describe("SqliteBudgetStore — cross-instance", () => {
193
- test("usage recorded by one instance bounds reservations in another", async () => {
194
- const file = dbPath("shared-budget.db");
195
- const a = track(new SqliteBudgetStore({ path: file }));
196
- const b = track(new SqliteBudgetStore({ path: file }));
197
- await a.recordUsage("t1", { input: 80, output: 0 });
198
- const refused = await b.tryReserve("t1", { input: 30, output: 0 }, LIMITS);
199
- expect(refused).toEqual({ ok: false, reason: "input", total: 110, limit: 100 });
200
- });
201
-
202
- test("jointly-exceeding concurrent reserves: at most one succeeds", async () => {
203
- const file = dbPath("race-budget.db");
204
- const a = track(new SqliteBudgetStore({ path: file }));
205
- const b = track(new SqliteBudgetStore({ path: file }));
206
- // Each fits alone (60 < 100); together they exceed (120 >= 100). The
207
- // IMMEDIATE transaction serializes them: the loser sees the winner's
208
- // reservation.
209
- const results = await Promise.all([
210
- a.tryReserve("t1", { input: 60, output: 0 }, LIMITS),
211
- b.tryReserve("t1", { input: 60, output: 0 }, LIMITS),
212
- ]);
213
- expect(results.filter((r) => r.ok).length).toBe(1);
214
- });
215
- });
216
-
217
- // ---------------------------------------------------------------------------
218
- // Spec-string factories
219
- // ---------------------------------------------------------------------------
220
-
221
- describe("createDedupStore / createBudgetStore", () => {
222
- test('"memory" builds the in-memory backends', () => {
223
- expect(track(createDedupStore("memory"))).toBeInstanceOf(InMemoryDedupStore);
224
- expect(track(createBudgetStore("memory"))).toBeInstanceOf(InMemoryBudgetStore);
225
- });
226
-
227
- test('"sqlite:<path>" builds the sqlite backends', () => {
228
- expect(track(createDedupStore(`sqlite:${dbPath("f1.db")}`))).toBeInstanceOf(SqliteDedupStore);
229
- expect(track(createBudgetStore(`sqlite:${dbPath("f2.db")}`))).toBeInstanceOf(SqliteBudgetStore);
230
- });
231
-
232
- test("unknown specs fail closed with a clear error", () => {
233
- expect(() => createDedupStore("redis://nope")).toThrow(/expected "memory" or "sqlite:<path>"/);
234
- expect(() => createBudgetStore("sqlite:")).toThrow(/needs a file path/);
235
- });
236
- });
package/src/index.ts DELETED
@@ -1,463 +0,0 @@
1
- /**
2
- * `@crewhaus/durable-state` — pluggable dedup + budget stores (audit
3
- * follow-up R3).
4
- *
5
- * Two pieces of security-relevant state were in-memory per process:
6
- *
7
- * 1. Webhook replay-dedup (the channel-bot gateway's LRU `Set` of
8
- * idempotency keys). A daemon restart or a second replica forgets every
9
- * seen key, so a captured webhook can be replayed against the fresh
10
- * process inside its signature replay-window.
11
- * 2. Tenant budget accounting (gateway-server's recorded + in-flight
12
- * reservation maps, PR #206). Each replica enforces the budget against
13
- * its own counters only — N replicas multiply every tenant budget by N.
14
- *
15
- * This package defines the store interfaces those consumers now accept, plus
16
- * two built-in backends:
17
- *
18
- * - In-memory (default, behavior-preserving): exactly the structures the
19
- * consumers used inline, factored behind the interface.
20
- * - `bun:sqlite` (zero new dependencies — built into Bun): durable across
21
- * restarts and shared across processes ON ONE HOST. WAL journaling, a
22
- * busy timeout, and IMMEDIATE transactions make the check-and-record /
23
- * check-and-reserve operations atomic across concurrent processes (the
24
- * write lock is held across the read and the write).
25
- *
26
- * Multi-HOST deployments need a network store (Redis, Postgres); implement
27
- * these interfaces against it — the consumers don't care. Follows the
28
- * checkpoint-store pluggable-adapter pattern.
29
- *
30
- * Crash residual (documented): a process that dies between `tryReserve` and
31
- * `release` leaks its reservation in the sqlite backend (in-memory state
32
- * dies with the process; durable state doesn't). Reservations are
33
- * request-scoped and small; single-writer deployments can call
34
- * `clearReservations()` at boot, and operators can do the same from tooling
35
- * after draining traffic.
36
- */
37
- import { Database } from "bun:sqlite";
38
- import { CrewhausError } from "@crewhaus/errors";
39
-
40
- export class DurableStateError extends CrewhausError {
41
- override readonly name = "DurableStateError";
42
- constructor(message: string, cause?: unknown) {
43
- super("config", message, cause);
44
- }
45
- }
46
-
47
- // ---------------------------------------------------------------------------
48
- // Dedup store
49
- // ---------------------------------------------------------------------------
50
-
51
- export interface DedupStore {
52
- /**
53
- * Atomic check-and-record. Returns `true` when `key` was already recorded
54
- * (caller should treat the event as a duplicate and skip it), `false` when
55
- * this call recorded it first. MUST be atomic across concurrent callers —
56
- * for the same key, exactly one concurrent `remember` returns `false`.
57
- */
58
- remember(key: string): Promise<boolean>;
59
- /** Drop all recorded keys. Tests/operator tooling. */
60
- clear(): Promise<void>;
61
- /** Release any underlying resources. Idempotent. */
62
- close(): Promise<void>;
63
- }
64
-
65
- export type InMemoryDedupStoreOptions = {
66
- /** Max keys remembered before oldest-first eviction. Default 10 000. */
67
- readonly capacity?: number;
68
- };
69
-
70
- /**
71
- * Bounded insertion-ordered set — the exact structure the channel-bot
72
- * gateway used inline. Volatile: restarts forget everything (the adapters'
73
- * signature replay-windows bound that exposure).
74
- */
75
- export class InMemoryDedupStore implements DedupStore {
76
- private readonly seen = new Set<string>();
77
- private readonly capacity: number;
78
-
79
- constructor(opts: InMemoryDedupStoreOptions = {}) {
80
- this.capacity = opts.capacity ?? 10_000;
81
- }
82
-
83
- async remember(key: string): Promise<boolean> {
84
- if (this.seen.has(key)) return true;
85
- this.seen.add(key);
86
- if (this.seen.size > this.capacity) {
87
- const oldest = this.seen.values().next().value as string | undefined;
88
- if (oldest !== undefined) this.seen.delete(oldest);
89
- }
90
- return false;
91
- }
92
-
93
- async clear(): Promise<void> {
94
- this.seen.clear();
95
- }
96
-
97
- async close(): Promise<void> {
98
- // nothing to release
99
- }
100
- }
101
-
102
- export type SqliteDedupStoreOptions = {
103
- /** Database file path. All processes on the host must use the same path. */
104
- readonly path: string;
105
- /**
106
- * How long a recorded key stays a duplicate. Default 24 h — comfortably
107
- * past every adapter's signature replay-window, which is the window an
108
- * attacker can replay a captured webhook within anyway.
109
- */
110
- readonly ttlMs?: number;
111
- /** SQLite busy timeout — how long a writer waits for the lock. Default 5 s. */
112
- readonly busyTimeoutMs?: number;
113
- /** Test seam: clock. */
114
- readonly _now?: () => number;
115
- };
116
-
117
- /**
118
- * Cross-process dedup on one host. `remember` runs prune + insert in a
119
- * single IMMEDIATE transaction: the write lock is held across both, so two
120
- * processes racing on the same key serialize — exactly one inserts
121
- * (`changes === 1`) and the other observes the existing row. The prune
122
- * inside the same transaction also closes the check/prune/insert race the
123
- * design review flagged.
124
- */
125
- export class SqliteDedupStore implements DedupStore {
126
- private readonly db: Database;
127
- private readonly ttlMs: number;
128
- private readonly now: () => number;
129
- private readonly rememberTx: (key: string) => boolean;
130
- private closed = false;
131
-
132
- constructor(opts: SqliteDedupStoreOptions) {
133
- this.db = new Database(opts.path);
134
- this.db.run("PRAGMA journal_mode = WAL");
135
- this.db.run(`PRAGMA busy_timeout = ${opts.busyTimeoutMs ?? 5000}`);
136
- this.db.run(
137
- "CREATE TABLE IF NOT EXISTS dedup (key TEXT PRIMARY KEY, expires_at INTEGER NOT NULL)",
138
- );
139
- this.ttlMs = opts.ttlMs ?? 24 * 60 * 60 * 1000;
140
- this.now = opts._now ?? Date.now;
141
- const prune = this.db.prepare("DELETE FROM dedup WHERE expires_at <= ?");
142
- const insert = this.db.prepare("INSERT OR IGNORE INTO dedup (key, expires_at) VALUES (?, ?)");
143
- const tx = this.db.transaction((key: string): boolean => {
144
- const t = this.now();
145
- prune.run(t);
146
- const r = insert.run(key, t + this.ttlMs);
147
- return r.changes === 0; // 0 changes = row existed = already seen
148
- });
149
- this.rememberTx = (key) => tx.immediate(key) as boolean;
150
- }
151
-
152
- async remember(key: string): Promise<boolean> {
153
- return this.rememberTx(key);
154
- }
155
-
156
- async clear(): Promise<void> {
157
- this.db.run("DELETE FROM dedup");
158
- }
159
-
160
- async close(): Promise<void> {
161
- if (this.closed) return;
162
- this.closed = true;
163
- this.db.close();
164
- }
165
- }
166
-
167
- // ---------------------------------------------------------------------------
168
- // Budget store
169
- // ---------------------------------------------------------------------------
170
-
171
- export type UsageDelta = {
172
- readonly input: number;
173
- readonly output: number;
174
- };
175
-
176
- export type BudgetLimits = {
177
- readonly maxInputTokens: number;
178
- readonly maxOutputTokens: number;
179
- };
180
-
181
- export type TryReserveResult =
182
- | { readonly ok: true }
183
- | {
184
- /** Which dimension exceeded, with the totals for the error message. */
185
- readonly ok: false;
186
- readonly reason: "input" | "output";
187
- readonly total: number;
188
- readonly limit: number;
189
- };
190
-
191
- export interface BudgetStore {
192
- /**
193
- * Atomically reserve `delta` for `tenantId` if recorded + reserved +
194
- * delta stays under `limits` on BOTH dimensions; nothing is reserved on
195
- * failure. MUST be atomic across concurrent callers — two reservations
196
- * that individually fit but jointly exceed must not both succeed.
197
- */
198
- tryReserve(tenantId: string, delta: UsageDelta, limits: BudgetLimits): Promise<TryReserveResult>;
199
- /** Release a reservation made by `tryReserve` (clamped at zero). */
200
- release(tenantId: string, delta: UsageDelta): Promise<void>;
201
- /** Add actual usage to the tenant's recorded running total. */
202
- recordUsage(tenantId: string, delta: UsageDelta): Promise<void>;
203
- /** Recorded usage (excludes in-flight reservations). */
204
- usage(tenantId: string): Promise<{ input: number; output: number }>;
205
- /**
206
- * Zero ALL reservations. Crash recovery for the durable backend: a process
207
- * that died mid-request leaks its reservation. Call at boot when this
208
- * process is the only writer, or from operator tooling after draining.
209
- */
210
- clearReservations(): Promise<void>;
211
- /** Drop all state. Tests. */
212
- clear(): Promise<void>;
213
- /** Release any underlying resources. Idempotent. */
214
- close(): Promise<void>;
215
- }
216
-
217
- /**
218
- * The exact recorded/reserved maps gateway-server used inline (PR #206),
219
- * factored behind the interface. Single-process semantics are unchanged:
220
- * synchronous map operations make every method trivially atomic.
221
- */
222
- export class InMemoryBudgetStore implements BudgetStore {
223
- private readonly recorded = new Map<string, { input: number; output: number }>();
224
- private readonly reserved = new Map<string, { input: number; output: number }>();
225
-
226
- async tryReserve(
227
- tenantId: string,
228
- delta: UsageDelta,
229
- limits: BudgetLimits,
230
- ): Promise<TryReserveResult> {
231
- const used = this.recorded.get(tenantId) ?? { input: 0, output: 0 };
232
- const res = this.reserved.get(tenantId) ?? { input: 0, output: 0 };
233
- const totalInput = used.input + res.input + delta.input;
234
- const totalOutput = used.output + res.output + delta.output;
235
- if (totalInput >= limits.maxInputTokens) {
236
- return { ok: false, reason: "input", total: totalInput, limit: limits.maxInputTokens };
237
- }
238
- if (totalOutput >= limits.maxOutputTokens) {
239
- return { ok: false, reason: "output", total: totalOutput, limit: limits.maxOutputTokens };
240
- }
241
- if (delta.input !== 0 || delta.output !== 0) {
242
- this.reserved.set(tenantId, {
243
- input: res.input + delta.input,
244
- output: res.output + delta.output,
245
- });
246
- }
247
- return { ok: true };
248
- }
249
-
250
- async release(tenantId: string, delta: UsageDelta): Promise<void> {
251
- if (delta.input === 0 && delta.output === 0) return;
252
- const cur = this.reserved.get(tenantId) ?? { input: 0, output: 0 };
253
- const next = {
254
- input: Math.max(0, cur.input - delta.input),
255
- output: Math.max(0, cur.output - delta.output),
256
- };
257
- if (next.input === 0 && next.output === 0) this.reserved.delete(tenantId);
258
- else this.reserved.set(tenantId, next);
259
- }
260
-
261
- async recordUsage(tenantId: string, delta: UsageDelta): Promise<void> {
262
- const cur = this.recorded.get(tenantId) ?? { input: 0, output: 0 };
263
- this.recorded.set(tenantId, {
264
- input: cur.input + delta.input,
265
- output: cur.output + delta.output,
266
- });
267
- }
268
-
269
- async usage(tenantId: string): Promise<{ input: number; output: number }> {
270
- return this.recorded.get(tenantId) ?? { input: 0, output: 0 };
271
- }
272
-
273
- async clearReservations(): Promise<void> {
274
- this.reserved.clear();
275
- }
276
-
277
- async clear(): Promise<void> {
278
- this.recorded.clear();
279
- this.reserved.clear();
280
- }
281
-
282
- async close(): Promise<void> {
283
- // nothing to release
284
- }
285
- }
286
-
287
- export type SqliteBudgetStoreOptions = {
288
- /** Database file path. All processes on the host must use the same path. */
289
- readonly path: string;
290
- /** SQLite busy timeout — how long a writer waits for the lock. Default 5 s. */
291
- readonly busyTimeoutMs?: number;
292
- };
293
-
294
- /**
295
- * Cross-process budget accounting on one host. `tryReserve` runs
296
- * read-check-update in a single IMMEDIATE transaction, so two processes
297
- * whose reservations individually fit but jointly exceed serialize on the
298
- * write lock — the second sees the first's reservation and is refused.
299
- */
300
- export class SqliteBudgetStore implements BudgetStore {
301
- private readonly db: Database;
302
- private readonly reserveTx: (
303
- tenantId: string,
304
- delta: UsageDelta,
305
- limits: BudgetLimits,
306
- ) => TryReserveResult;
307
- private readonly releaseTx: (tenantId: string, delta: UsageDelta) => void;
308
- private closed = false;
309
-
310
- constructor(opts: SqliteBudgetStoreOptions) {
311
- this.db = new Database(opts.path);
312
- this.db.run("PRAGMA journal_mode = WAL");
313
- this.db.run(`PRAGMA busy_timeout = ${opts.busyTimeoutMs ?? 5000}`);
314
- this.db.run(`CREATE TABLE IF NOT EXISTS budget (
315
- tenant_id TEXT PRIMARY KEY,
316
- recorded_input INTEGER NOT NULL DEFAULT 0,
317
- recorded_output INTEGER NOT NULL DEFAULT 0,
318
- reserved_input INTEGER NOT NULL DEFAULT 0,
319
- reserved_output INTEGER NOT NULL DEFAULT 0
320
- )`);
321
-
322
- const select = this.db.prepare(
323
- "SELECT recorded_input, recorded_output, reserved_input, reserved_output FROM budget WHERE tenant_id = ?",
324
- );
325
- const upsertReserve = this.db.prepare(`INSERT INTO budget
326
- (tenant_id, reserved_input, reserved_output) VALUES (?, ?, ?)
327
- ON CONFLICT(tenant_id) DO UPDATE SET
328
- reserved_input = reserved_input + excluded.reserved_input,
329
- reserved_output = reserved_output + excluded.reserved_output`);
330
- const releaseUpdate = this.db.prepare(`UPDATE budget SET
331
- reserved_input = MAX(0, reserved_input - ?),
332
- reserved_output = MAX(0, reserved_output - ?)
333
- WHERE tenant_id = ?`);
334
-
335
- type Row = {
336
- recorded_input: number;
337
- recorded_output: number;
338
- reserved_input: number;
339
- reserved_output: number;
340
- };
341
-
342
- const reserveTxn = this.db.transaction(
343
- (tenantId: string, delta: UsageDelta, limits: BudgetLimits): TryReserveResult => {
344
- const row = (select.get(tenantId) as Row | null) ?? {
345
- recorded_input: 0,
346
- recorded_output: 0,
347
- reserved_input: 0,
348
- reserved_output: 0,
349
- };
350
- const totalInput = row.recorded_input + row.reserved_input + delta.input;
351
- const totalOutput = row.recorded_output + row.reserved_output + delta.output;
352
- if (totalInput >= limits.maxInputTokens) {
353
- return { ok: false, reason: "input", total: totalInput, limit: limits.maxInputTokens };
354
- }
355
- if (totalOutput >= limits.maxOutputTokens) {
356
- return {
357
- ok: false,
358
- reason: "output",
359
- total: totalOutput,
360
- limit: limits.maxOutputTokens,
361
- };
362
- }
363
- if (delta.input !== 0 || delta.output !== 0) {
364
- upsertReserve.run(tenantId, delta.input, delta.output);
365
- }
366
- return { ok: true };
367
- },
368
- );
369
- this.reserveTx = (tenantId, delta, limits) =>
370
- reserveTxn.immediate(tenantId, delta, limits) as TryReserveResult;
371
-
372
- const releaseTxn = this.db.transaction((tenantId: string, delta: UsageDelta): void => {
373
- releaseUpdate.run(delta.input, delta.output, tenantId);
374
- });
375
- this.releaseTx = (tenantId, delta) => {
376
- releaseTxn.immediate(tenantId, delta);
377
- };
378
- }
379
-
380
- async tryReserve(
381
- tenantId: string,
382
- delta: UsageDelta,
383
- limits: BudgetLimits,
384
- ): Promise<TryReserveResult> {
385
- return this.reserveTx(tenantId, delta, limits);
386
- }
387
-
388
- async release(tenantId: string, delta: UsageDelta): Promise<void> {
389
- if (delta.input === 0 && delta.output === 0) return;
390
- this.releaseTx(tenantId, delta);
391
- }
392
-
393
- async recordUsage(tenantId: string, delta: UsageDelta): Promise<void> {
394
- this.db
395
- .prepare(`INSERT INTO budget
396
- (tenant_id, recorded_input, recorded_output) VALUES (?, ?, ?)
397
- ON CONFLICT(tenant_id) DO UPDATE SET
398
- recorded_input = recorded_input + excluded.recorded_input,
399
- recorded_output = recorded_output + excluded.recorded_output`)
400
- .run(tenantId, delta.input, delta.output);
401
- }
402
-
403
- async usage(tenantId: string): Promise<{ input: number; output: number }> {
404
- const row = this.db
405
- .prepare("SELECT recorded_input, recorded_output FROM budget WHERE tenant_id = ?")
406
- .get(tenantId) as { recorded_input: number; recorded_output: number } | null;
407
- return row === null
408
- ? { input: 0, output: 0 }
409
- : { input: row.recorded_input, output: row.recorded_output };
410
- }
411
-
412
- async clearReservations(): Promise<void> {
413
- this.db.run("UPDATE budget SET reserved_input = 0, reserved_output = 0");
414
- }
415
-
416
- async clear(): Promise<void> {
417
- this.db.run("DELETE FROM budget");
418
- }
419
-
420
- async close(): Promise<void> {
421
- if (this.closed) return;
422
- this.closed = true;
423
- this.db.close();
424
- }
425
- }
426
-
427
- // ---------------------------------------------------------------------------
428
- // Spec-string factories — env-driven backend selection for generated daemons.
429
- // ---------------------------------------------------------------------------
430
-
431
- function parseSpec(spec: string): { backend: "memory" } | { backend: "sqlite"; path: string } {
432
- if (spec === "memory") return { backend: "memory" };
433
- if (spec.startsWith("sqlite:")) {
434
- const path = spec.slice("sqlite:".length);
435
- if (path === "") {
436
- throw new DurableStateError(`invalid store spec "${spec}": sqlite: needs a file path`);
437
- }
438
- return { backend: "sqlite", path };
439
- }
440
- throw new DurableStateError(`invalid store spec "${spec}": expected "memory" or "sqlite:<path>"`);
441
- }
442
-
443
- /** Build a DedupStore from a spec string: `"memory"` or `"sqlite:<path>"`. */
444
- export function createDedupStore(
445
- spec: string,
446
- opts: { capacity?: number; ttlMs?: number } = {},
447
- ): DedupStore {
448
- const parsed = parseSpec(spec);
449
- if (parsed.backend === "memory") {
450
- return new InMemoryDedupStore(opts.capacity !== undefined ? { capacity: opts.capacity } : {});
451
- }
452
- return new SqliteDedupStore({
453
- path: parsed.path,
454
- ...(opts.ttlMs !== undefined ? { ttlMs: opts.ttlMs } : {}),
455
- });
456
- }
457
-
458
- /** Build a BudgetStore from a spec string: `"memory"` or `"sqlite:<path>"`. */
459
- export function createBudgetStore(spec: string): BudgetStore {
460
- const parsed = parseSpec(spec);
461
- if (parsed.backend === "memory") return new InMemoryBudgetStore();
462
- return new SqliteBudgetStore({ path: parsed.path });
463
- }