toiljs 0.0.54 → 0.0.56

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.
Files changed (105) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/build/backend/.tsbuildinfo +1 -1
  3. package/build/cli/.tsbuildinfo +1 -1
  4. package/build/cli/index.js +9 -5
  5. package/build/client/.tsbuildinfo +1 -1
  6. package/build/client/auth.js +1 -1
  7. package/build/client/components/Image.d.ts +1 -1
  8. package/build/client/dev/devtools.js +3 -1
  9. package/build/client/index.d.ts +2 -2
  10. package/build/client/index.js +2 -2
  11. package/build/client/routing/Router.js +1 -1
  12. package/build/client/routing/mount.js +1 -1
  13. package/build/compiler/.tsbuildinfo +1 -1
  14. package/build/compiler/docs.js +1 -1
  15. package/build/compiler/seo.js +1 -3
  16. package/build/compiler/template-build.js +1 -1
  17. package/build/devserver/.tsbuildinfo +1 -1
  18. package/build/devserver/cache.js +0 -0
  19. package/build/devserver/crypto.js +45 -17
  20. package/build/devserver/database.d.ts +8 -0
  21. package/build/devserver/database.js +416 -0
  22. package/build/devserver/email/caps.js +0 -0
  23. package/build/devserver/email/config.js +7 -2
  24. package/build/devserver/email/validate.js +1 -4
  25. package/build/devserver/host.d.ts +2 -0
  26. package/build/devserver/host.js +3 -2
  27. package/build/devserver/index.d.ts +1 -1
  28. package/build/devserver/index.js +3 -2
  29. package/build/devserver/module.js +52 -7
  30. package/build/devserver/proxy.js +2 -1
  31. package/build/io/.tsbuildinfo +1 -1
  32. package/build/io/codec.d.ts +5 -5
  33. package/build/io/codec.js +193 -77
  34. package/examples/basic/client/components/HoneycombBackground.tsx +1 -1
  35. package/examples/basic/client/public/images/logo.svg +37 -34
  36. package/examples/basic/client/public/index.html +14 -14
  37. package/examples/basic/client/routes/auth.tsx +18 -10
  38. package/examples/basic/client/routes/cookies.tsx +15 -24
  39. package/examples/basic/client/routes/crypto.tsx +4 -5
  40. package/examples/basic/client/routes/features/template/template.tsx +1 -1
  41. package/examples/basic/client/routes/hello.tsx +1 -1
  42. package/examples/basic/client/routes/pq.tsx +14 -14
  43. package/examples/basic/client/routes/rest.tsx +50 -1
  44. package/examples/basic/client/styles/main.css +25 -22
  45. package/examples/basic/client/toil.tsx +1 -1
  46. package/examples/basic/server/README.md +8 -8
  47. package/examples/basic/server/core/AppHandler.ts +4 -7
  48. package/examples/basic/server/main.ts +1 -0
  49. package/examples/basic/server/models/GuestEntry.ts +12 -0
  50. package/examples/basic/server/models/GuestbookView.ts +10 -0
  51. package/examples/basic/server/models/NewMessage.ts +6 -0
  52. package/examples/basic/server/routes/Auth.ts +50 -106
  53. package/examples/basic/server/routes/EnvDemo.ts +9 -3
  54. package/examples/basic/server/routes/Guestbook.ts +62 -0
  55. package/package.json +2 -2
  56. package/server/globals/auth.ts +3 -3
  57. package/server/globals/twofactor.ts +2 -1
  58. package/server/runtime/http/securecookies.ts +3 -2
  59. package/src/backend/index.ts +4 -2
  60. package/src/cli/doctor.ts +10 -3
  61. package/src/cli/notify.ts +1 -6
  62. package/src/cli/ui.ts +3 -3
  63. package/src/cli/version-check.ts +5 -1
  64. package/src/client/auth.ts +33 -10
  65. package/src/client/components/Form.tsx +2 -2
  66. package/src/client/components/Image.tsx +1 -1
  67. package/src/client/components/Script.tsx +1 -1
  68. package/src/client/components/Slot.tsx +1 -1
  69. package/src/client/dev/devtools.tsx +121 -54
  70. package/src/client/dev/error-overlay.tsx +7 -1
  71. package/src/client/head/metadata.ts +1 -1
  72. package/src/client/index.ts +13 -2
  73. package/src/client/routing/Router.tsx +2 -2
  74. package/src/client/routing/error-boundary.tsx +1 -1
  75. package/src/client/routing/loader.ts +2 -2
  76. package/src/client/routing/mount.tsx +5 -6
  77. package/src/compiler/docs.ts +1 -1
  78. package/src/compiler/email-preview.ts +1 -1
  79. package/src/compiler/generate.ts +1 -1
  80. package/src/compiler/seo.ts +1 -3
  81. package/src/compiler/ssg.ts +10 -4
  82. package/src/compiler/template-build.ts +2 -7
  83. package/src/compiler/template.ts +1 -4
  84. package/src/compiler/vite.ts +1 -1
  85. package/src/devserver/cache.ts +0 -0
  86. package/src/devserver/crypto.ts +140 -51
  87. package/src/devserver/database.ts +600 -0
  88. package/src/devserver/dotenv.ts +10 -2
  89. package/src/devserver/email/caps.ts +0 -0
  90. package/src/devserver/email/config.ts +8 -2
  91. package/src/devserver/email/index.ts +3 -3
  92. package/src/devserver/email/validate.ts +1 -4
  93. package/src/devserver/envelope.ts +3 -3
  94. package/src/devserver/host.ts +22 -9
  95. package/src/devserver/index.ts +15 -6
  96. package/src/devserver/module.ts +59 -11
  97. package/src/devserver/proxy.ts +5 -7
  98. package/src/io/codec.ts +226 -83
  99. package/test/devserver-database.test.ts +364 -0
  100. package/test/devserver-pqauth.test.ts +5 -65
  101. package/test/example-guestbook.test.ts +78 -0
  102. package/test/pqauth-e2e.test.ts +6 -6
  103. package/build/devserver/kv.d.ts +0 -3
  104. package/build/devserver/kv.js +0 -53
  105. package/src/devserver/kv.ts +0 -93
@@ -0,0 +1,600 @@
1
+ /**
2
+ * DEV emulation of the ToilDB record-family data API (`env::data.*`).
3
+ *
4
+ * The compiler-emitted guest (`@database` + `App.users.get/create/...`) calls
5
+ * these; under `toiljs dev` we back them with a single-process in-memory store.
6
+ * The production edge backs the SAME imports with ScyllaDB via the off-core
7
+ * "scylla rail" (toil-backend). The wire ABI mirrors the edge exactly
8
+ * (toildb/ABI.md): every byte region is a (ptr,len) into guest memory; returns
9
+ * are `>= 0` success (a length/handle/flag), `-1` too-small, `-2` absent,
10
+ * `<= -1000` a typed error (`-(1000+TDLnnn)`); variable-length results use the
11
+ * two-step `take_result` pull.
12
+ *
13
+ * This is a DEV store: single-process, single-tenant, lost on restart - never a
14
+ * production path. Record family only (get/exists/create/patch/delete/
15
+ * get_delete + resolve/take_result); other families land with their host shims.
16
+ */
17
+
18
+ import type { MemoryRef } from './host.js';
19
+
20
+ /** Process-lifetime store: `"collection\0keyLatin1"` -> value. Shared across dispatches. */
21
+ const STORE = new Map<string, Buffer>();
22
+ /** View family: `"collection\0key"` -> the latest published view blob. */
23
+ const VIEWS = new Map<string, Buffer>();
24
+ /** Membership family: `"collection\0setKey"` -> (memberLatin1 -> member bytes). */
25
+ const MEMBERS = new Map<string, Map<string, Buffer>>();
26
+ /** Counter family: `"collection\0key"` -> saturating i64 sum of deltas. */
27
+ const COUNTERS = new Map<string, bigint>();
28
+ /** Events family: `"collection\0key"` -> append-ordered event blobs (oldest first). */
29
+ const EVENTS = new Map<string, Buffer[]>();
30
+ /** Capacity family: `"collection\0key"` -> an escrow ledger (total/holds/confirmed). */
31
+ const CAPACITY = new Map<string, CapLedger>();
32
+
33
+ /** A finite-resource escrow: a ceiling, in-flight holds, and confirmed consumes. */
34
+ interface CapLedger {
35
+ total: bigint;
36
+ confirmed: bigint;
37
+ holds: Map<bigint, { amount: bigint; expiresMs: number }>;
38
+ nextId: bigint;
39
+ }
40
+
41
+ function capLedger(sk: string): CapLedger {
42
+ let l = CAPACITY.get(sk);
43
+ if (l === undefined) {
44
+ l = { total: 0n, confirmed: 0n, holds: new Map(), nextId: 1n };
45
+ CAPACITY.set(sk, l);
46
+ }
47
+ return l;
48
+ }
49
+
50
+ /** Drop holds whose TTL has elapsed (self-heal, mirrors the edge's now-based prune). */
51
+ function capPrune(l: CapLedger, nowMs: number): void {
52
+ for (const [id, h] of l.holds) if (h.expiresMs <= nowMs) l.holds.delete(id);
53
+ }
54
+
55
+ /** Units currently held (un-expired, unconfirmed). */
56
+ function capHeld(l: CapLedger): bigint {
57
+ let sum = 0n;
58
+ for (const h of l.holds.values()) sum += h.amount;
59
+ return sum;
60
+ }
61
+
62
+ const MAX_NAME = 512;
63
+ const MAX_KEY = 4096;
64
+ const MAX_VALUE = 256 * 1024;
65
+
66
+ // i64 saturation bounds (the edge `MemEngine`/`ScyllaEngine` counters are i64).
67
+ const I64_MIN = -(2n ** 63n);
68
+ const I64_MAX = 2n ** 63n - 1n;
69
+ function satI64(v: bigint): bigint {
70
+ return v < I64_MIN ? I64_MIN : v > I64_MAX ? I64_MAX : v;
71
+ }
72
+
73
+ // Return codes, mirroring `toildb::observe::diagnostics`.
74
+ const ABSENT = -2;
75
+ const TOO_SMALL = -1;
76
+ const INVALID_HANDLE = -1001; // -(1000 + TDL001)
77
+ const PRODUCT_ERR = -1000; // AlreadyExists / NotFound / Conflict (TDL000)
78
+
79
+ /** Per-request data state: resolved handles + the last variable-length result. */
80
+ export interface DbDevState {
81
+ handles: string[];
82
+ lastResult: Buffer | null;
83
+ }
84
+
85
+ export function freshDbState(): DbDevState {
86
+ return { handles: [], lastResult: null };
87
+ }
88
+
89
+ function mem(ref: MemoryRef): Buffer {
90
+ if (!ref.memory) throw new Error('data host import called before memory was bound');
91
+ return Buffer.from(ref.memory.buffer);
92
+ }
93
+
94
+ /** Bounds-checked read that COPIES out of guest memory (the buffer is reused). */
95
+ function readCopy(ref: MemoryRef, ptr: number, len: number): Buffer {
96
+ const m = mem(ref);
97
+ if (ptr < 0 || len < 0 || ptr + len > m.length)
98
+ throw new Error(`data read out of bounds: ptr=${String(ptr)} len=${String(len)}`);
99
+ return Buffer.from(m.subarray(ptr, ptr + len));
100
+ }
101
+
102
+ function storeKey(collection: string, key: Buffer): string {
103
+ return collection + '\0' + key.toString('latin1');
104
+ }
105
+
106
+ function collOf(db: DbDevState, handle: number): string | null {
107
+ return handle >= 0 && handle < db.handles.length ? db.handles[handle] : null;
108
+ }
109
+
110
+ export function buildDatabaseImports(
111
+ ref: MemoryRef,
112
+ db: DbDevState,
113
+ ): Record<string, (...args: number[]) => number> {
114
+ return {
115
+ 'data.resolve_collection': (
116
+ namePtr: number,
117
+ nameLen: number,
118
+ outHandlePtr: number,
119
+ ): number => {
120
+ if (nameLen < 0 || nameLen > MAX_NAME)
121
+ throw new Error('data: collection name too long');
122
+ const name = readCopy(ref, namePtr, nameLen).toString('utf8');
123
+ const handle = db.handles.length;
124
+ db.handles.push(name);
125
+ const m = mem(ref);
126
+ if (outHandlePtr < 0 || outHandlePtr + 4 > m.length)
127
+ throw new Error('data: resolve out-handle out of bounds');
128
+ m.writeUInt32LE(handle, outHandlePtr);
129
+ return 0;
130
+ },
131
+
132
+ 'data.get': (handle: number, keyPtr: number, keyLen: number): number => {
133
+ const coll = collOf(db, handle);
134
+ if (coll === null) return INVALID_HANDLE;
135
+ if (keyLen > MAX_KEY) throw new Error('data: key too long');
136
+ const v = STORE.get(storeKey(coll, readCopy(ref, keyPtr, keyLen)));
137
+ if (v === undefined) return ABSENT;
138
+ db.lastResult = v;
139
+ return v.length;
140
+ },
141
+
142
+ // Bounded multi-get. Keys blob: u32 count + per key (u32 len + bytes).
143
+ // Result (stashed): u32 count + per item u8 present (+ u32 len + bytes),
144
+ // in request order. Mirrors the edge `op_get_many` framing byte-for-byte.
145
+ 'data.get_many': (handle: number, keysPtr: number, keysLen: number): number => {
146
+ const coll = collOf(db, handle);
147
+ if (coll === null) return INVALID_HANDLE;
148
+ if (keysLen > MAX_VALUE) throw new Error('data: keys blob too large');
149
+ const blob = readCopy(ref, keysPtr, keysLen);
150
+ let off = 0;
151
+ const count = blob.readUInt32LE(off);
152
+ off += 4;
153
+ if (count > 1024) return PRODUCT_ERR; // anti-OOM cap, mirrors the edge
154
+ const header = Buffer.alloc(4);
155
+ header.writeUInt32LE(count, 0);
156
+ const parts: Buffer[] = [header];
157
+ for (let i = 0; i < count; i++) {
158
+ const len = blob.readUInt32LE(off);
159
+ off += 4;
160
+ const key = blob.subarray(off, off + len);
161
+ off += len;
162
+ const v = STORE.get(storeKey(coll, key));
163
+ if (v === undefined) {
164
+ parts.push(Buffer.from([0]));
165
+ } else {
166
+ const h = Buffer.alloc(5);
167
+ h.writeUInt8(1, 0);
168
+ h.writeUInt32LE(v.length, 1);
169
+ parts.push(h, v);
170
+ }
171
+ }
172
+ db.lastResult = Buffer.concat(parts);
173
+ return db.lastResult.length;
174
+ },
175
+
176
+ 'data.exists': (handle: number, keyPtr: number, keyLen: number): number => {
177
+ const coll = collOf(db, handle);
178
+ if (coll === null) return INVALID_HANDLE;
179
+ return STORE.has(storeKey(coll, readCopy(ref, keyPtr, keyLen))) ? 1 : 0;
180
+ },
181
+
182
+ 'data.create': (
183
+ handle: number,
184
+ keyPtr: number,
185
+ keyLen: number,
186
+ valPtr: number,
187
+ valLen: number,
188
+ _idemPtr: number,
189
+ ): number => {
190
+ const coll = collOf(db, handle);
191
+ if (coll === null) return INVALID_HANDLE;
192
+ if (keyLen > MAX_KEY || valLen > MAX_VALUE)
193
+ throw new Error('data: key/value too large');
194
+ const sk = storeKey(coll, readCopy(ref, keyPtr, keyLen));
195
+ if (STORE.has(sk)) return PRODUCT_ERR; // AlreadyExists
196
+ STORE.set(sk, readCopy(ref, valPtr, valLen));
197
+ return 0;
198
+ },
199
+
200
+ 'data.patch': (
201
+ handle: number,
202
+ keyPtr: number,
203
+ keyLen: number,
204
+ patchPtr: number,
205
+ patchLen: number,
206
+ _idemPtr: number,
207
+ ): number => {
208
+ const coll = collOf(db, handle);
209
+ if (coll === null) return INVALID_HANDLE;
210
+ if (keyLen > MAX_KEY || patchLen > MAX_VALUE)
211
+ throw new Error('data: key/patch too large');
212
+ const sk = storeKey(coll, readCopy(ref, keyPtr, keyLen));
213
+ if (!STORE.has(sk)) return PRODUCT_ERR; // NotFound
214
+ const v = readCopy(ref, patchPtr, patchLen);
215
+ STORE.set(sk, v);
216
+ db.lastResult = v; // patch returns the stored record
217
+ return v.length;
218
+ },
219
+
220
+ 'data.delete': (
221
+ handle: number,
222
+ keyPtr: number,
223
+ keyLen: number,
224
+ _idemPtr: number,
225
+ ): number => {
226
+ const coll = collOf(db, handle);
227
+ if (coll === null) return INVALID_HANDLE;
228
+ STORE.delete(storeKey(coll, readCopy(ref, keyPtr, keyLen)));
229
+ return 0;
230
+ },
231
+
232
+ // Atomic fetch-and-delete (consume-once); deletes only on a real read.
233
+ 'data.get_delete': (
234
+ handle: number,
235
+ keyPtr: number,
236
+ keyLen: number,
237
+ _idemPtr: number,
238
+ ): number => {
239
+ const coll = collOf(db, handle);
240
+ if (coll === null) return INVALID_HANDLE;
241
+ const sk = storeKey(coll, readCopy(ref, keyPtr, keyLen));
242
+ const v = STORE.get(sk);
243
+ if (v === undefined) return ABSENT;
244
+ STORE.delete(sk);
245
+ db.lastResult = v;
246
+ return v.length;
247
+ },
248
+
249
+ // --- unique family (lookup / claim / release) ---
250
+
251
+ 'data.unique_lookup': (handle: number, keyPtr: number, keyLen: number): number => {
252
+ const coll = collOf(db, handle);
253
+ if (coll === null) return INVALID_HANDLE;
254
+ const v = STORE.get(storeKey(coll, readCopy(ref, keyPtr, keyLen)));
255
+ if (v === undefined) return ABSENT;
256
+ db.lastResult = v;
257
+ return v.length;
258
+ },
259
+
260
+ // Tag: 0 Claimed, 1 AlreadyClaimed (owner stashed), 2 AlreadyOwnedByCaller.
261
+ 'data.unique_claim': (
262
+ handle: number,
263
+ keyPtr: number,
264
+ keyLen: number,
265
+ valPtr: number,
266
+ valLen: number,
267
+ _idemPtr: number,
268
+ ): number => {
269
+ const coll = collOf(db, handle);
270
+ if (coll === null) return INVALID_HANDLE;
271
+ if (keyLen > MAX_KEY || valLen > MAX_VALUE)
272
+ throw new Error('data: key/value too large');
273
+ const sk = storeKey(coll, readCopy(ref, keyPtr, keyLen));
274
+ const owner = readCopy(ref, valPtr, valLen);
275
+ const existing = STORE.get(sk);
276
+ if (existing === undefined) {
277
+ STORE.set(sk, owner);
278
+ return 0; // Claimed
279
+ }
280
+ if (existing.equals(owner)) return 2; // AlreadyOwnedByCaller
281
+ db.lastResult = existing;
282
+ return 1; // AlreadyClaimed (current owner stashed)
283
+ },
284
+
285
+ 'data.unique_release': (
286
+ handle: number,
287
+ keyPtr: number,
288
+ keyLen: number,
289
+ valPtr: number,
290
+ valLen: number,
291
+ _idemPtr: number,
292
+ ): number => {
293
+ const coll = collOf(db, handle);
294
+ if (coll === null) return INVALID_HANDLE;
295
+ const sk = storeKey(coll, readCopy(ref, keyPtr, keyLen));
296
+ const existing = STORE.get(sk);
297
+ if (existing === undefined) return 0; // idempotent
298
+ if (!existing.equals(readCopy(ref, valPtr, valLen))) return PRODUCT_ERR; // not the owner
299
+ STORE.delete(sk);
300
+ return 0;
301
+ },
302
+
303
+ // --- membership family (contains / add / remove / list) ---
304
+
305
+ 'data.membership_contains': (
306
+ handle: number,
307
+ setPtr: number,
308
+ setLen: number,
309
+ memberPtr: number,
310
+ memberLen: number,
311
+ ): number => {
312
+ const coll = collOf(db, handle);
313
+ if (coll === null) return INVALID_HANDLE;
314
+ const set = MEMBERS.get(storeKey(coll, readCopy(ref, setPtr, setLen)));
315
+ if (set === undefined) return 0;
316
+ return set.has(readCopy(ref, memberPtr, memberLen).toString('latin1')) ? 1 : 0;
317
+ },
318
+
319
+ 'data.membership_add': (
320
+ handle: number,
321
+ setPtr: number,
322
+ setLen: number,
323
+ memberPtr: number,
324
+ memberLen: number,
325
+ _idemPtr: number,
326
+ ): number => {
327
+ const coll = collOf(db, handle);
328
+ if (coll === null) return INVALID_HANDLE;
329
+ if (setLen > MAX_KEY || memberLen > MAX_VALUE)
330
+ throw new Error('data: set/member too large');
331
+ const sk = storeKey(coll, readCopy(ref, setPtr, setLen));
332
+ const member = readCopy(ref, memberPtr, memberLen);
333
+ let set = MEMBERS.get(sk);
334
+ if (set === undefined) {
335
+ set = new Map();
336
+ MEMBERS.set(sk, set);
337
+ }
338
+ set.set(member.toString('latin1'), member);
339
+ return 0;
340
+ },
341
+
342
+ 'data.membership_remove': (
343
+ handle: number,
344
+ setPtr: number,
345
+ setLen: number,
346
+ memberPtr: number,
347
+ memberLen: number,
348
+ _idemPtr: number,
349
+ ): number => {
350
+ const coll = collOf(db, handle);
351
+ if (coll === null) return INVALID_HANDLE;
352
+ const set = MEMBERS.get(storeKey(coll, readCopy(ref, setPtr, setLen)));
353
+ if (set !== undefined)
354
+ set.delete(readCopy(ref, memberPtr, memberLen).toString('latin1'));
355
+ return 0;
356
+ },
357
+
358
+ // Frame the members (sorted by bytes, matching the edge BTreeMap) as
359
+ // `u32 count` + per member `u32 len + bytes`; stash + return the length.
360
+ 'data.membership_list': (
361
+ handle: number,
362
+ setPtr: number,
363
+ setLen: number,
364
+ limit: number,
365
+ ): number => {
366
+ const coll = collOf(db, handle);
367
+ if (coll === null) return INVALID_HANDLE;
368
+ const set = MEMBERS.get(storeKey(coll, readCopy(ref, setPtr, setLen)));
369
+ const n = Math.max(0, Math.min(limit, 0xffff));
370
+ const members =
371
+ set === undefined ? [] : Array.from(set.values()).sort(Buffer.compare).slice(0, n);
372
+ const header = Buffer.alloc(4);
373
+ header.writeUInt32LE(members.length, 0);
374
+ const parts: Buffer[] = [header];
375
+ for (const m of members) {
376
+ const h = Buffer.alloc(4);
377
+ h.writeUInt32LE(m.length, 0);
378
+ parts.push(h, m);
379
+ }
380
+ db.lastResult = Buffer.concat(parts);
381
+ return db.lastResult.length;
382
+ },
383
+
384
+ // --- view family (get / publish) ---
385
+
386
+ 'data.view_get': (handle: number, keyPtr: number, keyLen: number): number => {
387
+ const coll = collOf(db, handle);
388
+ if (coll === null) return INVALID_HANDLE;
389
+ const v = VIEWS.get(storeKey(coll, readCopy(ref, keyPtr, keyLen)));
390
+ if (v === undefined) return ABSENT;
391
+ db.lastResult = v;
392
+ return v.length;
393
+ },
394
+
395
+ // Publish overwrites (the host assigns the version; dev keeps the latest).
396
+ 'data.view_publish': (
397
+ handle: number,
398
+ keyPtr: number,
399
+ keyLen: number,
400
+ valPtr: number,
401
+ valLen: number,
402
+ _idemPtr: number,
403
+ ): number => {
404
+ const coll = collOf(db, handle);
405
+ if (coll === null) return INVALID_HANDLE;
406
+ if (keyLen > MAX_KEY || valLen > MAX_VALUE) throw new Error('data: key/view too large');
407
+ VIEWS.set(storeKey(coll, readCopy(ref, keyPtr, keyLen)), readCopy(ref, valPtr, valLen));
408
+ return 0;
409
+ },
410
+
411
+ // --- counter family (get / add) ---
412
+
413
+ // Stash the i64 sum as 8 LE bytes; the guest pulls + loads it. A counter
414
+ // with no deltas reads as 0 (never absent).
415
+ 'data.counter_get': (handle: number, keyPtr: number, keyLen: number): number => {
416
+ const coll = collOf(db, handle);
417
+ if (coll === null) return INVALID_HANDLE;
418
+ const sum = COUNTERS.get(storeKey(coll, readCopy(ref, keyPtr, keyLen))) ?? 0n;
419
+ const out = Buffer.alloc(8);
420
+ out.writeBigInt64LE(sum);
421
+ db.lastResult = out;
422
+ return out.length;
423
+ },
424
+
425
+ // `delta` is the wasm i64 (a BigInt across the boundary); `BigInt()`
426
+ // normalizes the test's plain-number form too. Saturates like the edge.
427
+ 'data.counter_add': (
428
+ handle: number,
429
+ keyPtr: number,
430
+ keyLen: number,
431
+ delta: number | bigint,
432
+ _idemPtr: number,
433
+ ): number => {
434
+ const coll = collOf(db, handle);
435
+ if (coll === null) return INVALID_HANDLE;
436
+ const sk = storeKey(coll, readCopy(ref, keyPtr, keyLen));
437
+ COUNTERS.set(sk, satI64((COUNTERS.get(sk) ?? 0n) + BigInt(delta)));
438
+ return 0;
439
+ },
440
+
441
+ // --- events family (append / latest) ---
442
+
443
+ 'data.append': (
444
+ handle: number,
445
+ keyPtr: number,
446
+ keyLen: number,
447
+ evPtr: number,
448
+ evLen: number,
449
+ _idemPtr: number,
450
+ ): number => {
451
+ const coll = collOf(db, handle);
452
+ if (coll === null) return INVALID_HANDLE;
453
+ if (keyLen > MAX_KEY || evLen > MAX_VALUE) throw new Error('data: key/event too large');
454
+ const sk = storeKey(coll, readCopy(ref, keyPtr, keyLen));
455
+ const log = EVENTS.get(sk);
456
+ const ev = readCopy(ref, evPtr, evLen);
457
+ if (log === undefined) EVENTS.set(sk, [ev]);
458
+ else log.push(ev);
459
+ return 0;
460
+ },
461
+
462
+ // Frame the newest-`limit` events as `u32 count` then per event a
463
+ // length-prefixed blob (`u32 len + bytes`), newest first; stash + return
464
+ // the blob length. Matches the edge `op_latest` / `toildb::Writer` framing.
465
+ 'data.latest': (handle: number, keyPtr: number, keyLen: number, limit: number): number => {
466
+ const coll = collOf(db, handle);
467
+ if (coll === null) return INVALID_HANDLE;
468
+ const log = EVENTS.get(storeKey(coll, readCopy(ref, keyPtr, keyLen))) ?? [];
469
+ const n = Math.max(0, Math.min(limit, 0xffff));
470
+ const newest = log.slice(Math.max(0, log.length - n)).reverse();
471
+ let size = 4;
472
+ for (const ev of newest) size += 4 + ev.length;
473
+ const out = Buffer.alloc(size);
474
+ let off = out.writeUInt32LE(newest.length, 0);
475
+ for (const ev of newest) {
476
+ off = out.writeUInt32LE(ev.length, off);
477
+ off += ev.copy(out, off);
478
+ }
479
+ db.lastResult = out;
480
+ return out.length;
481
+ },
482
+
483
+ // --- capacity family (escrow: set_total / available / reserve / confirm / cancel) ---
484
+
485
+ // Set the ceiling (restock / reduce). Job/derive only (kind-gated upstream).
486
+ // A ceiling is never negative.
487
+ 'data.capacity_set_total': (
488
+ handle: number,
489
+ keyPtr: number,
490
+ keyLen: number,
491
+ total: number | bigint,
492
+ _idemPtr: number,
493
+ ): number => {
494
+ const coll = collOf(db, handle);
495
+ if (coll === null) return INVALID_HANDLE;
496
+ const l = capLedger(storeKey(coll, readCopy(ref, keyPtr, keyLen)));
497
+ const t = BigInt(total);
498
+ l.total = satI64(t < 0n ? 0n : t);
499
+ return 0;
500
+ },
501
+
502
+ // Stash the i64 available (total - confirmed - active holds, floored at 0).
503
+ 'data.capacity_available': (handle: number, keyPtr: number, keyLen: number): number => {
504
+ const coll = collOf(db, handle);
505
+ if (coll === null) return INVALID_HANDLE;
506
+ const l = capLedger(storeKey(coll, readCopy(ref, keyPtr, keyLen)));
507
+ capPrune(l, Date.now());
508
+ const avail = l.total - l.confirmed - capHeld(l);
509
+ const out = Buffer.alloc(8);
510
+ out.writeBigInt64LE(avail < 0n ? 0n : avail);
511
+ db.lastResult = out;
512
+ return out.length;
513
+ },
514
+
515
+ // Hold `amount` for `ttlMs`: stash the u64 reservation id (8 bytes) on
516
+ // success, or return ABSENT (-2) when there is not enough available (the
517
+ // guest maps that to reservation 0 = no oversell). `now` is the HOST clock.
518
+ 'data.capacity_reserve': (
519
+ handle: number,
520
+ keyPtr: number,
521
+ keyLen: number,
522
+ amount: number | bigint,
523
+ ttlMs: number | bigint,
524
+ _idemPtr: number,
525
+ ): number => {
526
+ const coll = collOf(db, handle);
527
+ if (coll === null) return INVALID_HANDLE;
528
+ const want = BigInt(amount);
529
+ if (want <= 0n) return ABSENT;
530
+ const l = capLedger(storeKey(coll, readCopy(ref, keyPtr, keyLen)));
531
+ const now = Date.now();
532
+ capPrune(l, now);
533
+ if (l.total - l.confirmed - capHeld(l) < want) return ABSENT; // never oversell
534
+ const id = l.nextId++;
535
+ l.holds.set(id, { amount: want, expiresMs: now + Math.max(0, Number(ttlMs)) });
536
+ const out = Buffer.alloc(8);
537
+ out.writeBigUInt64LE(id);
538
+ db.lastResult = out;
539
+ return out.length;
540
+ },
541
+
542
+ // Finalize a hold into a permanent consume. 1 if the id was a live hold,
543
+ // 0 if it was unknown / expired / already settled.
544
+ 'data.capacity_confirm': (
545
+ handle: number,
546
+ keyPtr: number,
547
+ keyLen: number,
548
+ reservationId: number | bigint,
549
+ _idemPtr: number,
550
+ ): number => {
551
+ const coll = collOf(db, handle);
552
+ if (coll === null) return INVALID_HANDLE;
553
+ const l = capLedger(storeKey(coll, readCopy(ref, keyPtr, keyLen)));
554
+ capPrune(l, Date.now());
555
+ const h = l.holds.get(BigInt(reservationId));
556
+ if (h === undefined) return 0;
557
+ l.holds.delete(BigInt(reservationId));
558
+ l.confirmed = satI64(l.confirmed + h.amount);
559
+ return 1;
560
+ },
561
+
562
+ // Release a hold back to available (a confirmed sale cannot be cancelled).
563
+ 'data.capacity_cancel': (
564
+ handle: number,
565
+ keyPtr: number,
566
+ keyLen: number,
567
+ reservationId: number | bigint,
568
+ _idemPtr: number,
569
+ ): number => {
570
+ const coll = collOf(db, handle);
571
+ if (coll === null) return INVALID_HANDLE;
572
+ const l = capLedger(storeKey(coll, readCopy(ref, keyPtr, keyLen)));
573
+ capPrune(l, Date.now());
574
+ return l.holds.delete(BigInt(reservationId)) ? 1 : 0;
575
+ },
576
+
577
+ // Drain the last stashed variable-length result into the caller buffer.
578
+ 'data.take_result': (outPtr: number, outCap: number): number => {
579
+ const v = db.lastResult;
580
+ if (v === null) return 0;
581
+ if (v.length > outCap) return TOO_SMALL; // keep the stash for retry
582
+ const m = mem(ref);
583
+ if (outPtr < 0 || outPtr + v.length > m.length)
584
+ throw new Error('data: take_result out of bounds');
585
+ v.copy(m, outPtr);
586
+ db.lastResult = null;
587
+ return v.length;
588
+ },
589
+ };
590
+ }
591
+
592
+ /** Test-only: clear the stores between unit tests. */
593
+ export function __resetDbForTests(): void {
594
+ STORE.clear();
595
+ VIEWS.clear();
596
+ MEMBERS.clear();
597
+ COUNTERS.clear();
598
+ EVENTS.clear();
599
+ CAPACITY.clear();
600
+ }
@@ -39,7 +39,11 @@ function parseValue(rest: string): string {
39
39
  * Parse dotenv text into `plain` (non-reserved) and `reserved` (`TOIL_*`):
40
40
  * `KEY=value`, `#` comments, optional `export`, optional surrounding quotes.
41
41
  */
42
- function parseDotenv(text: string, plain: Map<string, string>, reserved: Map<string, string>): void {
42
+ function parseDotenv(
43
+ text: string,
44
+ plain: Map<string, string>,
45
+ reserved: Map<string, string>,
46
+ ): void {
43
47
  for (const raw of text.split('\n')) {
44
48
  let line = raw.trim();
45
49
  if (line.length === 0 || line.startsWith('#')) continue;
@@ -53,7 +57,11 @@ function parseDotenv(text: string, plain: Map<string, string>, reserved: Map<str
53
57
  }
54
58
  }
55
59
 
56
- function readFileInto(file: string, plain: Map<string, string>, reserved: Map<string, string>): void {
60
+ function readFileInto(
61
+ file: string,
62
+ plain: Map<string, string>,
63
+ reserved: Map<string, string>,
64
+ ): void {
57
65
  try {
58
66
  parseDotenv(fs.readFileSync(file, 'utf8'), plain, reserved);
59
67
  } catch {
Binary file
@@ -94,7 +94,10 @@ export function resolveEmailConfig(
94
94
  } else if (providerId === 'gmail' || providerId === 'smtp') {
95
95
  provider = 'smtp';
96
96
  const isGmail = providerId === 'gmail';
97
- const host = envOf(reserved, 'SMTP_HOST') ?? c.smtp?.host?.trim() ?? (isGmail ? 'smtp.gmail.com' : '');
97
+ const host =
98
+ envOf(reserved, 'SMTP_HOST') ??
99
+ c.smtp?.host?.trim() ??
100
+ (isGmail ? 'smtp.gmail.com' : '');
98
101
  if (!host) {
99
102
  return { config: null, warning: 'provider `smtp` requires TOIL_EMAIL_SMTP_HOST' };
100
103
  }
@@ -102,7 +105,10 @@ export function resolveEmailConfig(
102
105
  const user = envOf(reserved, 'SMTP_USER') ?? c.smtp?.user?.trim() ?? from;
103
106
  smtp = { host, port, user };
104
107
  } else {
105
- return { config: null, warning: `unknown email provider "${providerId}" (resend|gmail|smtp)` };
108
+ return {
109
+ config: null,
110
+ warning: `unknown email provider "${providerId}" (resend|gmail|smtp)`,
111
+ };
106
112
  }
107
113
 
108
114
  return {
@@ -11,11 +11,11 @@
11
11
  */
12
12
  import { loadEnvFiles } from '../dotenv.js';
13
13
  import { EmailCaps } from './caps.js';
14
- import { resolveEmailConfig, type ResolvedEmailConfig } from './config.js';
15
- import { sendVia, type OutboundMessage } from './providers.js';
14
+ import { type ResolvedEmailConfig, resolveEmailConfig } from './config.js';
15
+ import { type OutboundMessage, sendVia } from './providers.js';
16
16
  import { EmailStatus } from './status.js';
17
17
  import { validRecipient } from './validate.js';
18
- import { parseEmailBlob, type ParsedEmail } from './wire.js';
18
+ import { type ParsedEmail, parseEmailBlob } from './wire.js';
19
19
 
20
20
  import type { EmailBackendConfig } from 'toiljs/shared';
21
21
 
@@ -17,10 +17,7 @@ export function validRecipient(s: string): boolean {
17
17
  if (parts.length !== 2) return false; // not exactly one '@'
18
18
  const [local, domain] = parts;
19
19
  return (
20
- local.length > 0 &&
21
- domain.includes('.') &&
22
- !domain.startsWith('.') &&
23
- !domain.endsWith('.')
20
+ local.length > 0 && domain.includes('.') && !domain.startsWith('.') && !domain.endsWith('.')
24
21
  );
25
22
  }
26
23