toiljs 0.0.59 → 0.0.60

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 (72) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/build/cli/.tsbuildinfo +1 -1
  3. package/build/cli/index.js +309 -116
  4. package/build/compiler/.tsbuildinfo +1 -1
  5. package/build/devserver/.tsbuildinfo +1 -1
  6. package/build/devserver/db/catalog.d.ts +1 -0
  7. package/build/devserver/db/catalog.js +80 -0
  8. package/build/devserver/db/database.d.ts +64 -0
  9. package/build/devserver/db/database.js +662 -0
  10. package/build/devserver/db/index.d.ts +3 -0
  11. package/build/devserver/db/index.js +3 -0
  12. package/build/devserver/db/types.d.ts +58 -0
  13. package/build/devserver/db/types.js +20 -0
  14. package/build/devserver/email/index.js +1 -1
  15. package/build/devserver/index.d.ts +9 -24
  16. package/build/devserver/index.js +4 -165
  17. package/build/devserver/{host.d.ts → runtime/host.d.ts} +1 -1
  18. package/build/devserver/{host.js → runtime/host.js} +6 -6
  19. package/build/devserver/{module.d.ts → runtime/module.d.ts} +1 -1
  20. package/build/devserver/{module.js → runtime/module.js} +8 -1
  21. package/build/devserver/server.d.ts +17 -0
  22. package/build/devserver/server.js +164 -0
  23. package/docs/time.md +2 -2
  24. package/examples/basic/server/migrations/GuestEntry.migration.ts +39 -0
  25. package/package.json +2 -2
  26. package/server/runtime/time.ts +3 -3
  27. package/src/cli/create.ts +38 -1
  28. package/src/cli/db.ts +158 -0
  29. package/src/cli/diagnostics.ts +19 -0
  30. package/src/cli/doctor.ts +20 -0
  31. package/src/cli/index.ts +10 -0
  32. package/src/cli/update.ts +58 -0
  33. package/src/devserver/db/catalog.ts +100 -0
  34. package/src/devserver/db/database.ts +1169 -0
  35. package/src/devserver/db/index.ts +18 -0
  36. package/src/devserver/db/types.ts +76 -0
  37. package/src/devserver/email/index.ts +1 -1
  38. package/src/devserver/index.ts +19 -287
  39. package/src/devserver/{host.ts → runtime/host.ts} +6 -6
  40. package/src/devserver/{module.ts → runtime/module.ts} +13 -1
  41. package/src/devserver/server.ts +292 -0
  42. package/test/db.test.ts +0 -0
  43. package/test/devserver-database.test.ts +114 -9
  44. package/test/devserver-pqauth.test.ts +1 -1
  45. package/test/devserver-secrets.test.ts +5 -1
  46. package/test/doctor.test.ts +13 -0
  47. package/test/example-guestbook.test.ts +43 -1
  48. package/test/pqauth-e2e.test.ts +1 -1
  49. package/build/devserver/database.d.ts +0 -8
  50. package/build/devserver/database.js +0 -418
  51. package/src/devserver/database.ts +0 -618
  52. /package/build/devserver/{dotenv.d.ts → config/dotenv.d.ts} +0 -0
  53. /package/build/devserver/{dotenv.js → config/dotenv.js} +0 -0
  54. /package/build/devserver/{env.d.ts → config/env.d.ts} +0 -0
  55. /package/build/devserver/{env.js → config/env.js} +0 -0
  56. /package/build/devserver/{ratelimit.d.ts → config/ratelimit.d.ts} +0 -0
  57. /package/build/devserver/{ratelimit.js → config/ratelimit.js} +0 -0
  58. /package/build/devserver/{cache.d.ts → http/cache.d.ts} +0 -0
  59. /package/build/devserver/{cache.js → http/cache.js} +0 -0
  60. /package/build/devserver/{envelope.d.ts → http/envelope.d.ts} +0 -0
  61. /package/build/devserver/{envelope.js → http/envelope.js} +0 -0
  62. /package/build/devserver/{proxy.d.ts → http/proxy.d.ts} +0 -0
  63. /package/build/devserver/{proxy.js → http/proxy.js} +0 -0
  64. /package/build/devserver/{crypto.d.ts → runtime/crypto.d.ts} +0 -0
  65. /package/build/devserver/{crypto.js → runtime/crypto.js} +0 -0
  66. /package/src/devserver/{dotenv.ts → config/dotenv.ts} +0 -0
  67. /package/src/devserver/{env.ts → config/env.ts} +0 -0
  68. /package/src/devserver/{ratelimit.ts → config/ratelimit.ts} +0 -0
  69. /package/src/devserver/{cache.ts → http/cache.ts} +0 -0
  70. /package/src/devserver/{envelope.ts → http/envelope.ts} +0 -0
  71. /package/src/devserver/{proxy.ts → http/proxy.ts} +0 -0
  72. /package/src/devserver/{crypto.ts → runtime/crypto.ts} +0 -0
@@ -0,0 +1,1169 @@
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. All seven families (record/view/unique/events/membership/
15
+ * counter/capacity) live on the {@link DevDatabase} class; one process shares a
16
+ * single instance ({@link devDb}), since the dev store is one per process.
17
+ */
18
+
19
+ import fs from 'node:fs';
20
+ import path from 'node:path';
21
+
22
+ import { DataReader, DataWriter } from 'toiljs/io';
23
+ import type { MemoryRef } from '../runtime/host.js';
24
+ import { parseCatalog } from './catalog.js';
25
+ import {
26
+ ABSENT,
27
+ ALREADY_EXISTS,
28
+ type CapLedger,
29
+ CODEC_ERR,
30
+ CONFLICT,
31
+ type DbDevState,
32
+ type DbSnapshot,
33
+ INVALID_HANDLE,
34
+ MAX_KEY,
35
+ MAX_NAME,
36
+ MAX_RESERVATION_TTL_MS,
37
+ MAX_RESERVATIONS,
38
+ MAX_VALUE,
39
+ type Reservation,
40
+ satI64,
41
+ TOO_MANY_KEYS,
42
+ TOO_SMALL,
43
+ } from './types.js';
44
+
45
+ // ---- schema versions: the dev equivalent of the edge binding the row's
46
+ // schema_version. Writes STAMP the value type's CURRENT version (from the loaded
47
+ // wasm's catalog); reads SURFACE the stamp. When a @data type evolves and the wasm
48
+ // is rebuilt, the catalog version changes but data on disk keeps its old stamp, so
49
+ // a read reports the old version and the guest's woven decoder runs the @migrate.
50
+
51
+ function mem(ref: MemoryRef): Buffer {
52
+ if (!ref.memory) throw new Error('data host import called before memory was bound');
53
+ return Buffer.from(ref.memory.buffer);
54
+ }
55
+
56
+ /** Bounds-checked read that COPIES out of guest memory (the buffer is reused). */
57
+ function readCopy(ref: MemoryRef, ptr: number, len: number): Buffer {
58
+ const m = mem(ref);
59
+ if (ptr < 0 || len < 0 || ptr + len > m.length)
60
+ throw new Error(`data read out of bounds: ptr=${String(ptr)} len=${String(len)}`);
61
+ return Buffer.from(m.subarray(ptr, ptr + len));
62
+ }
63
+
64
+ /** Read + length-cap a KEY. The edge traps a key over MAX_KEY on EVERY op (via
65
+ * `bound` in prepare_key); enforce it uniformly here so a dev read of an over-cap
66
+ * key fails the same way instead of silently succeeding. */
67
+ function readKey(ref: MemoryRef, ptr: number, len: number): Buffer {
68
+ if (len > MAX_KEY) throw new Error('data: key too long');
69
+ return readCopy(ref, ptr, len);
70
+ }
71
+
72
+ function storeKey(collection: string, key: Buffer): string {
73
+ return collection + '\0' + key.toString('latin1');
74
+ }
75
+
76
+ function collOf(db: DbDevState, handle: number): string | null {
77
+ return handle >= 0 && handle < db.handles.length ? db.handles[handle] : null;
78
+ }
79
+
80
+ /**
81
+ * The single-process dev data store: the seven ToilDB families, their per-row
82
+ * schema_versions, the loaded wasm's catalog, and optional on-disk persistence.
83
+ * Process-lifetime, shared across dispatches via the module singleton {@link devDb}.
84
+ */
85
+ export class DevDatabase {
86
+ /** Process-lifetime store: `"collection\0keyLatin1"` -> value. Shared across dispatches. */
87
+ private readonly store = new Map<string, Buffer>();
88
+ /** View family: `"collection\0key"` -> the latest published view blob. */
89
+ private readonly views = new Map<string, Buffer>();
90
+ /** Membership family: `"collection\0setKey"` -> (memberLatin1 -> member bytes). */
91
+ private readonly members = new Map<string, Map<string, Buffer>>();
92
+ /** Counter family: `"collection\0key"` -> saturating i64 sum of deltas. */
93
+ private readonly counters = new Map<string, bigint>();
94
+ /** Events family: `"collection\0key"` -> append-ordered event blobs (oldest first). */
95
+ private readonly events = new Map<string, Buffer[]>();
96
+ /** append_once dedup: `"collection\0key"` -> set of eventIds already appended. */
97
+ private readonly eventDedup = new Map<string, Set<string>>();
98
+ /** Capacity family: `"collection\0key"` -> an escrow ledger (ceiling + reservations). */
99
+ private readonly capacity = new Map<string, CapLedger>();
100
+
101
+ /** `"collection\0key"` -> the schema_version the record/view/unique-owner was last
102
+ * written under (single-value families; the edge stores it per StoredValue). */
103
+ private readonly versions = new Map<string, number>();
104
+ /** Per-event schema_version, parallel to `events[sk]` (append order). */
105
+ private readonly eventVersions = new Map<string, number[]>();
106
+ /** Per-member schema_version: `sk` -> (memberLatin1 -> version), parallel to `members`. */
107
+ private readonly memberVersions = new Map<string, Map<string, number>>();
108
+ /** `"<Db>/<collection>"` -> the CURRENT schema_version, from the loaded wasm catalog. */
109
+ private catalog = new Map<string, number>();
110
+
111
+ // ---- on-disk persistence: dev data + its versions survive restarts, so a
112
+ // developer can write rows, evolve a @data type, restart, and watch the @migrate
113
+ // run. Delete the file to reset the dev database. JSON with base64 buffers.
114
+ private persistPath: string | null = null;
115
+
116
+ /** (Re)load the collection -> current-schema_version map from a server wasm. The
117
+ * module loader calls this on every (re)compile so writes stamp the live version. */
118
+ setCatalog(wasm: Buffer): void {
119
+ this.catalog = parseCatalog(wasm);
120
+ }
121
+
122
+ private stampVersion(coll: string, sk: string): void {
123
+ this.versions.set(sk, this.catalog.get(coll) ?? 0); // stamp the value type's current version
124
+ }
125
+
126
+ /** Point the dev DB at an on-disk file and load any existing snapshot. Call once at
127
+ * dev-server startup. Deleting the file (or the whole `.toil/` dir) resets dev data. */
128
+ configurePersistence(filePath: string): void {
129
+ this.persistPath = filePath;
130
+ this.load();
131
+ }
132
+
133
+ /** Write the current store to disk (no-op if persistence is not configured). The
134
+ * module loader calls this after each dispatch so a crash never loses a write. */
135
+ persist(): void {
136
+ if (this.persistPath === null) return;
137
+ const snap: DbSnapshot = {
138
+ store: {},
139
+ views: {},
140
+ members: {},
141
+ counters: {},
142
+ events: {},
143
+ eventDedup: {},
144
+ capacity: {},
145
+ };
146
+ for (const [k, v] of this.store)
147
+ snap.store[k] = { v: v.toString('base64'), sv: this.versions.get(k) ?? 0 };
148
+ for (const [k, v] of this.views)
149
+ snap.views[k] = { v: v.toString('base64'), sv: this.versions.get(k) ?? 0 };
150
+ for (const [k, m] of this.members) {
151
+ const o: Record<string, { v: string; sv: number }> = {};
152
+ const mv = this.memberVersions.get(k);
153
+ for (const [mk, mvb] of m) o[mk] = { v: mvb.toString('base64'), sv: mv?.get(mk) ?? 0 };
154
+ snap.members[k] = o;
155
+ }
156
+ for (const [k, v] of this.counters) snap.counters[k] = v.toString();
157
+ for (const [k, log] of this.events) {
158
+ const ver = this.eventVersions.get(k) ?? [];
159
+ snap.events[k] = log.map((b, i) => ({ v: b.toString('base64'), sv: ver[i] ?? 0 }));
160
+ }
161
+ for (const [k, s] of this.eventDedup) snap.eventDedup[k] = [...s];
162
+ for (const [k, l] of this.capacity)
163
+ snap.capacity[k] = {
164
+ total: l.total.toString(),
165
+ nextId: l.nextId.toString(),
166
+ reservations: [...l.reservations].map(([id, r]) => [
167
+ id.toString(),
168
+ { amount: r.amount.toString(), expiresMs: r.expiresMs, confirmed: r.confirmed },
169
+ ]),
170
+ };
171
+ try {
172
+ fs.mkdirSync(path.dirname(this.persistPath), { recursive: true });
173
+ // Write to a temp file then rename: atomic on POSIX, so a crash mid-write
174
+ // leaves the previous good snapshot intact instead of a truncated file
175
+ // that would fail to parse and drop the whole dev database on next load.
176
+ const tmp = `${this.persistPath}.${process.pid}.tmp`;
177
+ fs.writeFileSync(tmp, JSON.stringify(snap));
178
+ fs.renameSync(tmp, this.persistPath);
179
+ } catch {
180
+ // dev-only best effort; a write failure must never crash a request.
181
+ }
182
+ }
183
+
184
+ private load(): void {
185
+ if (this.persistPath === null) return;
186
+ let snap: DbSnapshot;
187
+ try {
188
+ snap = JSON.parse(fs.readFileSync(this.persistPath, 'utf8')) as DbSnapshot;
189
+ } catch {
190
+ return; // no snapshot yet (or unreadable) - start empty
191
+ }
192
+ this.clear();
193
+ for (const [k, e] of Object.entries(snap.store ?? {})) {
194
+ this.store.set(k, Buffer.from(e.v, 'base64'));
195
+ this.versions.set(k, e.sv);
196
+ }
197
+ for (const [k, e] of Object.entries(snap.views ?? {})) {
198
+ this.views.set(k, Buffer.from(e.v, 'base64'));
199
+ this.versions.set(k, e.sv);
200
+ }
201
+ for (const [k, m] of Object.entries(snap.members ?? {})) {
202
+ const map = new Map<string, Buffer>();
203
+ const ver = new Map<string, number>();
204
+ for (const [mk, e] of Object.entries(m)) {
205
+ map.set(mk, Buffer.from(e.v, 'base64'));
206
+ ver.set(mk, e.sv);
207
+ }
208
+ this.members.set(k, map);
209
+ this.memberVersions.set(k, ver);
210
+ }
211
+ for (const [k, v] of Object.entries(snap.counters ?? {})) this.counters.set(k, BigInt(v));
212
+ for (const [k, log] of Object.entries(snap.events ?? {})) {
213
+ this.events.set(
214
+ k,
215
+ log.map((e) => Buffer.from(e.v, 'base64')),
216
+ );
217
+ this.eventVersions.set(
218
+ k,
219
+ log.map((e) => e.sv),
220
+ );
221
+ }
222
+ for (const [k, ids] of Object.entries(snap.eventDedup ?? {}))
223
+ this.eventDedup.set(k, new Set(ids));
224
+ for (const [k, l] of Object.entries(snap.capacity ?? {})) {
225
+ const res = new Map<bigint, Reservation>();
226
+ for (const [id, r] of l.reservations)
227
+ res.set(BigInt(id), {
228
+ amount: BigInt(r.amount),
229
+ expiresMs: r.expiresMs,
230
+ confirmed: r.confirmed,
231
+ });
232
+ this.capacity.set(k, {
233
+ total: BigInt(l.total),
234
+ nextId: BigInt(l.nextId),
235
+ reservations: res,
236
+ });
237
+ }
238
+ }
239
+
240
+ private clear(): void {
241
+ this.store.clear();
242
+ this.versions.clear();
243
+ this.views.clear();
244
+ this.members.clear();
245
+ this.memberVersions.clear();
246
+ this.counters.clear();
247
+ this.eventVersions.clear();
248
+ this.events.clear();
249
+ this.eventDedup.clear();
250
+ this.capacity.clear();
251
+ }
252
+
253
+ private capLedger(sk: string): CapLedger {
254
+ let l = this.capacity.get(sk);
255
+ if (l === undefined) {
256
+ l = { total: 0n, reservations: new Map(), nextId: 1n };
257
+ this.capacity.set(sk, l);
258
+ }
259
+ return l;
260
+ }
261
+
262
+ /** Drop UN-confirmed reservations whose TTL elapsed (a confirmed sale never expires). */
263
+ private capPrune(l: CapLedger, nowMs: number): void {
264
+ for (const [id, r] of l.reservations)
265
+ if (!r.confirmed && r.expiresMs <= nowMs) l.reservations.delete(id);
266
+ }
267
+
268
+ /** Units reserved against the ceiling: held (un-expired) + confirmed (call capPrune first). */
269
+ private capReserved(l: CapLedger): bigint {
270
+ let sum = 0n;
271
+ for (const r of l.reservations.values()) sum += r.amount;
272
+ return sum;
273
+ }
274
+
275
+ // --- record family (resolve / get / get_many / exists / create / patch /
276
+ // delete / get_delete) ---
277
+
278
+ resolveCollection(
279
+ ref: MemoryRef,
280
+ db: DbDevState,
281
+ namePtr: number,
282
+ nameLen: number,
283
+ outHandlePtr: number,
284
+ ): number {
285
+ if (nameLen < 0 || nameLen > MAX_NAME) throw new Error('data: collection name too long');
286
+ const name = readCopy(ref, namePtr, nameLen).toString('utf8');
287
+ const handle = db.handles.length;
288
+ db.handles.push(name);
289
+ const m = mem(ref);
290
+ if (outHandlePtr < 0 || outHandlePtr + 4 > m.length)
291
+ throw new Error('data: resolve out-handle out of bounds');
292
+ m.writeUInt32LE(handle, outHandlePtr);
293
+ return 0;
294
+ }
295
+
296
+ get(ref: MemoryRef, db: DbDevState, handle: number, keyPtr: number, keyLen: number): number {
297
+ const coll = collOf(db, handle);
298
+ if (coll === null) return INVALID_HANDLE;
299
+ if (keyLen > MAX_KEY) throw new Error('data: key too long');
300
+ const sk = storeKey(coll, readKey(ref, keyPtr, keyLen));
301
+ const v = this.store.get(sk);
302
+ if (v === undefined) return ABSENT;
303
+ db.lastResult = v;
304
+ db.lastResultVersion = this.versions.get(sk) ?? 0; // surface the row's stored version
305
+ return v.length;
306
+ }
307
+
308
+ // Bounded multi-get. Keys blob: u32 count + per key (u32 len + bytes).
309
+ // Result (stashed): u32 count + per item u8 present (+ u32 len + bytes),
310
+ // in request order. Mirrors the edge `op_get_many` framing byte-for-byte.
311
+ getMany(
312
+ ref: MemoryRef,
313
+ db: DbDevState,
314
+ handle: number,
315
+ keysPtr: number,
316
+ keysLen: number,
317
+ ): number {
318
+ const coll = collOf(db, handle);
319
+ if (coll === null) return INVALID_HANDLE;
320
+ if (keysLen > MAX_VALUE) throw new Error('data: keys blob too large');
321
+ // Keys blob: u32 count, then per key a u32-length-prefixed blob. The shared
322
+ // DataReader is bounds-safe (empty past end), so a malformed/truncated blob
323
+ // can't over-read; cap each key at MAX_KEY like the edge's prepare_key.
324
+ const r = new DataReader(readCopy(ref, keysPtr, keysLen));
325
+ const count = r.readU32();
326
+ if (count > 1024) return TOO_MANY_KEYS; // anti-OOM cap, mirrors the edge
327
+ // Result: u32 count, then per item present(u8) + (when present) the row's
328
+ // stored schema_version (u32, per-item @migrate dispatch) + value (u32 len +
329
+ // bytes). Byte-identical to the edge op_get_many framing.
330
+ const w = new DataWriter();
331
+ w.writeU32(count);
332
+ for (let i = 0; i < count; i++) {
333
+ const key = r.readBytes();
334
+ if (key.length > MAX_KEY) throw new Error('data: key too long');
335
+ const sk = storeKey(coll, Buffer.from(key));
336
+ const v = this.store.get(sk);
337
+ if (v === undefined) {
338
+ w.writeU8(0);
339
+ } else {
340
+ w.writeU8(1).writeU32(this.versions.get(sk) ?? 0).writeBytes(v);
341
+ }
342
+ }
343
+ db.lastResult = Buffer.from(w.toBytes());
344
+ return db.lastResult.length;
345
+ }
346
+
347
+ exists(ref: MemoryRef, db: DbDevState, handle: number, keyPtr: number, keyLen: number): number {
348
+ const coll = collOf(db, handle);
349
+ if (coll === null) return INVALID_HANDLE;
350
+ return this.store.has(storeKey(coll, readKey(ref, keyPtr, keyLen))) ? 1 : 0;
351
+ }
352
+
353
+ create(
354
+ ref: MemoryRef,
355
+ db: DbDevState,
356
+ handle: number,
357
+ keyPtr: number,
358
+ keyLen: number,
359
+ valPtr: number,
360
+ valLen: number,
361
+ ): number {
362
+ const coll = collOf(db, handle);
363
+ if (coll === null) return INVALID_HANDLE;
364
+ if (keyLen > MAX_KEY || valLen > MAX_VALUE) throw new Error('data: key/value too large');
365
+ const sk = storeKey(coll, readKey(ref, keyPtr, keyLen));
366
+ if (this.store.has(sk)) return ALREADY_EXISTS;
367
+ this.store.set(sk, readCopy(ref, valPtr, valLen));
368
+ this.stampVersion(coll, sk); // stamp the value type's current schema version
369
+ return 0;
370
+ }
371
+
372
+ patch(
373
+ ref: MemoryRef,
374
+ db: DbDevState,
375
+ handle: number,
376
+ keyPtr: number,
377
+ keyLen: number,
378
+ patchPtr: number,
379
+ patchLen: number,
380
+ ): number {
381
+ const coll = collOf(db, handle);
382
+ if (coll === null) return INVALID_HANDLE;
383
+ if (keyLen > MAX_KEY || patchLen > MAX_VALUE) throw new Error('data: key/patch too large');
384
+ const sk = storeKey(coll, readKey(ref, keyPtr, keyLen));
385
+ if (!this.store.has(sk)) return ABSENT; // NotFound -> ABSENT on the edge
386
+ const v = readCopy(ref, patchPtr, patchLen);
387
+ this.store.set(sk, v);
388
+ this.stampVersion(coll, sk); // a patch rewrites the row at the current version
389
+ db.lastResult = v; // patch returns the stored record
390
+ db.lastResultVersion = -1; // the just-written value is current; never migrate it (matches the edge's LenStash None)
391
+ return v.length;
392
+ }
393
+
394
+ delete(ref: MemoryRef, db: DbDevState, handle: number, keyPtr: number, keyLen: number): number {
395
+ const coll = collOf(db, handle);
396
+ if (coll === null) return INVALID_HANDLE;
397
+ const sk = storeKey(coll, readKey(ref, keyPtr, keyLen));
398
+ this.store.delete(sk);
399
+ this.versions.delete(sk);
400
+ return 0;
401
+ }
402
+
403
+ // Atomic fetch-and-delete (consume-once); deletes only on a real read.
404
+ getDelete(
405
+ ref: MemoryRef,
406
+ db: DbDevState,
407
+ handle: number,
408
+ keyPtr: number,
409
+ keyLen: number,
410
+ ): number {
411
+ const coll = collOf(db, handle);
412
+ if (coll === null) return INVALID_HANDLE;
413
+ const sk = storeKey(coll, readKey(ref, keyPtr, keyLen));
414
+ const v = this.store.get(sk);
415
+ if (v === undefined) return ABSENT;
416
+ db.lastResultVersion = this.versions.get(sk) ?? 0; // surface before consuming
417
+ this.store.delete(sk);
418
+ this.versions.delete(sk);
419
+ db.lastResult = v;
420
+ return v.length;
421
+ }
422
+
423
+ // --- unique family (lookup / claim / release) ---
424
+
425
+ uniqueLookup(
426
+ ref: MemoryRef,
427
+ db: DbDevState,
428
+ handle: number,
429
+ keyPtr: number,
430
+ keyLen: number,
431
+ ): number {
432
+ const coll = collOf(db, handle);
433
+ if (coll === null) return INVALID_HANDLE;
434
+ const sk = storeKey(coll, readKey(ref, keyPtr, keyLen));
435
+ const v = this.store.get(sk);
436
+ if (v === undefined) return ABSENT;
437
+ db.lastResult = v;
438
+ db.lastResultVersion = this.versions.get(sk) ?? 0;
439
+ return v.length;
440
+ }
441
+
442
+ // Tag: 0 Claimed, 1 AlreadyClaimed (owner stashed), 2 AlreadyOwnedByCaller.
443
+ uniqueClaim(
444
+ ref: MemoryRef,
445
+ db: DbDevState,
446
+ handle: number,
447
+ keyPtr: number,
448
+ keyLen: number,
449
+ valPtr: number,
450
+ valLen: number,
451
+ ): number {
452
+ const coll = collOf(db, handle);
453
+ if (coll === null) return INVALID_HANDLE;
454
+ if (keyLen > MAX_KEY || valLen > MAX_VALUE) throw new Error('data: key/value too large');
455
+ const sk = storeKey(coll, readKey(ref, keyPtr, keyLen));
456
+ const owner = readCopy(ref, valPtr, valLen);
457
+ const existing = this.store.get(sk);
458
+ if (existing === undefined) {
459
+ this.store.set(sk, owner);
460
+ this.stampVersion(coll, sk);
461
+ return 0; // Claimed
462
+ }
463
+ if (existing.equals(owner)) return 2; // AlreadyOwnedByCaller
464
+ db.lastResult = existing;
465
+ return 1; // AlreadyClaimed (current owner stashed)
466
+ }
467
+
468
+ uniqueRelease(
469
+ ref: MemoryRef,
470
+ db: DbDevState,
471
+ handle: number,
472
+ keyPtr: number,
473
+ keyLen: number,
474
+ valPtr: number,
475
+ valLen: number,
476
+ ): number {
477
+ const coll = collOf(db, handle);
478
+ if (coll === null) return INVALID_HANDLE;
479
+ const sk = storeKey(coll, readKey(ref, keyPtr, keyLen));
480
+ const existing = this.store.get(sk);
481
+ if (existing === undefined) return 0; // idempotent
482
+ if (!existing.equals(readCopy(ref, valPtr, valLen))) return CONFLICT; // not the owner
483
+ this.store.delete(sk);
484
+ this.versions.delete(sk);
485
+ return 0;
486
+ }
487
+
488
+ // --- membership family (contains / add / remove / list) ---
489
+
490
+ membershipContains(
491
+ ref: MemoryRef,
492
+ db: DbDevState,
493
+ handle: number,
494
+ setPtr: number,
495
+ setLen: number,
496
+ memberPtr: number,
497
+ memberLen: number,
498
+ ): number {
499
+ const coll = collOf(db, handle);
500
+ if (coll === null) return INVALID_HANDLE;
501
+ const set = this.members.get(storeKey(coll, readKey(ref, setPtr, setLen)));
502
+ if (set === undefined) return 0;
503
+ return set.has(readCopy(ref, memberPtr, memberLen).toString('latin1')) ? 1 : 0;
504
+ }
505
+
506
+ membershipAdd(
507
+ ref: MemoryRef,
508
+ db: DbDevState,
509
+ handle: number,
510
+ setPtr: number,
511
+ setLen: number,
512
+ memberPtr: number,
513
+ memberLen: number,
514
+ ): number {
515
+ const coll = collOf(db, handle);
516
+ if (coll === null) return INVALID_HANDLE;
517
+ if (setLen > MAX_KEY || memberLen > MAX_VALUE)
518
+ throw new Error('data: set/member too large');
519
+ const sk = storeKey(coll, readKey(ref, setPtr, setLen));
520
+ const member = readCopy(ref, memberPtr, memberLen);
521
+ let set = this.members.get(sk);
522
+ if (set === undefined) {
523
+ set = new Map();
524
+ this.members.set(sk, set);
525
+ }
526
+ const ml = member.toString('latin1');
527
+ set.set(ml, member);
528
+ let mv = this.memberVersions.get(sk);
529
+ if (mv === undefined) {
530
+ mv = new Map();
531
+ this.memberVersions.set(sk, mv);
532
+ }
533
+ mv.set(ml, this.catalog.get(coll) ?? 0);
534
+ return 0;
535
+ }
536
+
537
+ membershipRemove(
538
+ ref: MemoryRef,
539
+ db: DbDevState,
540
+ handle: number,
541
+ setPtr: number,
542
+ setLen: number,
543
+ memberPtr: number,
544
+ memberLen: number,
545
+ ): number {
546
+ const coll = collOf(db, handle);
547
+ if (coll === null) return INVALID_HANDLE;
548
+ const sk = storeKey(coll, readKey(ref, setPtr, setLen));
549
+ const ml = readCopy(ref, memberPtr, memberLen).toString('latin1');
550
+ this.members.get(sk)?.delete(ml);
551
+ this.memberVersions.get(sk)?.delete(ml);
552
+ return 0;
553
+ }
554
+
555
+ // Frame the members (sorted by bytes, matching the edge BTreeMap) as
556
+ // `u32 count` + per member `u32 len + bytes`; stash + return the length.
557
+ membershipList(
558
+ ref: MemoryRef,
559
+ db: DbDevState,
560
+ handle: number,
561
+ setPtr: number,
562
+ setLen: number,
563
+ limit: number,
564
+ ): number {
565
+ const coll = collOf(db, handle);
566
+ if (coll === null) return INVALID_HANDLE;
567
+ const sk = storeKey(coll, readKey(ref, setPtr, setLen));
568
+ const set = this.members.get(sk);
569
+ const mv = this.memberVersions.get(sk);
570
+ const n = Math.max(0, Math.min(limit, 0xffff));
571
+ const members =
572
+ set === undefined ? [] : Array.from(set.values()).sort(Buffer.compare).slice(0, n);
573
+ // u32 count, then per member its stored schema_version (u32) + bytes (u32
574
+ // len + bytes). Same framing as the edge op_membership_list.
575
+ const w = new DataWriter();
576
+ w.writeU32(members.length);
577
+ for (const m of members) {
578
+ w.writeU32(mv?.get(m.toString('latin1')) ?? 0).writeBytes(m);
579
+ }
580
+ db.lastResult = Buffer.from(w.toBytes());
581
+ return db.lastResult.length;
582
+ }
583
+
584
+ // --- view family (get / publish) ---
585
+
586
+ viewGet(
587
+ ref: MemoryRef,
588
+ db: DbDevState,
589
+ handle: number,
590
+ keyPtr: number,
591
+ keyLen: number,
592
+ ): number {
593
+ const coll = collOf(db, handle);
594
+ if (coll === null) return INVALID_HANDLE;
595
+ const sk = storeKey(coll, readKey(ref, keyPtr, keyLen));
596
+ const v = this.views.get(sk);
597
+ if (v === undefined) return ABSENT;
598
+ db.lastResult = v;
599
+ db.lastResultVersion = this.versions.get(sk) ?? 0;
600
+ return v.length;
601
+ }
602
+
603
+ // Publish overwrites (the host assigns the version; dev keeps the latest).
604
+ viewPublish(
605
+ ref: MemoryRef,
606
+ db: DbDevState,
607
+ handle: number,
608
+ keyPtr: number,
609
+ keyLen: number,
610
+ valPtr: number,
611
+ valLen: number,
612
+ ): number {
613
+ const coll = collOf(db, handle);
614
+ if (coll === null) return INVALID_HANDLE;
615
+ if (keyLen > MAX_KEY || valLen > MAX_VALUE) throw new Error('data: key/view too large');
616
+ const sk = storeKey(coll, readKey(ref, keyPtr, keyLen));
617
+ this.views.set(sk, readCopy(ref, valPtr, valLen));
618
+ this.stampVersion(coll, sk);
619
+ return 0;
620
+ }
621
+
622
+ // --- counter family (get / add) ---
623
+
624
+ // Stash the i64 sum as 8 LE bytes; the guest pulls + loads it. A counter
625
+ // with no deltas reads as 0 (never absent).
626
+ counterGet(
627
+ ref: MemoryRef,
628
+ db: DbDevState,
629
+ handle: number,
630
+ keyPtr: number,
631
+ keyLen: number,
632
+ ): number {
633
+ const coll = collOf(db, handle);
634
+ if (coll === null) return INVALID_HANDLE;
635
+ const sum = this.counters.get(storeKey(coll, readKey(ref, keyPtr, keyLen))) ?? 0n;
636
+ const out = Buffer.alloc(8);
637
+ out.writeBigInt64LE(sum);
638
+ db.lastResult = out;
639
+ return out.length;
640
+ }
641
+
642
+ // `delta` is the wasm i64 (a BigInt across the boundary); `BigInt()`
643
+ // normalizes the test's plain-number form too. Saturates like the edge.
644
+ counterAdd(
645
+ ref: MemoryRef,
646
+ db: DbDevState,
647
+ handle: number,
648
+ keyPtr: number,
649
+ keyLen: number,
650
+ delta: number | bigint,
651
+ ): number {
652
+ const coll = collOf(db, handle);
653
+ if (coll === null) return INVALID_HANDLE;
654
+ const sk = storeKey(coll, readKey(ref, keyPtr, keyLen));
655
+ this.counters.set(sk, satI64((this.counters.get(sk) ?? 0n) + BigInt(delta)));
656
+ return 0;
657
+ }
658
+
659
+ // --- events family (append / append_once / enqueue / latest) ---
660
+
661
+ append(
662
+ ref: MemoryRef,
663
+ db: DbDevState,
664
+ handle: number,
665
+ keyPtr: number,
666
+ keyLen: number,
667
+ evPtr: number,
668
+ evLen: number,
669
+ ): number {
670
+ const coll = collOf(db, handle);
671
+ if (coll === null) return INVALID_HANDLE;
672
+ if (keyLen > MAX_KEY || evLen > MAX_VALUE) throw new Error('data: key/event too large');
673
+ const sk = storeKey(coll, readKey(ref, keyPtr, keyLen));
674
+ const log = this.events.get(sk);
675
+ const ev = readCopy(ref, evPtr, evLen);
676
+ const sv = this.catalog.get(coll) ?? 0;
677
+ if (log === undefined) {
678
+ this.events.set(sk, [ev]);
679
+ this.eventVersions.set(sk, [sv]);
680
+ } else {
681
+ log.push(ev);
682
+ (this.eventVersions.get(sk) ?? this.eventVersions.set(sk, []).get(sk)!).push(sv);
683
+ }
684
+ return 0;
685
+ }
686
+
687
+ // Idempotent append: dedup on eventId. 1 appended, 0 duplicate. Mirrors the
688
+ // edge's (key, event_id) dedup marker (just an in-memory set in dev).
689
+ appendOnce(
690
+ ref: MemoryRef,
691
+ db: DbDevState,
692
+ handle: number,
693
+ keyPtr: number,
694
+ keyLen: number,
695
+ evidPtr: number,
696
+ evidLen: number,
697
+ evPtr: number,
698
+ evLen: number,
699
+ ): number {
700
+ const coll = collOf(db, handle);
701
+ if (coll === null) return INVALID_HANDLE;
702
+ if (keyLen > MAX_KEY || evLen > MAX_VALUE) throw new Error('data: key/event too large');
703
+ const sk = storeKey(coll, readKey(ref, keyPtr, keyLen));
704
+ const evid = readCopy(ref, evidPtr, evidLen).toString('latin1');
705
+ let seen = this.eventDedup.get(sk);
706
+ if (seen === undefined) {
707
+ seen = new Set();
708
+ this.eventDedup.set(sk, seen);
709
+ }
710
+ if (seen.has(evid)) return 0; // already appended under this id
711
+ const ev = readCopy(ref, evPtr, evLen);
712
+ const sv = this.catalog.get(coll) ?? 0;
713
+ const log = this.events.get(sk);
714
+ if (log === undefined) {
715
+ this.events.set(sk, [ev]);
716
+ this.eventVersions.set(sk, [sv]);
717
+ } else {
718
+ log.push(ev);
719
+ (this.eventVersions.get(sk) ?? this.eventVersions.set(sk, []).get(sk)!).push(sv);
720
+ }
721
+ seen.add(evid);
722
+ return 1;
723
+ }
724
+
725
+ // Version-checked replace of an EXISTING record's value. Returns 0 on apply,
726
+ // ABSENT (-2) if the record is absent. A single dev process has no concurrent
727
+ // writer, so the optimistic-concurrency check always succeeds here.
728
+ enqueue(
729
+ ref: MemoryRef,
730
+ db: DbDevState,
731
+ handle: number,
732
+ keyPtr: number,
733
+ keyLen: number,
734
+ valPtr: number,
735
+ valLen: number,
736
+ ): number {
737
+ const coll = collOf(db, handle);
738
+ if (coll === null) return INVALID_HANDLE;
739
+ if (keyLen > MAX_KEY || valLen > MAX_VALUE) throw new Error('data: key/value too large');
740
+ const sk = storeKey(coll, readKey(ref, keyPtr, keyLen));
741
+ if (!this.store.has(sk)) return ABSENT; // enqueue replaces an existing record
742
+ this.store.set(sk, readCopy(ref, valPtr, valLen));
743
+ this.stampVersion(coll, sk);
744
+ return 0;
745
+ }
746
+
747
+ // Frame the newest-`limit` events as `u32 count` then per event a
748
+ // length-prefixed blob (`u32 len + bytes`), newest first; stash + return
749
+ // the blob length. Matches the edge `op_latest` / `toildb::Writer` framing.
750
+ latest(
751
+ ref: MemoryRef,
752
+ db: DbDevState,
753
+ handle: number,
754
+ keyPtr: number,
755
+ keyLen: number,
756
+ limit: number,
757
+ ): number {
758
+ const coll = collOf(db, handle);
759
+ if (coll === null) return INVALID_HANDLE;
760
+ const sk = storeKey(coll, readKey(ref, keyPtr, keyLen));
761
+ const log = this.events.get(sk) ?? [];
762
+ const vers = this.eventVersions.get(sk) ?? [];
763
+ const n = Math.max(0, Math.min(limit, 0xffff));
764
+ const start = Math.max(0, log.length - n);
765
+ const newest = log.slice(start).reverse();
766
+ const newestVers = vers.slice(start).reverse();
767
+ // u32 count, then per event its stored schema_version (u32) + bytes (u32 len
768
+ // + bytes), newest first. Same framing as the edge op_latest.
769
+ const w = new DataWriter();
770
+ w.writeU32(newest.length);
771
+ for (let i = 0; i < newest.length; i++) {
772
+ w.writeU32(newestVers[i] ?? 0).writeBytes(newest[i]);
773
+ }
774
+ db.lastResult = Buffer.from(w.toBytes());
775
+ return db.lastResult.length;
776
+ }
777
+
778
+ // --- capacity family (escrow: set_total / available / reserve / confirm / cancel) ---
779
+
780
+ // Set the ceiling (restock / reduce). Job/derive only (kind-gated upstream).
781
+ // A ceiling is never negative.
782
+ capacitySetTotal(
783
+ ref: MemoryRef,
784
+ db: DbDevState,
785
+ handle: number,
786
+ keyPtr: number,
787
+ keyLen: number,
788
+ total: number | bigint,
789
+ ): number {
790
+ const coll = collOf(db, handle);
791
+ if (coll === null) return INVALID_HANDLE;
792
+ const l = this.capLedger(storeKey(coll, readKey(ref, keyPtr, keyLen)));
793
+ const t = BigInt(total);
794
+ l.total = satI64(t < 0n ? 0n : t);
795
+ return 0;
796
+ }
797
+
798
+ // Stash the i64 available (total - reserved [held + confirmed], floored at 0).
799
+ capacityAvailable(
800
+ ref: MemoryRef,
801
+ db: DbDevState,
802
+ handle: number,
803
+ keyPtr: number,
804
+ keyLen: number,
805
+ ): number {
806
+ const coll = collOf(db, handle);
807
+ if (coll === null) return INVALID_HANDLE;
808
+ const l = this.capLedger(storeKey(coll, readKey(ref, keyPtr, keyLen)));
809
+ this.capPrune(l, Date.now());
810
+ const avail = l.total - this.capReserved(l);
811
+ const out = Buffer.alloc(8);
812
+ out.writeBigInt64LE(avail < 0n ? 0n : avail);
813
+ db.lastResult = out;
814
+ return out.length;
815
+ }
816
+
817
+ // Hold `amount` for `ttlMs`: stash the u64 reservation id (8 bytes) on
818
+ // success. A non-positive amount is a typed error (CODEC_ERR), matching the
819
+ // edge's BadAmount; insufficient available OR too many live reservations is
820
+ // ABSENT (-2) (the guest maps that to reservation 0 = no oversell). The TTL
821
+ // is clamped to the edge's 24h ceiling. `now` is the HOST clock.
822
+ capacityReserve(
823
+ ref: MemoryRef,
824
+ db: DbDevState,
825
+ handle: number,
826
+ keyPtr: number,
827
+ keyLen: number,
828
+ amount: number | bigint,
829
+ ttlMs: number | bigint,
830
+ ): number {
831
+ const coll = collOf(db, handle);
832
+ if (coll === null) return INVALID_HANDLE;
833
+ const want = BigInt(amount);
834
+ if (want <= 0n) return CODEC_ERR; // BadAmount (edge: -1006)
835
+ const l = this.capLedger(storeKey(coll, readKey(ref, keyPtr, keyLen)));
836
+ const now = Date.now();
837
+ this.capPrune(l, now);
838
+ if (l.total - this.capReserved(l) < want || l.reservations.size >= MAX_RESERVATIONS)
839
+ return ABSENT; // never oversell; bound the reservation count
840
+ const ttl = Math.min(Math.max(0, Number(ttlMs)), MAX_RESERVATION_TTL_MS);
841
+ const id = l.nextId++;
842
+ l.reservations.set(id, { amount: want, expiresMs: now + ttl, confirmed: false });
843
+ const out = Buffer.alloc(8);
844
+ out.writeBigUInt64LE(id);
845
+ db.lastResult = out;
846
+ return out.length;
847
+ }
848
+
849
+ // Finalize a reservation into a permanent consume. IDEMPOTENT: the
850
+ // reservation is flagged confirmed (and kept), so a retry of a settled id
851
+ // still returns 1; 0 only when the id is unknown / expired-and-pruned.
852
+ capacityConfirm(
853
+ ref: MemoryRef,
854
+ db: DbDevState,
855
+ handle: number,
856
+ keyPtr: number,
857
+ keyLen: number,
858
+ reservationId: number | bigint,
859
+ ): number {
860
+ const coll = collOf(db, handle);
861
+ if (coll === null) return INVALID_HANDLE;
862
+ const l = this.capLedger(storeKey(coll, readKey(ref, keyPtr, keyLen)));
863
+ this.capPrune(l, Date.now());
864
+ const r = l.reservations.get(BigInt(reservationId));
865
+ if (r === undefined) return 0;
866
+ r.confirmed = true;
867
+ return 1;
868
+ }
869
+
870
+ // Release a HELD reservation back to available. A confirmed sale cannot be
871
+ // cancelled (returns 0), nor an unknown id.
872
+ capacityCancel(
873
+ ref: MemoryRef,
874
+ db: DbDevState,
875
+ handle: number,
876
+ keyPtr: number,
877
+ keyLen: number,
878
+ reservationId: number | bigint,
879
+ ): number {
880
+ const coll = collOf(db, handle);
881
+ if (coll === null) return INVALID_HANDLE;
882
+ const l = this.capLedger(storeKey(coll, readKey(ref, keyPtr, keyLen)));
883
+ this.capPrune(l, Date.now());
884
+ const id = BigInt(reservationId);
885
+ const r = l.reservations.get(id);
886
+ if (r === undefined || r.confirmed) return 0;
887
+ l.reservations.delete(id);
888
+ return 1;
889
+ }
890
+
891
+ // --- result pull + per-row metadata ---
892
+
893
+ // Drain the last stashed variable-length result into the caller buffer.
894
+ takeResult(ref: MemoryRef, db: DbDevState, outPtr: number, outCap: number): number {
895
+ const v = db.lastResult;
896
+ if (v === null) return 0;
897
+ if (v.length > outCap) return TOO_SMALL; // keep the stash for retry
898
+ const m = mem(ref);
899
+ if (outPtr < 0 || outPtr + v.length > m.length)
900
+ throw new Error('data: take_result out of bounds');
901
+ v.copy(m, outPtr);
902
+ db.lastResult = null;
903
+ return v.length;
904
+ }
905
+
906
+ // `data.result_schema_version() -> i64`: the schema_version the last
907
+ // value-returning read's row was written under, so the guest's woven decoder
908
+ // runs the right @migrate. With on-disk persistence + catalog version
909
+ // stamping, dev DOES surface historical versions: a row written under an old
910
+ // @data layout reports that old version after the type evolves, so the dev
911
+ // server exercises real cross-version decode. -1 means the last op returned
912
+ // no value. An i64 result returns a BigInt in Node's WASM ABI.
913
+ resultSchemaVersion(db: DbDevState): bigint {
914
+ return BigInt(db.lastResultVersion);
915
+ }
916
+
917
+ // --- test hooks ---
918
+
919
+ /** Test-only: clear the stores + catalog + persistence between unit tests. */
920
+ resetForTests(): void {
921
+ this.clear();
922
+ this.catalog = new Map();
923
+ this.persistPath = null;
924
+ }
925
+
926
+ /** Test-only: seed the catalog (collection -> current schema_version) directly. */
927
+ setCatalogForTests(entries: Record<string, number>): void {
928
+ this.catalog = new Map(Object.entries(entries));
929
+ }
930
+ }
931
+
932
+ /** The single process-lifetime dev database (one store per dev/self-host process). */
933
+ export const devDb = new DevDatabase();
934
+
935
+ /** (Re)load the collection -> current schema_version map from a server wasm. */
936
+ export function setDbCatalog(wasm: Buffer): void {
937
+ devDb.setCatalog(wasm);
938
+ }
939
+
940
+ /** Point the dev DB at an on-disk file and load any existing snapshot. */
941
+ export function configureDbPersistence(filePath: string): void {
942
+ devDb.configurePersistence(filePath);
943
+ }
944
+
945
+ /** Write the current store to disk (no-op if persistence is not configured). */
946
+ export function persistDb(): void {
947
+ devDb.persist();
948
+ }
949
+
950
+ /**
951
+ * Build the `data.*` host imports for one instance, delegating to the shared
952
+ * {@link devDb}. `db` is the per-request state (handles + result stash); bind a
953
+ * fresh one per dispatch.
954
+ */
955
+ export function buildDatabaseImports(
956
+ ref: MemoryRef,
957
+ db: DbDevState,
958
+ ): Record<string, (...args: number[]) => number | bigint> {
959
+ return {
960
+ 'data.resolve_collection': (
961
+ namePtr: number,
962
+ nameLen: number,
963
+ outHandlePtr: number,
964
+ ): number => devDb.resolveCollection(ref, db, namePtr, nameLen, outHandlePtr),
965
+
966
+ 'data.get': (handle: number, keyPtr: number, keyLen: number): number =>
967
+ devDb.get(ref, db, handle, keyPtr, keyLen),
968
+
969
+ 'data.get_many': (handle: number, keysPtr: number, keysLen: number): number =>
970
+ devDb.getMany(ref, db, handle, keysPtr, keysLen),
971
+
972
+ 'data.exists': (handle: number, keyPtr: number, keyLen: number): number =>
973
+ devDb.exists(ref, db, handle, keyPtr, keyLen),
974
+
975
+ 'data.create': (
976
+ handle: number,
977
+ keyPtr: number,
978
+ keyLen: number,
979
+ valPtr: number,
980
+ valLen: number,
981
+ _idemPtr: number,
982
+ ): number => devDb.create(ref, db, handle, keyPtr, keyLen, valPtr, valLen),
983
+
984
+ 'data.patch': (
985
+ handle: number,
986
+ keyPtr: number,
987
+ keyLen: number,
988
+ patchPtr: number,
989
+ patchLen: number,
990
+ _idemPtr: number,
991
+ ): number => devDb.patch(ref, db, handle, keyPtr, keyLen, patchPtr, patchLen),
992
+
993
+ 'data.delete': (handle: number, keyPtr: number, keyLen: number, _idemPtr: number): number =>
994
+ devDb.delete(ref, db, handle, keyPtr, keyLen),
995
+
996
+ 'data.get_delete': (
997
+ handle: number,
998
+ keyPtr: number,
999
+ keyLen: number,
1000
+ _idemPtr: number,
1001
+ ): number => devDb.getDelete(ref, db, handle, keyPtr, keyLen),
1002
+
1003
+ 'data.unique_lookup': (handle: number, keyPtr: number, keyLen: number): number =>
1004
+ devDb.uniqueLookup(ref, db, handle, keyPtr, keyLen),
1005
+
1006
+ 'data.unique_claim': (
1007
+ handle: number,
1008
+ keyPtr: number,
1009
+ keyLen: number,
1010
+ valPtr: number,
1011
+ valLen: number,
1012
+ _idemPtr: number,
1013
+ ): number => devDb.uniqueClaim(ref, db, handle, keyPtr, keyLen, valPtr, valLen),
1014
+
1015
+ 'data.unique_release': (
1016
+ handle: number,
1017
+ keyPtr: number,
1018
+ keyLen: number,
1019
+ valPtr: number,
1020
+ valLen: number,
1021
+ _idemPtr: number,
1022
+ ): number => devDb.uniqueRelease(ref, db, handle, keyPtr, keyLen, valPtr, valLen),
1023
+
1024
+ 'data.membership_contains': (
1025
+ handle: number,
1026
+ setPtr: number,
1027
+ setLen: number,
1028
+ memberPtr: number,
1029
+ memberLen: number,
1030
+ ): number =>
1031
+ devDb.membershipContains(ref, db, handle, setPtr, setLen, memberPtr, memberLen),
1032
+
1033
+ 'data.membership_add': (
1034
+ handle: number,
1035
+ setPtr: number,
1036
+ setLen: number,
1037
+ memberPtr: number,
1038
+ memberLen: number,
1039
+ _idemPtr: number,
1040
+ ): number => devDb.membershipAdd(ref, db, handle, setPtr, setLen, memberPtr, memberLen),
1041
+
1042
+ 'data.membership_remove': (
1043
+ handle: number,
1044
+ setPtr: number,
1045
+ setLen: number,
1046
+ memberPtr: number,
1047
+ memberLen: number,
1048
+ _idemPtr: number,
1049
+ ): number => devDb.membershipRemove(ref, db, handle, setPtr, setLen, memberPtr, memberLen),
1050
+
1051
+ 'data.membership_list': (
1052
+ handle: number,
1053
+ setPtr: number,
1054
+ setLen: number,
1055
+ limit: number,
1056
+ ): number => devDb.membershipList(ref, db, handle, setPtr, setLen, limit),
1057
+
1058
+ 'data.view_get': (handle: number, keyPtr: number, keyLen: number): number =>
1059
+ devDb.viewGet(ref, db, handle, keyPtr, keyLen),
1060
+
1061
+ 'data.view_publish': (
1062
+ handle: number,
1063
+ keyPtr: number,
1064
+ keyLen: number,
1065
+ valPtr: number,
1066
+ valLen: number,
1067
+ _idemPtr: number,
1068
+ ): number => devDb.viewPublish(ref, db, handle, keyPtr, keyLen, valPtr, valLen),
1069
+
1070
+ 'data.counter_get': (handle: number, keyPtr: number, keyLen: number): number =>
1071
+ devDb.counterGet(ref, db, handle, keyPtr, keyLen),
1072
+
1073
+ 'data.counter_add': (
1074
+ handle: number,
1075
+ keyPtr: number,
1076
+ keyLen: number,
1077
+ delta: number | bigint,
1078
+ _idemPtr: number,
1079
+ ): number => devDb.counterAdd(ref, db, handle, keyPtr, keyLen, delta),
1080
+
1081
+ 'data.append': (
1082
+ handle: number,
1083
+ keyPtr: number,
1084
+ keyLen: number,
1085
+ evPtr: number,
1086
+ evLen: number,
1087
+ _idemPtr: number,
1088
+ ): number => devDb.append(ref, db, handle, keyPtr, keyLen, evPtr, evLen),
1089
+
1090
+ 'data.append_once': (
1091
+ handle: number,
1092
+ keyPtr: number,
1093
+ keyLen: number,
1094
+ evidPtr: number,
1095
+ evidLen: number,
1096
+ evPtr: number,
1097
+ evLen: number,
1098
+ ): number =>
1099
+ devDb.appendOnce(ref, db, handle, keyPtr, keyLen, evidPtr, evidLen, evPtr, evLen),
1100
+
1101
+ 'data.enqueue': (
1102
+ handle: number,
1103
+ keyPtr: number,
1104
+ keyLen: number,
1105
+ valPtr: number,
1106
+ valLen: number,
1107
+ _idemPtr: number,
1108
+ ): number => devDb.enqueue(ref, db, handle, keyPtr, keyLen, valPtr, valLen),
1109
+
1110
+ 'data.latest': (handle: number, keyPtr: number, keyLen: number, limit: number): number =>
1111
+ devDb.latest(ref, db, handle, keyPtr, keyLen, limit),
1112
+
1113
+ 'data.capacity_set_total': (
1114
+ handle: number,
1115
+ keyPtr: number,
1116
+ keyLen: number,
1117
+ total: number | bigint,
1118
+ _idemPtr: number,
1119
+ ): number => devDb.capacitySetTotal(ref, db, handle, keyPtr, keyLen, total),
1120
+
1121
+ 'data.capacity_available': (handle: number, keyPtr: number, keyLen: number): number =>
1122
+ devDb.capacityAvailable(ref, db, handle, keyPtr, keyLen),
1123
+
1124
+ 'data.capacity_reserve': (
1125
+ handle: number,
1126
+ keyPtr: number,
1127
+ keyLen: number,
1128
+ amount: number | bigint,
1129
+ ttlMs: number | bigint,
1130
+ _idemPtr: number,
1131
+ ): number => devDb.capacityReserve(ref, db, handle, keyPtr, keyLen, amount, ttlMs),
1132
+
1133
+ 'data.capacity_confirm': (
1134
+ handle: number,
1135
+ keyPtr: number,
1136
+ keyLen: number,
1137
+ reservationId: number | bigint,
1138
+ _idemPtr: number,
1139
+ ): number => devDb.capacityConfirm(ref, db, handle, keyPtr, keyLen, reservationId),
1140
+
1141
+ 'data.capacity_cancel': (
1142
+ handle: number,
1143
+ keyPtr: number,
1144
+ keyLen: number,
1145
+ reservationId: number | bigint,
1146
+ _idemPtr: number,
1147
+ ): number => devDb.capacityCancel(ref, db, handle, keyPtr, keyLen, reservationId),
1148
+
1149
+ 'data.take_result': (outPtr: number, outCap: number): number =>
1150
+ devDb.takeResult(ref, db, outPtr, outCap),
1151
+
1152
+ 'data.result_schema_version': (): bigint => devDb.resultSchemaVersion(db),
1153
+
1154
+ // `data.write_allowed() -> i32`: 1 if the current call may write. Used by the
1155
+ // rewrite-on-read convergence after a lazy migration to persist the converged
1156
+ // row. The dev store permits writes, so return 1.
1157
+ 'data.write_allowed': (): number => 1,
1158
+ };
1159
+ }
1160
+
1161
+ /** Test-only: clear the stores + catalog + persistence between unit tests. */
1162
+ export function __resetDbForTests(): void {
1163
+ devDb.resetForTests();
1164
+ }
1165
+
1166
+ /** Test-only: seed the catalog (collection -> current schema_version) directly. */
1167
+ export function __setDbCatalogForTests(entries: Record<string, number>): void {
1168
+ devDb.setCatalogForTests(entries);
1169
+ }