toiljs 0.0.59 → 0.0.61

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 (158) hide show
  1. package/.github/workflows/ci.yml +31 -0
  2. package/CHANGELOG.md +15 -0
  3. package/build/cli/.tsbuildinfo +1 -1
  4. package/build/cli/index.js +311 -118
  5. package/build/client/.tsbuildinfo +1 -1
  6. package/build/client/index.d.ts +1 -1
  7. package/build/client/index.js +1 -1
  8. package/build/client/routing/mount.js +12 -1
  9. package/build/client/ssr/markers.d.ts +1 -0
  10. package/build/client/ssr/markers.js +3 -0
  11. package/build/compiler/.tsbuildinfo +1 -1
  12. package/build/compiler/config.d.ts +21 -0
  13. package/build/compiler/config.js +35 -0
  14. package/build/compiler/docs.d.ts +2 -1
  15. package/build/compiler/docs.js +33 -304
  16. package/build/compiler/index.d.ts +13 -0
  17. package/build/compiler/index.js +113 -21
  18. package/build/compiler/template-build.d.ts +21 -1
  19. package/build/compiler/template-build.js +110 -26
  20. package/build/compiler/toil-docs.generated.d.ts +1 -0
  21. package/build/compiler/toil-docs.generated.js +20 -0
  22. package/build/devserver/.tsbuildinfo +1 -1
  23. package/build/devserver/daemon/catalog.d.ts +26 -0
  24. package/build/devserver/daemon/catalog.js +48 -0
  25. package/build/devserver/daemon/cron.d.ts +4 -0
  26. package/build/devserver/daemon/cron.js +50 -0
  27. package/build/devserver/daemon/host.d.ts +37 -0
  28. package/build/devserver/daemon/host.js +94 -0
  29. package/build/devserver/daemon/index.d.ts +34 -0
  30. package/build/devserver/daemon/index.js +241 -0
  31. package/build/devserver/db/catalog.d.ts +2 -0
  32. package/build/devserver/db/catalog.js +80 -0
  33. package/build/devserver/db/database.d.ts +80 -0
  34. package/build/devserver/db/database.js +1032 -0
  35. package/build/devserver/db/index.d.ts +3 -0
  36. package/build/devserver/db/index.js +3 -0
  37. package/build/devserver/db/routeKinds.d.ts +8 -0
  38. package/build/devserver/db/routeKinds.js +139 -0
  39. package/build/devserver/db/types.d.ts +121 -0
  40. package/build/devserver/db/types.js +52 -0
  41. package/build/devserver/email/index.js +1 -1
  42. package/build/devserver/index.d.ts +19 -24
  43. package/build/devserver/index.js +11 -165
  44. package/build/devserver/mstore/store.d.ts +18 -0
  45. package/build/devserver/mstore/store.js +82 -0
  46. package/build/devserver/{host.d.ts → runtime/host.d.ts} +7 -1
  47. package/build/devserver/{host.js → runtime/host.js} +51 -7
  48. package/build/devserver/{module.d.ts → runtime/module.d.ts} +2 -1
  49. package/build/devserver/{module.js → runtime/module.js} +34 -1
  50. package/build/devserver/server.d.ts +23 -0
  51. package/build/devserver/server.js +223 -0
  52. package/build/devserver/ssr.d.ts +25 -0
  53. package/build/devserver/ssr.js +114 -0
  54. package/build/devserver/wasm/sections.d.ts +2 -0
  55. package/build/devserver/wasm/sections.js +42 -0
  56. package/build/devserver/wasm/surface.d.ts +18 -0
  57. package/build/devserver/wasm/surface.js +41 -0
  58. package/docs/README.md +4 -4
  59. package/docs/auth-todo.md +6 -6
  60. package/docs/caching.md +5 -5
  61. package/docs/cli.md +15 -0
  62. package/docs/client.md +40 -0
  63. package/docs/crypto.md +4 -4
  64. package/docs/data.md +6 -6
  65. package/docs/email.md +28 -28
  66. package/docs/environment.md +10 -10
  67. package/docs/index.md +26 -0
  68. package/docs/ratelimit.md +10 -10
  69. package/docs/routing.md +2 -2
  70. package/docs/server.md +61 -0
  71. package/docs/ssr.md +561 -113
  72. package/docs/styling.md +22 -0
  73. package/docs/time.md +3 -3
  74. package/eslint.config.js +10 -1
  75. package/examples/basic/client/components/Header.tsx +3 -0
  76. package/examples/basic/client/routes/features/actions.tsx +0 -2
  77. package/examples/basic/client/routes/hello.tsx +89 -19
  78. package/examples/basic/client/styles/main.css +48 -0
  79. package/examples/basic/server/SsrHelloRender.ts +97 -0
  80. package/examples/basic/server/main.ts +5 -0
  81. package/examples/basic/server/migrations/GuestEntry.migration.ts +39 -0
  82. package/examples/basic/server/streams/Echo.ts +49 -0
  83. package/package.json +12 -10
  84. package/scripts/gen-toil-docs.mjs +96 -0
  85. package/server/runtime/time.ts +3 -3
  86. package/src/cli/create.ts +40 -3
  87. package/src/cli/db.ts +158 -0
  88. package/src/cli/diagnostics.ts +19 -0
  89. package/src/cli/doctor.ts +20 -0
  90. package/src/cli/index.ts +10 -0
  91. package/src/cli/update.ts +58 -0
  92. package/src/client/index.ts +1 -1
  93. package/src/client/routing/mount.tsx +18 -2
  94. package/src/client/ssr/markers.tsx +22 -0
  95. package/src/compiler/config.ts +88 -2
  96. package/src/compiler/docs.ts +47 -308
  97. package/src/compiler/index.ts +236 -32
  98. package/src/compiler/ssr-codegen.ts +1 -1
  99. package/src/compiler/template-build.ts +247 -46
  100. package/src/compiler/toil-docs.generated.ts +26 -0
  101. package/src/devserver/daemon/catalog.ts +120 -0
  102. package/src/devserver/daemon/cron.ts +87 -0
  103. package/src/devserver/daemon/host.ts +224 -0
  104. package/src/devserver/daemon/index.ts +349 -0
  105. package/src/devserver/db/catalog.ts +108 -0
  106. package/src/devserver/db/database.ts +1633 -0
  107. package/src/devserver/db/index.ts +18 -0
  108. package/src/devserver/db/routeKinds.ts +147 -0
  109. package/src/devserver/db/types.ts +139 -0
  110. package/src/devserver/email/index.ts +1 -1
  111. package/src/devserver/index.ts +31 -287
  112. package/src/devserver/mstore/store.ts +121 -0
  113. package/src/devserver/{host.ts → runtime/host.ts} +98 -7
  114. package/src/devserver/{module.ts → runtime/module.ts} +47 -1
  115. package/src/devserver/server.ts +393 -0
  116. package/src/devserver/ssr.ts +166 -0
  117. package/src/devserver/wasm/sections.ts +59 -0
  118. package/src/devserver/wasm/surface.ts +88 -0
  119. package/test/daemon-build.test.ts +198 -0
  120. package/test/daemon-catalog.test.ts +265 -0
  121. package/test/daemon-emulation.test.ts +216 -0
  122. package/test/db.test.ts +0 -0
  123. package/test/devserver-database.test.ts +510 -14
  124. package/test/devserver-pqauth.test.ts +1 -1
  125. package/test/devserver-secrets.test.ts +5 -1
  126. package/test/doctor.test.ts +13 -0
  127. package/test/email-preview.test.ts +6 -1
  128. package/test/example-guestbook.test.ts +43 -1
  129. package/test/fixtures/daemon-app.ts +56 -0
  130. package/test/global-setup.ts +17 -0
  131. package/test/pqauth-e2e.test.ts +1 -1
  132. package/test/ssr-render.test.ts +94 -27
  133. package/test/ssr-template.test.tsx +44 -1
  134. package/vitest.config.ts +3 -0
  135. package/build/devserver/database.d.ts +0 -8
  136. package/build/devserver/database.js +0 -418
  137. package/src/devserver/database.ts +0 -618
  138. /package/build/devserver/{dotenv.d.ts → config/dotenv.d.ts} +0 -0
  139. /package/build/devserver/{dotenv.js → config/dotenv.js} +0 -0
  140. /package/build/devserver/{env.d.ts → config/env.d.ts} +0 -0
  141. /package/build/devserver/{env.js → config/env.js} +0 -0
  142. /package/build/devserver/{ratelimit.d.ts → config/ratelimit.d.ts} +0 -0
  143. /package/build/devserver/{ratelimit.js → config/ratelimit.js} +0 -0
  144. /package/build/devserver/{cache.d.ts → http/cache.d.ts} +0 -0
  145. /package/build/devserver/{cache.js → http/cache.js} +0 -0
  146. /package/build/devserver/{envelope.d.ts → http/envelope.d.ts} +0 -0
  147. /package/build/devserver/{envelope.js → http/envelope.js} +0 -0
  148. /package/build/devserver/{proxy.d.ts → http/proxy.d.ts} +0 -0
  149. /package/build/devserver/{proxy.js → http/proxy.js} +0 -0
  150. /package/build/devserver/{crypto.d.ts → runtime/crypto.d.ts} +0 -0
  151. /package/build/devserver/{crypto.js → runtime/crypto.js} +0 -0
  152. /package/src/devserver/{dotenv.ts → config/dotenv.ts} +0 -0
  153. /package/src/devserver/{env.ts → config/env.ts} +0 -0
  154. /package/src/devserver/{ratelimit.ts → config/ratelimit.ts} +0 -0
  155. /package/src/devserver/{cache.ts → http/cache.ts} +0 -0
  156. /package/src/devserver/{envelope.ts → http/envelope.ts} +0 -0
  157. /package/src/devserver/{proxy.ts → http/proxy.ts} +0 -0
  158. /package/src/devserver/{crypto.ts → runtime/crypto.ts} +0 -0
@@ -0,0 +1,1633 @@
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 { createHash } from 'node:crypto';
21
+ import path from 'node:path';
22
+
23
+ import { DataReader, DataWriter } from 'toiljs/io';
24
+ import type { MemoryRef } from '../runtime/host.js';
25
+ import { parseCatalog } from './catalog.js';
26
+ import {
27
+ ABSENT,
28
+ ALREADY_EXISTS,
29
+ type CapLedger,
30
+ CODEC_ERR,
31
+ CollectionFamily,
32
+ CONFLICT,
33
+ type DbCatalogState,
34
+ type DbDevState,
35
+ type DbSnapshot,
36
+ DbFunctionKind,
37
+ DEFAULT_FILL_WAIT_MS,
38
+ type DevCollectionHandle,
39
+ INVALID_HANDLE,
40
+ MAX_KEY,
41
+ MAX_FILL_WAIT_MS,
42
+ MAX_NAME,
43
+ MAX_RESERVATION_TTL_MS,
44
+ MAX_RESERVATIONS,
45
+ MAX_VALUE,
46
+ OP_NOT_ALLOWED_FOR_FAMILY,
47
+ OP_NOT_ALLOWED_IN_KIND,
48
+ type RecordOutcomeSnapshot,
49
+ type Reservation,
50
+ satI64,
51
+ SCHEMA_UNAVAILABLE,
52
+ TOO_MANY_KEYS,
53
+ TOO_SMALL,
54
+ UNAVAILABLE,
55
+ isCollectionFamily,
56
+ } from './types.js';
57
+
58
+ // ---- schema versions: the dev equivalent of the edge binding the row's
59
+ // schema_version. Writes STAMP the value type's CURRENT version (from the loaded
60
+ // wasm's catalog); reads SURFACE the stamp. When a @data type evolves and the wasm
61
+ // is rebuilt, the catalog version changes but data on disk keeps its old stamp, so
62
+ // a read reports the old version and the guest's woven decoder runs the @migrate.
63
+
64
+ function mem(ref: MemoryRef): Buffer {
65
+ if (!ref.memory) throw new Error('data host import called before memory was bound');
66
+ return Buffer.from(ref.memory.buffer);
67
+ }
68
+
69
+ /** Bounds-checked read that COPIES out of guest memory (the buffer is reused). */
70
+ function readCopy(ref: MemoryRef, ptr: number, len: number): Buffer {
71
+ const m = mem(ref);
72
+ if (ptr < 0 || len < 0 || ptr + len > m.length)
73
+ throw new Error(`data read out of bounds: ptr=${String(ptr)} len=${String(len)}`);
74
+ return Buffer.from(m.subarray(ptr, ptr + len));
75
+ }
76
+
77
+ /** Read + length-cap a KEY. The edge traps a key over MAX_KEY on EVERY op (via
78
+ * `bound` in prepare_key); enforce it uniformly here so a dev read of an over-cap
79
+ * key fails the same way instead of silently succeeding. */
80
+ function readKey(ref: MemoryRef, ptr: number, len: number): Buffer {
81
+ if (len > MAX_KEY) throw new Error('data: key too long');
82
+ return readCopy(ref, ptr, len);
83
+ }
84
+
85
+ function storeKey(collection: string, key: Buffer): string {
86
+ return collection + '\0' + key.toString('latin1');
87
+ }
88
+
89
+ type RecordOutcome =
90
+ | { kind: 'unit' }
91
+ | { kind: 'value'; value: Buffer; schemaVersion: number }
92
+ | { kind: 'absent' }
93
+ | { kind: 'already_exists' }
94
+ | { kind: 'not_found' }
95
+ | { kind: 'conflict' };
96
+
97
+ interface RecordIdemRow {
98
+ requestHash: string;
99
+ outcome: RecordOutcome | null;
100
+ }
101
+
102
+ function readIdem(ref: MemoryRef, ptr: number): Buffer | null {
103
+ if (ptr === 0) return null;
104
+ return readCopy(ref, ptr, 16);
105
+ }
106
+
107
+ function u64le(n: number): Buffer {
108
+ const b = Buffer.allocUnsafe(8);
109
+ b.writeBigUInt64LE(BigInt(n));
110
+ return b;
111
+ }
112
+
113
+ const RECORD_OP_CREATE = 4;
114
+ const RECORD_OP_PATCH = 5;
115
+ const RECORD_OP_DELETE = 6;
116
+ const RECORD_OP_GET_DELETE = 7;
117
+ const RECORD_OP_ENQUEUE = 8;
118
+
119
+ function recordRequestHash(op: number, key: Buffer, value: Buffer): string {
120
+ return createHash('sha256')
121
+ .update('toildb/record-idempotency/request/v1')
122
+ .update(Buffer.from([op]))
123
+ .update(u64le(key.length))
124
+ .update(key)
125
+ .update(u64le(value.length))
126
+ .update(value)
127
+ .digest('hex');
128
+ }
129
+
130
+ function idemKey(coll: DevCollectionHandle, key: Buffer, op: string, idem: Buffer): string {
131
+ return `${storeKey(coll.name, key)}\0${op}\0${idem.toString('hex')}`;
132
+ }
133
+
134
+ function reservationIdFromIdem(coll: DevCollectionHandle, key: Buffer, idem: Buffer): bigint {
135
+ const digest = createHash('sha256')
136
+ .update('toildb/capacity-reservation-id/v1')
137
+ .update(coll.name)
138
+ .update('\0')
139
+ .update(key)
140
+ .update(idem)
141
+ .digest();
142
+ return digest.readBigUInt64LE(0) | (1n << 63n);
143
+ }
144
+
145
+ function snapshotOutcome(outcome: RecordOutcome): RecordOutcomeSnapshot {
146
+ switch (outcome.kind) {
147
+ case 'value':
148
+ return {
149
+ kind: 'value',
150
+ v: outcome.value.toString('base64'),
151
+ sv: outcome.schemaVersion,
152
+ };
153
+ default:
154
+ return { kind: outcome.kind };
155
+ }
156
+ }
157
+
158
+ function loadOutcome(outcome: RecordOutcomeSnapshot): RecordOutcome {
159
+ if (outcome.kind === 'value') {
160
+ return {
161
+ kind: 'value',
162
+ value: Buffer.from(outcome.v, 'base64'),
163
+ schemaVersion: outcome.sv,
164
+ };
165
+ }
166
+ return { kind: outcome.kind };
167
+ }
168
+
169
+ function collOf(db: DbDevState, handle: number): DevCollectionHandle | null {
170
+ return handle >= 0 && handle < db.handles.length ? db.handles[handle] : null;
171
+ }
172
+
173
+ function collOfFamily(
174
+ db: DbDevState,
175
+ handle: number,
176
+ ...families: CollectionFamily[]
177
+ ): DevCollectionHandle | number {
178
+ const coll = collOf(db, handle);
179
+ if (coll === null) return INVALID_HANDLE;
180
+ return families.includes(coll.family) ? coll : OP_NOT_ALLOWED_FOR_FAMILY;
181
+ }
182
+
183
+ enum DbOp {
184
+ Get,
185
+ GetMany,
186
+ Exists,
187
+ Create,
188
+ Patch,
189
+ Delete,
190
+ GetDelete,
191
+ Enqueue,
192
+ Append,
193
+ AppendOnce,
194
+ Latest,
195
+ CounterGet,
196
+ CounterAdd,
197
+ MembershipContains,
198
+ MembershipAdd,
199
+ MembershipRemove,
200
+ MembershipList,
201
+ UniqueLookup,
202
+ UniqueClaim,
203
+ UniqueRelease,
204
+ CapacityAvailable,
205
+ CapacityReserve,
206
+ CapacityConfirm,
207
+ CapacityCancel,
208
+ ViewGet,
209
+ ViewPublish,
210
+ CapacitySetTotal,
211
+ }
212
+
213
+ function isReadOp(op: DbOp): boolean {
214
+ return (
215
+ op === DbOp.Get ||
216
+ op === DbOp.GetMany ||
217
+ op === DbOp.Exists ||
218
+ op === DbOp.ViewGet ||
219
+ op === DbOp.CounterGet ||
220
+ op === DbOp.MembershipContains ||
221
+ op === DbOp.MembershipList ||
222
+ op === DbOp.UniqueLookup ||
223
+ op === DbOp.Latest ||
224
+ op === DbOp.CapacityAvailable
225
+ );
226
+ }
227
+
228
+ function isScanOp(op: DbOp): boolean {
229
+ return op === DbOp.Latest || op === DbOp.MembershipList;
230
+ }
231
+
232
+ function kindAllows(kind: DbFunctionKind, op: DbOp): boolean {
233
+ if (kind === DbFunctionKind.Query) return isReadOp(op) && !isScanOp(op);
234
+ if (kind === DbFunctionKind.Action) {
235
+ return (
236
+ (isReadOp(op) && !isScanOp(op)) ||
237
+ op === DbOp.Create ||
238
+ op === DbOp.Patch ||
239
+ op === DbOp.Delete ||
240
+ op === DbOp.GetDelete ||
241
+ op === DbOp.Enqueue ||
242
+ op === DbOp.Append ||
243
+ op === DbOp.AppendOnce ||
244
+ op === DbOp.CounterAdd ||
245
+ op === DbOp.MembershipAdd ||
246
+ op === DbOp.MembershipRemove ||
247
+ op === DbOp.UniqueClaim ||
248
+ op === DbOp.UniqueRelease ||
249
+ op === DbOp.CapacityReserve ||
250
+ op === DbOp.CapacityConfirm ||
251
+ op === DbOp.CapacityCancel
252
+ );
253
+ }
254
+ if (kind === DbFunctionKind.Derive)
255
+ return (
256
+ isReadOp(op) || op === DbOp.ViewPublish || op === DbOp.Append || op === DbOp.CounterAdd
257
+ );
258
+ if (kind === DbFunctionKind.Job) return true;
259
+ return false;
260
+ }
261
+
262
+ function collForOp(
263
+ db: DbDevState,
264
+ handle: number,
265
+ op: DbOp,
266
+ ...families: CollectionFamily[]
267
+ ): DevCollectionHandle | number {
268
+ const coll = collOfFamily(db, handle, ...families);
269
+ if (typeof coll === 'number') return coll;
270
+ return kindAllows(db.functionKind, op) ? coll : OP_NOT_ALLOWED_IN_KIND;
271
+ }
272
+
273
+ type CatalogSeedEntry =
274
+ | number
275
+ | {
276
+ family?: number;
277
+ schemaVersion?: number;
278
+ replication?: number;
279
+ placement?: number;
280
+ fillMaxWaitMs?: number;
281
+ fillAllowStale?: boolean;
282
+ };
283
+
284
+ /**
285
+ * The single-process dev data store: the seven ToilDB families, their per-row
286
+ * schema_versions, the loaded wasm's catalog, and optional on-disk persistence.
287
+ * Process-lifetime, shared across dispatches via the module singleton {@link devDb}.
288
+ */
289
+ export class DevDatabase {
290
+ /** Process-lifetime store: `"collection\0keyLatin1"` -> value. Shared across dispatches. */
291
+ private readonly store = new Map<string, Buffer>();
292
+ /** Record-family idempotency claims/outcomes: collection+key+op+idem -> row. */
293
+ private readonly recordIdem = new Map<string, RecordIdemRow>();
294
+ /** Unique-claim request idempotency bytes: `"collection\0key"` -> hex idem. */
295
+ private readonly uniqueIdem = new Map<string, string>();
296
+ /** View family: `"collection\0key"` -> the latest published view blob. */
297
+ private readonly views = new Map<string, Buffer>();
298
+ /** Membership family: `"collection\0setKey"` -> (memberLatin1 -> member bytes). */
299
+ private readonly members = new Map<string, Map<string, Buffer>>();
300
+ /** Counter family: `"collection\0key"` -> saturating i64 sum of deltas. */
301
+ private readonly counters = new Map<string, bigint>();
302
+ /** Counter idempotency: collection+key+idem -> original delta. */
303
+ private readonly counterIdem = new Map<string, bigint>();
304
+ /** Events family: `"collection\0key"` -> append-ordered event blobs (oldest first). */
305
+ private readonly events = new Map<string, Buffer[]>();
306
+ /** append_once dedup: `"collection\0key"` -> set of eventIds already appended. */
307
+ private readonly eventDedup = new Map<string, Set<string>>();
308
+ /** Capacity family: `"collection\0key"` -> an escrow ledger (ceiling + reservations). */
309
+ private readonly capacity = new Map<string, CapLedger>();
310
+ /** `"collection\0key"` -> the schema_version the record/view/unique-owner was last
311
+ * written under (single-value families; the edge stores it per StoredValue). */
312
+ private readonly versions = new Map<string, number>();
313
+ /** Per-event schema_version, parallel to `events[sk]` (append order). */
314
+ private readonly eventVersions = new Map<string, number[]>();
315
+ /** Per-member schema_version: `sk` -> (memberLatin1 -> version), parallel to `members`. */
316
+ private readonly memberVersions = new Map<string, Map<string, number>>();
317
+ /** The decoded catalog from the loaded wasm, including family + current schema_version. */
318
+ private catalog: DbCatalogState = { kind: 'no-section' };
319
+
320
+ // ---- on-disk persistence: dev data + its versions survive restarts, so a
321
+ // developer can write rows, evolve a @data type, restart, and watch the @migrate
322
+ // run. Delete the file to reset the dev database. JSON with base64 buffers.
323
+ private persistPath: string | null = null;
324
+
325
+ /** (Re)load the catalog capability metadata from a server wasm. The module
326
+ * loader calls this on every (re)compile so writes stamp the live version. */
327
+ setCatalog(wasm: Buffer): void {
328
+ this.catalog = parseCatalog(wasm);
329
+ }
330
+
331
+ private currentSchemaVersion(coll: DevCollectionHandle): number {
332
+ if (this.catalog.kind !== 'present') return coll.schemaVersion;
333
+ return this.catalog.collections.get(coll.name)?.schemaVersion ?? coll.schemaVersion;
334
+ }
335
+
336
+ private stampVersion(coll: DevCollectionHandle, sk: string): void {
337
+ this.versions.set(sk, this.currentSchemaVersion(coll)); // stamp the value type's current version
338
+ }
339
+
340
+ private recordIdemStart(
341
+ coll: DevCollectionHandle,
342
+ key: Buffer,
343
+ op: string,
344
+ idem: Buffer | null,
345
+ requestHash: string,
346
+ ): { fresh: true } | { fresh: false; status: number; outcome?: RecordOutcome } {
347
+ if (idem === null) return { fresh: true };
348
+ const ik = idemKey(coll, key, op, idem);
349
+ const row = this.recordIdem.get(ik);
350
+ if (row === undefined) {
351
+ this.recordIdem.set(ik, { requestHash, outcome: null });
352
+ return { fresh: true };
353
+ }
354
+ if (row.requestHash !== requestHash) return { fresh: false, status: CONFLICT };
355
+ if (row.outcome === null) return { fresh: false, status: UNAVAILABLE };
356
+ return { fresh: false, status: 0, outcome: row.outcome };
357
+ }
358
+
359
+ private recordIdemFinish(
360
+ coll: DevCollectionHandle,
361
+ key: Buffer,
362
+ op: string,
363
+ idem: Buffer | null,
364
+ requestHash: string,
365
+ outcome: RecordOutcome,
366
+ ): void {
367
+ if (idem === null) return;
368
+ const ik = idemKey(coll, key, op, idem);
369
+ const row = this.recordIdem.get(ik);
370
+ if (row === undefined || row.requestHash !== requestHash) return;
371
+ if (row.outcome === null) row.outcome = outcome;
372
+ }
373
+
374
+ private replayRecordOutcome(db: DbDevState, outcome: RecordOutcome): number {
375
+ switch (outcome.kind) {
376
+ case 'unit':
377
+ return 0;
378
+ case 'value':
379
+ db.lastResult = outcome.value;
380
+ db.lastResultVersion = outcome.schemaVersion;
381
+ return outcome.value.length;
382
+ case 'absent':
383
+ case 'not_found':
384
+ return ABSENT;
385
+ case 'already_exists':
386
+ return ALREADY_EXISTS;
387
+ case 'conflict':
388
+ return CONFLICT;
389
+ }
390
+ }
391
+
392
+ /** Point the dev DB at an on-disk file and load any existing snapshot. Call once at
393
+ * dev-server startup. Deleting the file (or the whole `.toil/` dir) resets dev data. */
394
+ configurePersistence(filePath: string): void {
395
+ this.persistPath = filePath;
396
+ this.load();
397
+ }
398
+
399
+ /** Write the current store to disk (no-op if persistence is not configured). The
400
+ * module loader calls this after each dispatch so a crash never loses a write. */
401
+ persist(): void {
402
+ if (this.persistPath === null) return;
403
+ const snap: DbSnapshot = {
404
+ store: {},
405
+ recordIdem: {},
406
+ uniqueIdem: {},
407
+ views: {},
408
+ members: {},
409
+ counters: {},
410
+ counterIdem: {},
411
+ events: {},
412
+ eventDedup: {},
413
+ capacity: {},
414
+ };
415
+ for (const [k, v] of this.store)
416
+ snap.store[k] = { v: v.toString('base64'), sv: this.versions.get(k) ?? 0 };
417
+ for (const [k, row] of this.recordIdem)
418
+ snap.recordIdem![k] = {
419
+ requestHash: row.requestHash,
420
+ state: row.outcome === null ? 'pending' : 'done',
421
+ outcome: row.outcome === null ? undefined : snapshotOutcome(row.outcome),
422
+ };
423
+ for (const [k, v] of this.uniqueIdem) snap.uniqueIdem![k] = v;
424
+ for (const [k, v] of this.views)
425
+ snap.views[k] = { v: v.toString('base64'), sv: this.versions.get(k) ?? 0 };
426
+ for (const [k, m] of this.members) {
427
+ const o: Record<string, { v: string; sv: number }> = {};
428
+ const mv = this.memberVersions.get(k);
429
+ for (const [mk, mvb] of m) o[mk] = { v: mvb.toString('base64'), sv: mv?.get(mk) ?? 0 };
430
+ snap.members[k] = o;
431
+ }
432
+ for (const [k, v] of this.counters) snap.counters[k] = v.toString();
433
+ for (const [k, v] of this.counterIdem) snap.counterIdem![k] = v.toString();
434
+ for (const [k, log] of this.events) {
435
+ const ver = this.eventVersions.get(k) ?? [];
436
+ snap.events[k] = log.map((b, i) => ({ v: b.toString('base64'), sv: ver[i] ?? 0 }));
437
+ }
438
+ for (const [k, s] of this.eventDedup) snap.eventDedup[k] = [...s];
439
+ for (const [k, l] of this.capacity)
440
+ snap.capacity[k] = {
441
+ total: l.total.toString(),
442
+ nextId: l.nextId.toString(),
443
+ reservations: [...l.reservations].map(([id, r]) => [
444
+ id.toString(),
445
+ { amount: r.amount.toString(), expiresMs: r.expiresMs, confirmed: r.confirmed },
446
+ ]),
447
+ };
448
+ try {
449
+ fs.mkdirSync(path.dirname(this.persistPath), { recursive: true });
450
+ // Write to a temp file then rename: atomic on POSIX, so a crash mid-write
451
+ // leaves the previous good snapshot intact instead of a truncated file
452
+ // that would fail to parse and drop the whole dev database on next load.
453
+ const tmp = `${this.persistPath}.${process.pid}.tmp`;
454
+ fs.writeFileSync(tmp, JSON.stringify(snap));
455
+ fs.renameSync(tmp, this.persistPath);
456
+ } catch {
457
+ // dev-only best effort; a write failure must never crash a request.
458
+ }
459
+ }
460
+
461
+ private load(): void {
462
+ if (this.persistPath === null) return;
463
+ let snap: DbSnapshot;
464
+ try {
465
+ snap = JSON.parse(fs.readFileSync(this.persistPath, 'utf8')) as DbSnapshot;
466
+ } catch {
467
+ return; // no snapshot yet (or unreadable) - start empty
468
+ }
469
+ this.clear();
470
+ for (const [k, e] of Object.entries(snap.store ?? {})) {
471
+ this.store.set(k, Buffer.from(e.v, 'base64'));
472
+ this.versions.set(k, e.sv);
473
+ }
474
+ for (const [k, row] of Object.entries(snap.recordIdem ?? {})) {
475
+ this.recordIdem.set(k, {
476
+ requestHash: row.requestHash,
477
+ outcome:
478
+ row.state === 'done' && row.outcome !== undefined
479
+ ? loadOutcome(row.outcome)
480
+ : null,
481
+ });
482
+ }
483
+ for (const [k, v] of Object.entries(snap.uniqueIdem ?? {})) this.uniqueIdem.set(k, v);
484
+ for (const [k, e] of Object.entries(snap.views ?? {})) {
485
+ this.views.set(k, Buffer.from(e.v, 'base64'));
486
+ this.versions.set(k, e.sv);
487
+ }
488
+ for (const [k, m] of Object.entries(snap.members ?? {})) {
489
+ const map = new Map<string, Buffer>();
490
+ const ver = new Map<string, number>();
491
+ for (const [mk, e] of Object.entries(m)) {
492
+ map.set(mk, Buffer.from(e.v, 'base64'));
493
+ ver.set(mk, e.sv);
494
+ }
495
+ this.members.set(k, map);
496
+ this.memberVersions.set(k, ver);
497
+ }
498
+ for (const [k, v] of Object.entries(snap.counters ?? {})) this.counters.set(k, BigInt(v));
499
+ for (const [k, v] of Object.entries(snap.counterIdem ?? {}))
500
+ this.counterIdem.set(k, BigInt(v));
501
+ for (const [k, log] of Object.entries(snap.events ?? {})) {
502
+ this.events.set(
503
+ k,
504
+ log.map((e) => Buffer.from(e.v, 'base64')),
505
+ );
506
+ this.eventVersions.set(
507
+ k,
508
+ log.map((e) => e.sv),
509
+ );
510
+ }
511
+ for (const [k, ids] of Object.entries(snap.eventDedup ?? {}))
512
+ this.eventDedup.set(k, new Set(ids));
513
+ for (const [k, l] of Object.entries(snap.capacity ?? {})) {
514
+ const res = new Map<bigint, Reservation>();
515
+ for (const [id, r] of l.reservations)
516
+ res.set(BigInt(id), {
517
+ amount: BigInt(r.amount),
518
+ expiresMs: r.expiresMs,
519
+ confirmed: r.confirmed,
520
+ });
521
+ this.capacity.set(k, {
522
+ total: BigInt(l.total),
523
+ nextId: BigInt(l.nextId),
524
+ reservations: res,
525
+ });
526
+ }
527
+ }
528
+
529
+ private clear(): void {
530
+ this.store.clear();
531
+ this.recordIdem.clear();
532
+ this.uniqueIdem.clear();
533
+ this.versions.clear();
534
+ this.views.clear();
535
+ this.members.clear();
536
+ this.memberVersions.clear();
537
+ this.counters.clear();
538
+ this.counterIdem.clear();
539
+ this.eventVersions.clear();
540
+ this.events.clear();
541
+ this.eventDedup.clear();
542
+ this.capacity.clear();
543
+ }
544
+
545
+ private capLedger(sk: string): CapLedger {
546
+ let l = this.capacity.get(sk);
547
+ if (l === undefined) {
548
+ l = { total: 0n, reservations: new Map(), nextId: 1n };
549
+ this.capacity.set(sk, l);
550
+ }
551
+ return l;
552
+ }
553
+
554
+ /** Drop UN-confirmed reservations whose TTL elapsed (a confirmed sale never expires). */
555
+ private capPrune(l: CapLedger, nowMs: number): void {
556
+ for (const [id, r] of l.reservations)
557
+ if (!r.confirmed && r.expiresMs <= nowMs) l.reservations.delete(id);
558
+ }
559
+
560
+ /** Units reserved against the ceiling: held (un-expired) + confirmed (call capPrune first). */
561
+ private capReserved(l: CapLedger): bigint {
562
+ let sum = 0n;
563
+ for (const r of l.reservations.values()) sum += r.amount;
564
+ return sum;
565
+ }
566
+
567
+ // --- record family (resolve / get / get_many / exists / create / patch /
568
+ // delete / get_delete) ---
569
+
570
+ resolveCollection(
571
+ ref: MemoryRef,
572
+ db: DbDevState,
573
+ namePtr: number,
574
+ nameLen: number,
575
+ outHandlePtr: number,
576
+ ): number {
577
+ if (nameLen < 0 || nameLen > MAX_NAME) throw new Error('data: collection name too long');
578
+ const name = readCopy(ref, namePtr, nameLen).toString('utf8');
579
+ let coll: DevCollectionHandle;
580
+ switch (this.catalog.kind) {
581
+ case 'present': {
582
+ const found = this.catalog.collections.get(name);
583
+ if (found === undefined) return SCHEMA_UNAVAILABLE;
584
+ coll = { ...found };
585
+ break;
586
+ }
587
+ case 'malformed':
588
+ return SCHEMA_UNAVAILABLE;
589
+ case 'no-section':
590
+ coll = {
591
+ name,
592
+ family: CollectionFamily.Record,
593
+ schemaVersion: 0,
594
+ replication: 0,
595
+ placement: 0,
596
+ fillMaxWaitMs: DEFAULT_FILL_WAIT_MS,
597
+ fillAllowStale: true,
598
+ };
599
+ break;
600
+ }
601
+ const handle = db.handles.length;
602
+ db.handles.push(coll);
603
+ const m = mem(ref);
604
+ if (outHandlePtr < 0 || outHandlePtr + 4 > m.length)
605
+ throw new Error('data: resolve out-handle out of bounds');
606
+ m.writeUInt32LE(handle, outHandlePtr);
607
+ return 0;
608
+ }
609
+
610
+ get(ref: MemoryRef, db: DbDevState, handle: number, keyPtr: number, keyLen: number): number {
611
+ const coll = collForOp(db, handle, DbOp.Get, CollectionFamily.Record);
612
+ if (typeof coll === 'number') return coll;
613
+ if (keyLen > MAX_KEY) throw new Error('data: key too long');
614
+ const sk = storeKey(coll.name, readKey(ref, keyPtr, keyLen));
615
+ const v = this.store.get(sk);
616
+ if (v === undefined) return ABSENT;
617
+ db.lastResult = v;
618
+ db.lastResultVersion = this.versions.get(sk) ?? 0; // surface the row's stored version
619
+ return v.length;
620
+ }
621
+
622
+ // Bounded multi-get. Keys blob: u32 count + per key (u32 len + bytes).
623
+ // Result (stashed): u32 count + per item u8 present (+ u32 len + bytes),
624
+ // in request order. Mirrors the edge `op_get_many` framing byte-for-byte.
625
+ getMany(
626
+ ref: MemoryRef,
627
+ db: DbDevState,
628
+ handle: number,
629
+ keysPtr: number,
630
+ keysLen: number,
631
+ ): number {
632
+ const coll = collForOp(
633
+ db,
634
+ handle,
635
+ DbOp.GetMany,
636
+ CollectionFamily.Record,
637
+ CollectionFamily.View,
638
+ );
639
+ if (typeof coll === 'number') return coll;
640
+ if (keysLen > MAX_VALUE) throw new Error('data: keys blob too large');
641
+ const table = coll.family === CollectionFamily.View ? this.views : this.store;
642
+ // Keys blob: u32 count, then per key a u32-length-prefixed blob. The shared
643
+ // DataReader is bounds-safe (empty past end), so a malformed/truncated blob
644
+ // can't over-read; cap each key at MAX_KEY like the edge's prepare_key.
645
+ const r = new DataReader(readCopy(ref, keysPtr, keysLen));
646
+ const count = r.readU32();
647
+ if (count > 1024) return TOO_MANY_KEYS; // anti-OOM cap, mirrors the edge
648
+ // Result: u32 count, then per item present(u8) + (when present) the row's
649
+ // stored schema_version (u32, per-item @migrate dispatch) + value (u32 len +
650
+ // bytes). Byte-identical to the edge op_get_many framing.
651
+ const w = new DataWriter();
652
+ w.writeU32(count);
653
+ for (let i = 0; i < count; i++) {
654
+ const key = r.readBytes();
655
+ if (key.length > MAX_KEY) throw new Error('data: key too long');
656
+ const sk = storeKey(coll.name, Buffer.from(key));
657
+ const v = table.get(sk);
658
+ if (v === undefined) {
659
+ w.writeU8(0);
660
+ } else {
661
+ w.writeU8(1)
662
+ .writeU32(this.versions.get(sk) ?? 0)
663
+ .writeBytes(v);
664
+ }
665
+ }
666
+ db.lastResult = Buffer.from(w.toBytes());
667
+ return db.lastResult.length;
668
+ }
669
+
670
+ exists(ref: MemoryRef, db: DbDevState, handle: number, keyPtr: number, keyLen: number): number {
671
+ const coll = collForOp(db, handle, DbOp.Exists, CollectionFamily.Record);
672
+ if (typeof coll === 'number') return coll;
673
+ return this.store.has(storeKey(coll.name, readKey(ref, keyPtr, keyLen))) ? 1 : 0;
674
+ }
675
+
676
+ create(
677
+ ref: MemoryRef,
678
+ db: DbDevState,
679
+ handle: number,
680
+ keyPtr: number,
681
+ keyLen: number,
682
+ valPtr: number,
683
+ valLen: number,
684
+ idemPtr: number,
685
+ ): number {
686
+ const coll = collForOp(db, handle, DbOp.Create, CollectionFamily.Record);
687
+ if (typeof coll === 'number') return coll;
688
+ if (keyLen > MAX_KEY || valLen > MAX_VALUE) throw new Error('data: key/value too large');
689
+ const key = readKey(ref, keyPtr, keyLen);
690
+ const value = readCopy(ref, valPtr, valLen);
691
+ const idem = readIdem(ref, idemPtr);
692
+ const requestHash = recordRequestHash(RECORD_OP_CREATE, key, value);
693
+ const start = this.recordIdemStart(coll, key, 'C', idem, requestHash);
694
+ if (!start.fresh)
695
+ return start.outcome ? this.replayRecordOutcome(db, start.outcome) : start.status;
696
+ const sk = storeKey(coll.name, key);
697
+ const outcome: RecordOutcome = this.store.has(sk)
698
+ ? { kind: 'already_exists' }
699
+ : { kind: 'unit' };
700
+ if (outcome.kind === 'unit') {
701
+ this.store.set(sk, value);
702
+ this.stampVersion(coll, sk); // stamp the value type's current schema version
703
+ }
704
+ this.recordIdemFinish(coll, key, 'C', idem, requestHash, outcome);
705
+ return this.replayRecordOutcome(db, outcome);
706
+ }
707
+
708
+ patch(
709
+ ref: MemoryRef,
710
+ db: DbDevState,
711
+ handle: number,
712
+ keyPtr: number,
713
+ keyLen: number,
714
+ patchPtr: number,
715
+ patchLen: number,
716
+ idemPtr: number,
717
+ ): number {
718
+ const coll = collForOp(db, handle, DbOp.Patch, CollectionFamily.Record);
719
+ if (typeof coll === 'number') return coll;
720
+ if (keyLen > MAX_KEY || patchLen > MAX_VALUE) throw new Error('data: key/patch too large');
721
+ const key = readKey(ref, keyPtr, keyLen);
722
+ const v = readCopy(ref, patchPtr, patchLen);
723
+ const idem = readIdem(ref, idemPtr);
724
+ const requestHash = recordRequestHash(RECORD_OP_PATCH, key, v);
725
+ const start = this.recordIdemStart(coll, key, 'P', idem, requestHash);
726
+ if (!start.fresh)
727
+ return start.outcome ? this.replayRecordOutcome(db, start.outcome) : start.status;
728
+ const sk = storeKey(coll.name, key);
729
+ const outcome: RecordOutcome = this.store.has(sk)
730
+ ? { kind: 'value', value: v, schemaVersion: -1 }
731
+ : { kind: 'not_found' };
732
+ if (outcome.kind === 'value') {
733
+ this.store.set(sk, v);
734
+ this.stampVersion(coll, sk); // a patch rewrites the row at the current version
735
+ }
736
+ this.recordIdemFinish(coll, key, 'P', idem, requestHash, outcome);
737
+ return this.replayRecordOutcome(db, outcome);
738
+ }
739
+
740
+ delete(
741
+ ref: MemoryRef,
742
+ db: DbDevState,
743
+ handle: number,
744
+ keyPtr: number,
745
+ keyLen: number,
746
+ idemPtr: number,
747
+ ): number {
748
+ const coll = collForOp(db, handle, DbOp.Delete, CollectionFamily.Record);
749
+ if (typeof coll === 'number') return coll;
750
+ const key = readKey(ref, keyPtr, keyLen);
751
+ const idem = readIdem(ref, idemPtr);
752
+ const requestHash = recordRequestHash(RECORD_OP_DELETE, key, Buffer.alloc(0));
753
+ const start = this.recordIdemStart(coll, key, 'D', idem, requestHash);
754
+ if (!start.fresh)
755
+ return start.outcome ? this.replayRecordOutcome(db, start.outcome) : start.status;
756
+ const sk = storeKey(coll.name, key);
757
+ this.store.delete(sk);
758
+ this.versions.delete(sk);
759
+ const outcome: RecordOutcome = { kind: 'unit' };
760
+ this.recordIdemFinish(coll, key, 'D', idem, requestHash, outcome);
761
+ return this.replayRecordOutcome(db, outcome);
762
+ }
763
+
764
+ // Atomic fetch-and-delete (consume-once); deletes only on a real read.
765
+ getDelete(
766
+ ref: MemoryRef,
767
+ db: DbDevState,
768
+ handle: number,
769
+ keyPtr: number,
770
+ keyLen: number,
771
+ idemPtr: number,
772
+ ): number {
773
+ const coll = collForOp(db, handle, DbOp.GetDelete, CollectionFamily.Record);
774
+ if (typeof coll === 'number') return coll;
775
+ const key = readKey(ref, keyPtr, keyLen);
776
+ const idem = readIdem(ref, idemPtr);
777
+ const requestHash = recordRequestHash(RECORD_OP_GET_DELETE, key, Buffer.alloc(0));
778
+ const start = this.recordIdemStart(coll, key, 'G', idem, requestHash);
779
+ if (!start.fresh)
780
+ return start.outcome ? this.replayRecordOutcome(db, start.outcome) : start.status;
781
+ const sk = storeKey(coll.name, key);
782
+ const v = this.store.get(sk);
783
+ const outcome: RecordOutcome =
784
+ v === undefined
785
+ ? { kind: 'absent' }
786
+ : {
787
+ kind: 'value',
788
+ value: v,
789
+ schemaVersion: this.versions.get(sk) ?? 0,
790
+ };
791
+ if (outcome.kind === 'value') {
792
+ this.store.delete(sk);
793
+ this.versions.delete(sk);
794
+ }
795
+ this.recordIdemFinish(coll, key, 'G', idem, requestHash, outcome);
796
+ return this.replayRecordOutcome(db, outcome);
797
+ }
798
+
799
+ // --- unique family (lookup / claim / release) ---
800
+
801
+ uniqueLookup(
802
+ ref: MemoryRef,
803
+ db: DbDevState,
804
+ handle: number,
805
+ keyPtr: number,
806
+ keyLen: number,
807
+ ): number {
808
+ const coll = collForOp(db, handle, DbOp.UniqueLookup, CollectionFamily.Unique);
809
+ if (typeof coll === 'number') return coll;
810
+ const sk = storeKey(coll.name, readKey(ref, keyPtr, keyLen));
811
+ const v = this.store.get(sk);
812
+ if (v === undefined) return ABSENT;
813
+ db.lastResult = v;
814
+ db.lastResultVersion = this.versions.get(sk) ?? 0;
815
+ return v.length;
816
+ }
817
+
818
+ // Tag: 0 Claimed, 1 AlreadyClaimed (owner stashed), 2 AlreadyOwnedByCaller.
819
+ uniqueClaim(
820
+ ref: MemoryRef,
821
+ db: DbDevState,
822
+ handle: number,
823
+ keyPtr: number,
824
+ keyLen: number,
825
+ valPtr: number,
826
+ valLen: number,
827
+ idemPtr: number,
828
+ ): number {
829
+ const coll = collForOp(db, handle, DbOp.UniqueClaim, CollectionFamily.Unique);
830
+ if (typeof coll === 'number') return coll;
831
+ if (keyLen > MAX_KEY || valLen > MAX_VALUE) throw new Error('data: key/value too large');
832
+ const sk = storeKey(coll.name, readKey(ref, keyPtr, keyLen));
833
+ const owner = readCopy(ref, valPtr, valLen);
834
+ const idem = readIdem(ref, idemPtr)?.toString('hex') ?? '';
835
+ const existing = this.store.get(sk);
836
+ if (existing === undefined) {
837
+ this.store.set(sk, owner);
838
+ this.uniqueIdem.set(sk, idem);
839
+ this.stampVersion(coll, sk);
840
+ return 0; // Claimed
841
+ }
842
+ if (existing.equals(owner)) {
843
+ return (this.uniqueIdem.get(sk) ?? '') === idem ? 2 : CONFLICT;
844
+ }
845
+ db.lastResult = existing;
846
+ return 1; // AlreadyClaimed (current owner stashed)
847
+ }
848
+
849
+ uniqueRelease(
850
+ ref: MemoryRef,
851
+ db: DbDevState,
852
+ handle: number,
853
+ keyPtr: number,
854
+ keyLen: number,
855
+ valPtr: number,
856
+ valLen: number,
857
+ ): number {
858
+ const coll = collForOp(db, handle, DbOp.UniqueRelease, CollectionFamily.Unique);
859
+ if (typeof coll === 'number') return coll;
860
+ const sk = storeKey(coll.name, readKey(ref, keyPtr, keyLen));
861
+ const existing = this.store.get(sk);
862
+ if (existing === undefined) return 0; // idempotent
863
+ if (!existing.equals(readCopy(ref, valPtr, valLen))) return CONFLICT; // not the owner
864
+ this.store.delete(sk);
865
+ this.uniqueIdem.delete(sk);
866
+ this.versions.delete(sk);
867
+ return 0;
868
+ }
869
+
870
+ // --- membership family (contains / add / remove / list) ---
871
+
872
+ membershipContains(
873
+ ref: MemoryRef,
874
+ db: DbDevState,
875
+ handle: number,
876
+ setPtr: number,
877
+ setLen: number,
878
+ memberPtr: number,
879
+ memberLen: number,
880
+ ): number {
881
+ const coll = collForOp(db, handle, DbOp.MembershipContains, CollectionFamily.Membership);
882
+ if (typeof coll === 'number') return coll;
883
+ const set = this.members.get(storeKey(coll.name, readKey(ref, setPtr, setLen)));
884
+ if (set === undefined) return 0;
885
+ return set.has(readCopy(ref, memberPtr, memberLen).toString('latin1')) ? 1 : 0;
886
+ }
887
+
888
+ membershipAdd(
889
+ ref: MemoryRef,
890
+ db: DbDevState,
891
+ handle: number,
892
+ setPtr: number,
893
+ setLen: number,
894
+ memberPtr: number,
895
+ memberLen: number,
896
+ ): number {
897
+ const coll = collForOp(db, handle, DbOp.MembershipAdd, CollectionFamily.Membership);
898
+ if (typeof coll === 'number') return coll;
899
+ if (setLen > MAX_KEY || memberLen > MAX_VALUE)
900
+ throw new Error('data: set/member too large');
901
+ const sk = storeKey(coll.name, readKey(ref, setPtr, setLen));
902
+ const member = readCopy(ref, memberPtr, memberLen);
903
+ let set = this.members.get(sk);
904
+ if (set === undefined) {
905
+ set = new Map();
906
+ this.members.set(sk, set);
907
+ }
908
+ const ml = member.toString('latin1');
909
+ set.set(ml, member);
910
+ let mv = this.memberVersions.get(sk);
911
+ if (mv === undefined) {
912
+ mv = new Map();
913
+ this.memberVersions.set(sk, mv);
914
+ }
915
+ mv.set(ml, this.currentSchemaVersion(coll));
916
+ return 0;
917
+ }
918
+
919
+ membershipRemove(
920
+ ref: MemoryRef,
921
+ db: DbDevState,
922
+ handle: number,
923
+ setPtr: number,
924
+ setLen: number,
925
+ memberPtr: number,
926
+ memberLen: number,
927
+ ): number {
928
+ const coll = collForOp(db, handle, DbOp.MembershipRemove, CollectionFamily.Membership);
929
+ if (typeof coll === 'number') return coll;
930
+ const sk = storeKey(coll.name, readKey(ref, setPtr, setLen));
931
+ const ml = readCopy(ref, memberPtr, memberLen).toString('latin1');
932
+ this.members.get(sk)?.delete(ml);
933
+ this.memberVersions.get(sk)?.delete(ml);
934
+ return 0;
935
+ }
936
+
937
+ // Frame the members (sorted by bytes, matching the edge BTreeMap) as
938
+ // `u32 count` + per member `u32 len + bytes`; stash + return the length.
939
+ membershipList(
940
+ ref: MemoryRef,
941
+ db: DbDevState,
942
+ handle: number,
943
+ setPtr: number,
944
+ setLen: number,
945
+ limit: number,
946
+ ): number {
947
+ const coll = collForOp(db, handle, DbOp.MembershipList, CollectionFamily.Membership);
948
+ if (typeof coll === 'number') return coll;
949
+ const sk = storeKey(coll.name, readKey(ref, setPtr, setLen));
950
+ const set = this.members.get(sk);
951
+ const mv = this.memberVersions.get(sk);
952
+ const n = Math.max(0, Math.min(limit, 0xffff));
953
+ const members =
954
+ set === undefined ? [] : Array.from(set.values()).sort(Buffer.compare).slice(0, n);
955
+ // u32 count, then per member its stored schema_version (u32) + bytes (u32
956
+ // len + bytes). Same framing as the edge op_membership_list.
957
+ const w = new DataWriter();
958
+ w.writeU32(members.length);
959
+ for (const m of members) {
960
+ w.writeU32(mv?.get(m.toString('latin1')) ?? 0).writeBytes(m);
961
+ }
962
+ db.lastResult = Buffer.from(w.toBytes());
963
+ return db.lastResult.length;
964
+ }
965
+
966
+ // --- view family (get / publish) ---
967
+
968
+ viewGet(
969
+ ref: MemoryRef,
970
+ db: DbDevState,
971
+ handle: number,
972
+ keyPtr: number,
973
+ keyLen: number,
974
+ ): number {
975
+ const coll = collForOp(db, handle, DbOp.ViewGet, CollectionFamily.View);
976
+ if (typeof coll === 'number') return coll;
977
+ const sk = storeKey(coll.name, readKey(ref, keyPtr, keyLen));
978
+ const v = this.views.get(sk);
979
+ if (v === undefined) return ABSENT;
980
+ db.lastResult = v;
981
+ db.lastResultVersion = this.versions.get(sk) ?? 0;
982
+ return v.length;
983
+ }
984
+
985
+ // Publish overwrites (the host assigns the version; dev keeps the latest).
986
+ viewPublish(
987
+ ref: MemoryRef,
988
+ db: DbDevState,
989
+ handle: number,
990
+ keyPtr: number,
991
+ keyLen: number,
992
+ valPtr: number,
993
+ valLen: number,
994
+ ): number {
995
+ const coll = collForOp(db, handle, DbOp.ViewPublish, CollectionFamily.View);
996
+ if (typeof coll === 'number') return coll;
997
+ if (keyLen > MAX_KEY || valLen > MAX_VALUE) throw new Error('data: key/view too large');
998
+ const sk = storeKey(coll.name, readKey(ref, keyPtr, keyLen));
999
+ this.views.set(sk, readCopy(ref, valPtr, valLen));
1000
+ this.stampVersion(coll, sk);
1001
+ return 0;
1002
+ }
1003
+
1004
+ // --- counter family (get / add) ---
1005
+
1006
+ // Stash the i64 sum as 8 LE bytes; the guest pulls + loads it. A counter
1007
+ // with no deltas reads as 0 (never absent).
1008
+ counterGet(
1009
+ ref: MemoryRef,
1010
+ db: DbDevState,
1011
+ handle: number,
1012
+ keyPtr: number,
1013
+ keyLen: number,
1014
+ ): number {
1015
+ const coll = collForOp(db, handle, DbOp.CounterGet, CollectionFamily.Counter);
1016
+ if (typeof coll === 'number') return coll;
1017
+ const sum = this.counters.get(storeKey(coll.name, readKey(ref, keyPtr, keyLen))) ?? 0n;
1018
+ const out = Buffer.alloc(8);
1019
+ out.writeBigInt64LE(sum);
1020
+ db.lastResult = out;
1021
+ return out.length;
1022
+ }
1023
+
1024
+ // `delta` is the wasm i64 (a BigInt across the boundary); `BigInt()`
1025
+ // normalizes the test's plain-number form too. Saturates like the edge.
1026
+ counterAdd(
1027
+ ref: MemoryRef,
1028
+ db: DbDevState,
1029
+ handle: number,
1030
+ keyPtr: number,
1031
+ keyLen: number,
1032
+ delta: number | bigint,
1033
+ idemPtr: number,
1034
+ ): number {
1035
+ const coll = collForOp(db, handle, DbOp.CounterAdd, CollectionFamily.Counter);
1036
+ if (typeof coll === 'number') return coll;
1037
+ const key = readKey(ref, keyPtr, keyLen);
1038
+ const idem = readIdem(ref, idemPtr);
1039
+ const d = BigInt(delta);
1040
+ if (idem !== null) {
1041
+ const ik = idemKey(coll, key, 'A', idem);
1042
+ const seen = this.counterIdem.get(ik);
1043
+ if (seen !== undefined) return seen === d ? 0 : CONFLICT;
1044
+ this.counterIdem.set(ik, d);
1045
+ }
1046
+ const sk = storeKey(coll.name, key);
1047
+ this.counters.set(sk, satI64((this.counters.get(sk) ?? 0n) + d));
1048
+ return 0;
1049
+ }
1050
+
1051
+ // --- events family (append / append_once / enqueue / latest) ---
1052
+
1053
+ append(
1054
+ ref: MemoryRef,
1055
+ db: DbDevState,
1056
+ handle: number,
1057
+ keyPtr: number,
1058
+ keyLen: number,
1059
+ evPtr: number,
1060
+ evLen: number,
1061
+ idemPtr: number,
1062
+ ): number {
1063
+ const coll = collForOp(db, handle, DbOp.Append, CollectionFamily.Events);
1064
+ if (typeof coll === 'number') return coll;
1065
+ if (keyLen > MAX_KEY || evLen > MAX_VALUE) throw new Error('data: key/event too large');
1066
+ const key = readKey(ref, keyPtr, keyLen);
1067
+ const sk = storeKey(coll.name, key);
1068
+ const idem = readIdem(ref, idemPtr);
1069
+ if (idem !== null) {
1070
+ let seen = this.eventDedup.get(sk);
1071
+ if (seen === undefined) {
1072
+ seen = new Set();
1073
+ this.eventDedup.set(sk, seen);
1074
+ }
1075
+ const eventId = idem.toString('latin1');
1076
+ if (seen.has(eventId)) return 0;
1077
+ seen.add(eventId);
1078
+ }
1079
+ const log = this.events.get(sk);
1080
+ const ev = readCopy(ref, evPtr, evLen);
1081
+ const sv = this.currentSchemaVersion(coll);
1082
+ if (log === undefined) {
1083
+ this.events.set(sk, [ev]);
1084
+ this.eventVersions.set(sk, [sv]);
1085
+ } else {
1086
+ log.push(ev);
1087
+ (this.eventVersions.get(sk) ?? this.eventVersions.set(sk, []).get(sk)!).push(sv);
1088
+ }
1089
+ return 0;
1090
+ }
1091
+
1092
+ // Idempotent append: dedup on eventId. 1 appended, 0 duplicate. Mirrors the
1093
+ // edge's (key, event_id) dedup marker (just an in-memory set in dev).
1094
+ appendOnce(
1095
+ ref: MemoryRef,
1096
+ db: DbDevState,
1097
+ handle: number,
1098
+ keyPtr: number,
1099
+ keyLen: number,
1100
+ evidPtr: number,
1101
+ evidLen: number,
1102
+ evPtr: number,
1103
+ evLen: number,
1104
+ ): number {
1105
+ const coll = collForOp(db, handle, DbOp.AppendOnce, CollectionFamily.Events);
1106
+ if (typeof coll === 'number') return coll;
1107
+ if (keyLen > MAX_KEY || evLen > MAX_VALUE) throw new Error('data: key/event too large');
1108
+ const sk = storeKey(coll.name, readKey(ref, keyPtr, keyLen));
1109
+ const evid = readCopy(ref, evidPtr, evidLen).toString('latin1');
1110
+ let seen = this.eventDedup.get(sk);
1111
+ if (seen === undefined) {
1112
+ seen = new Set();
1113
+ this.eventDedup.set(sk, seen);
1114
+ }
1115
+ if (seen.has(evid)) return 0; // already appended under this id
1116
+ const ev = readCopy(ref, evPtr, evLen);
1117
+ const sv = this.currentSchemaVersion(coll);
1118
+ const log = this.events.get(sk);
1119
+ if (log === undefined) {
1120
+ this.events.set(sk, [ev]);
1121
+ this.eventVersions.set(sk, [sv]);
1122
+ } else {
1123
+ log.push(ev);
1124
+ (this.eventVersions.get(sk) ?? this.eventVersions.set(sk, []).get(sk)!).push(sv);
1125
+ }
1126
+ seen.add(evid);
1127
+ return 1;
1128
+ }
1129
+
1130
+ // Version-checked replace of an EXISTING record's value. Returns 0 on apply,
1131
+ // ABSENT (-2) if the record is absent. A single dev process has no concurrent
1132
+ // writer, so the optimistic-concurrency check always succeeds here.
1133
+ enqueue(
1134
+ ref: MemoryRef,
1135
+ db: DbDevState,
1136
+ handle: number,
1137
+ keyPtr: number,
1138
+ keyLen: number,
1139
+ valPtr: number,
1140
+ valLen: number,
1141
+ idemPtr: number,
1142
+ ): number {
1143
+ const coll = collForOp(db, handle, DbOp.Enqueue, CollectionFamily.Record);
1144
+ if (typeof coll === 'number') return coll;
1145
+ if (keyLen > MAX_KEY || valLen > MAX_VALUE) throw new Error('data: key/value too large');
1146
+ const key = readKey(ref, keyPtr, keyLen);
1147
+ const value = readCopy(ref, valPtr, valLen);
1148
+ const idem = readIdem(ref, idemPtr);
1149
+ const requestHash = recordRequestHash(RECORD_OP_ENQUEUE, key, value);
1150
+ const start = this.recordIdemStart(coll, key, 'E', idem, requestHash);
1151
+ if (!start.fresh)
1152
+ return start.outcome ? this.replayRecordOutcome(db, start.outcome) : start.status;
1153
+ const sk = storeKey(coll.name, key);
1154
+ const outcome: RecordOutcome = this.store.has(sk)
1155
+ ? { kind: 'unit' }
1156
+ : { kind: 'not_found' };
1157
+ if (outcome.kind === 'unit') {
1158
+ this.store.set(sk, value);
1159
+ this.stampVersion(coll, sk);
1160
+ }
1161
+ this.recordIdemFinish(coll, key, 'E', idem, requestHash, outcome);
1162
+ return this.replayRecordOutcome(db, outcome);
1163
+ }
1164
+
1165
+ // Frame the newest-`limit` events as `u32 count` then per event a
1166
+ // length-prefixed blob (`u32 len + bytes`), newest first; stash + return
1167
+ // the blob length. Matches the edge `op_latest` / `toildb::Writer` framing.
1168
+ latest(
1169
+ ref: MemoryRef,
1170
+ db: DbDevState,
1171
+ handle: number,
1172
+ keyPtr: number,
1173
+ keyLen: number,
1174
+ limit: number,
1175
+ ): number {
1176
+ const coll = collForOp(db, handle, DbOp.Latest, CollectionFamily.Events);
1177
+ if (typeof coll === 'number') return coll;
1178
+ const sk = storeKey(coll.name, readKey(ref, keyPtr, keyLen));
1179
+ const log = this.events.get(sk) ?? [];
1180
+ const vers = this.eventVersions.get(sk) ?? [];
1181
+ const n = Math.max(0, Math.min(limit, 0xffff));
1182
+ const start = Math.max(0, log.length - n);
1183
+ const newest = log.slice(start).reverse();
1184
+ const newestVers = vers.slice(start).reverse();
1185
+ // u32 count, then per event its stored schema_version (u32) + bytes (u32 len
1186
+ // + bytes), newest first. Same framing as the edge op_latest.
1187
+ const w = new DataWriter();
1188
+ w.writeU32(newest.length);
1189
+ for (let i = 0; i < newest.length; i++) {
1190
+ w.writeU32(newestVers[i] ?? 0).writeBytes(newest[i]);
1191
+ }
1192
+ db.lastResult = Buffer.from(w.toBytes());
1193
+ return db.lastResult.length;
1194
+ }
1195
+
1196
+ // --- capacity family (escrow: set_total / available / reserve / confirm / cancel) ---
1197
+
1198
+ // Set the ceiling (restock / reduce). Job/derive only (kind-gated upstream).
1199
+ // A ceiling is never negative.
1200
+ capacitySetTotal(
1201
+ ref: MemoryRef,
1202
+ db: DbDevState,
1203
+ handle: number,
1204
+ keyPtr: number,
1205
+ keyLen: number,
1206
+ total: number | bigint,
1207
+ ): number {
1208
+ const coll = collForOp(db, handle, DbOp.CapacitySetTotal, CollectionFamily.Capacity);
1209
+ if (typeof coll === 'number') return coll;
1210
+ const l = this.capLedger(storeKey(coll.name, readKey(ref, keyPtr, keyLen)));
1211
+ const t = BigInt(total);
1212
+ l.total = satI64(t < 0n ? 0n : t);
1213
+ return 0;
1214
+ }
1215
+
1216
+ // Stash the i64 available (total - reserved [held + confirmed], floored at 0).
1217
+ capacityAvailable(
1218
+ ref: MemoryRef,
1219
+ db: DbDevState,
1220
+ handle: number,
1221
+ keyPtr: number,
1222
+ keyLen: number,
1223
+ ): number {
1224
+ const coll = collForOp(db, handle, DbOp.CapacityAvailable, CollectionFamily.Capacity);
1225
+ if (typeof coll === 'number') return coll;
1226
+ const l = this.capLedger(storeKey(coll.name, readKey(ref, keyPtr, keyLen)));
1227
+ this.capPrune(l, Date.now());
1228
+ const avail = l.total - this.capReserved(l);
1229
+ const out = Buffer.alloc(8);
1230
+ out.writeBigInt64LE(avail < 0n ? 0n : avail);
1231
+ db.lastResult = out;
1232
+ return out.length;
1233
+ }
1234
+
1235
+ // Hold `amount` for `ttlMs`: stash the u64 reservation id (8 bytes) on
1236
+ // success. A non-positive amount is a typed error (CODEC_ERR), matching the
1237
+ // edge's BadAmount; insufficient available OR too many live reservations is
1238
+ // ABSENT (-2) (the guest maps that to reservation 0 = no oversell). The TTL
1239
+ // is clamped to the edge's 24h ceiling. `now` is the HOST clock.
1240
+ capacityReserve(
1241
+ ref: MemoryRef,
1242
+ db: DbDevState,
1243
+ handle: number,
1244
+ keyPtr: number,
1245
+ keyLen: number,
1246
+ amount: number | bigint,
1247
+ ttlMs: number | bigint,
1248
+ idemPtr: number,
1249
+ ): number {
1250
+ const coll = collForOp(db, handle, DbOp.CapacityReserve, CollectionFamily.Capacity);
1251
+ if (typeof coll === 'number') return coll;
1252
+ const want = BigInt(amount);
1253
+ if (want <= 0n) return CODEC_ERR; // BadAmount (edge: -1006)
1254
+ const key = readKey(ref, keyPtr, keyLen);
1255
+ const idem = readIdem(ref, idemPtr);
1256
+ const ttl = Math.min(Math.max(0, Number(ttlMs)), MAX_RESERVATION_TTL_MS);
1257
+ const requestedId = idem === null ? null : reservationIdFromIdem(coll, key, idem);
1258
+ const l = this.capLedger(storeKey(coll.name, key));
1259
+ const now = Date.now();
1260
+ this.capPrune(l, now);
1261
+ if (requestedId !== null) {
1262
+ const existing = l.reservations.get(requestedId);
1263
+ if (existing !== undefined) {
1264
+ if (existing.amount !== want) return CONFLICT;
1265
+ const out = Buffer.alloc(8);
1266
+ out.writeBigUInt64LE(requestedId);
1267
+ db.lastResult = out;
1268
+ return out.length;
1269
+ }
1270
+ }
1271
+ if (l.total - this.capReserved(l) < want || l.reservations.size >= MAX_RESERVATIONS)
1272
+ return ABSENT; // never oversell; bound the reservation count
1273
+ const id = requestedId ?? l.nextId++;
1274
+ l.reservations.set(id, { amount: want, expiresMs: now + ttl, confirmed: false });
1275
+ const out = Buffer.alloc(8);
1276
+ out.writeBigUInt64LE(id);
1277
+ db.lastResult = out;
1278
+ return out.length;
1279
+ }
1280
+
1281
+ // Finalize a reservation into a permanent consume. IDEMPOTENT: the
1282
+ // reservation is flagged confirmed (and kept), so a retry of a settled id
1283
+ // still returns 1; 0 only when the id is unknown / expired-and-pruned.
1284
+ capacityConfirm(
1285
+ ref: MemoryRef,
1286
+ db: DbDevState,
1287
+ handle: number,
1288
+ keyPtr: number,
1289
+ keyLen: number,
1290
+ reservationId: number | bigint,
1291
+ ): number {
1292
+ const coll = collForOp(db, handle, DbOp.CapacityConfirm, CollectionFamily.Capacity);
1293
+ if (typeof coll === 'number') return coll;
1294
+ const l = this.capLedger(storeKey(coll.name, readKey(ref, keyPtr, keyLen)));
1295
+ this.capPrune(l, Date.now());
1296
+ const r = l.reservations.get(BigInt(reservationId));
1297
+ if (r === undefined) return 0;
1298
+ r.confirmed = true;
1299
+ return 1;
1300
+ }
1301
+
1302
+ // Release a HELD reservation back to available. A confirmed sale cannot be
1303
+ // cancelled (returns 0), nor an unknown id.
1304
+ capacityCancel(
1305
+ ref: MemoryRef,
1306
+ db: DbDevState,
1307
+ handle: number,
1308
+ keyPtr: number,
1309
+ keyLen: number,
1310
+ reservationId: number | bigint,
1311
+ ): number {
1312
+ const coll = collForOp(db, handle, DbOp.CapacityCancel, CollectionFamily.Capacity);
1313
+ if (typeof coll === 'number') return coll;
1314
+ const l = this.capLedger(storeKey(coll.name, readKey(ref, keyPtr, keyLen)));
1315
+ this.capPrune(l, Date.now());
1316
+ const id = BigInt(reservationId);
1317
+ const r = l.reservations.get(id);
1318
+ if (r === undefined || r.confirmed) return 0;
1319
+ l.reservations.delete(id);
1320
+ return 1;
1321
+ }
1322
+
1323
+ // --- result pull + per-row metadata ---
1324
+
1325
+ // Drain the last stashed variable-length result into the caller buffer.
1326
+ takeResult(ref: MemoryRef, db: DbDevState, outPtr: number, outCap: number): number {
1327
+ const v = db.lastResult;
1328
+ if (v === null) return 0;
1329
+ if (v.length > outCap) return TOO_SMALL; // keep the stash for retry
1330
+ const m = mem(ref);
1331
+ if (outPtr < 0 || outPtr + v.length > m.length)
1332
+ throw new Error('data: take_result out of bounds');
1333
+ v.copy(m, outPtr);
1334
+ db.lastResult = null;
1335
+ return v.length;
1336
+ }
1337
+
1338
+ // `data.result_schema_version() -> i64`: the schema_version the last
1339
+ // value-returning read's row was written under, so the guest's woven decoder
1340
+ // runs the right @migrate. With on-disk persistence + catalog version
1341
+ // stamping, dev DOES surface historical versions: a row written under an old
1342
+ // @data layout reports that old version after the type evolves, so the dev
1343
+ // server exercises real cross-version decode. -1 means the last op returned
1344
+ // no value. An i64 result returns a BigInt in Node's WASM ABI.
1345
+ resultSchemaVersion(db: DbDevState): bigint {
1346
+ return BigInt(db.lastResultVersion);
1347
+ }
1348
+
1349
+ // --- test hooks ---
1350
+
1351
+ /** Test-only: clear the stores + catalog + persistence between unit tests. */
1352
+ resetForTests(): void {
1353
+ this.clear();
1354
+ this.catalog = { kind: 'no-section' };
1355
+ this.persistPath = null;
1356
+ }
1357
+
1358
+ /** Test-only: seed the catalog directly. Number values default to record-family entries. */
1359
+ setCatalogForTests(entries: Record<string, CatalogSeedEntry>): void {
1360
+ const collections = new Map<string, DevCollectionHandle>();
1361
+ for (const [name, entry] of Object.entries(entries)) {
1362
+ const schemaVersion = typeof entry === 'number' ? entry : (entry.schemaVersion ?? 0);
1363
+ const family =
1364
+ typeof entry === 'number'
1365
+ ? CollectionFamily.Record
1366
+ : (entry.family ?? CollectionFamily.Record);
1367
+ const replication = typeof entry === 'number' ? 0 : (entry.replication ?? 0);
1368
+ const placement = typeof entry === 'number' ? 0 : (entry.placement ?? 0);
1369
+ const fillMaxWaitMs =
1370
+ typeof entry === 'number'
1371
+ ? DEFAULT_FILL_WAIT_MS
1372
+ : (entry.fillMaxWaitMs ?? DEFAULT_FILL_WAIT_MS);
1373
+ const fillAllowStale =
1374
+ typeof entry === 'number' ? true : (entry.fillAllowStale ?? true);
1375
+ if (!isCollectionFamily(family)) {
1376
+ this.catalog = { kind: 'malformed' };
1377
+ return;
1378
+ }
1379
+ if (fillMaxWaitMs > MAX_FILL_WAIT_MS) {
1380
+ this.catalog = { kind: 'malformed' };
1381
+ return;
1382
+ }
1383
+ collections.set(name, {
1384
+ name,
1385
+ family,
1386
+ schemaVersion: schemaVersion >>> 0,
1387
+ replication,
1388
+ placement,
1389
+ fillMaxWaitMs,
1390
+ fillAllowStale,
1391
+ });
1392
+ }
1393
+ this.catalog = { kind: 'present', collections };
1394
+ }
1395
+ }
1396
+
1397
+ /** The single process-lifetime dev database (one store per dev/self-host process). */
1398
+ export const devDb = new DevDatabase();
1399
+
1400
+ /** (Re)load the collection -> current schema_version map from a server wasm. */
1401
+ export function setDbCatalog(wasm: Buffer): void {
1402
+ devDb.setCatalog(wasm);
1403
+ }
1404
+
1405
+ /** Point the dev DB at an on-disk file and load any existing snapshot. */
1406
+ export function configureDbPersistence(filePath: string): void {
1407
+ devDb.configurePersistence(filePath);
1408
+ }
1409
+
1410
+ /** Write the current store to disk (no-op if persistence is not configured). */
1411
+ export function persistDb(): void {
1412
+ devDb.persist();
1413
+ }
1414
+
1415
+ /**
1416
+ * Build the `data.*` host imports for one instance, delegating to the shared
1417
+ * {@link devDb}. `db` is the per-request state (handles + result stash); bind a
1418
+ * fresh one per dispatch.
1419
+ */
1420
+ export function buildDatabaseImports(
1421
+ ref: MemoryRef,
1422
+ db: DbDevState,
1423
+ ): Record<string, (...args: number[]) => number | bigint> {
1424
+ return {
1425
+ 'data.resolve_collection': (
1426
+ namePtr: number,
1427
+ nameLen: number,
1428
+ outHandlePtr: number,
1429
+ ): number => devDb.resolveCollection(ref, db, namePtr, nameLen, outHandlePtr),
1430
+
1431
+ 'data.get': (handle: number, keyPtr: number, keyLen: number): number =>
1432
+ devDb.get(ref, db, handle, keyPtr, keyLen),
1433
+
1434
+ 'data.get_many': (handle: number, keysPtr: number, keysLen: number): number =>
1435
+ devDb.getMany(ref, db, handle, keysPtr, keysLen),
1436
+
1437
+ 'data.exists': (handle: number, keyPtr: number, keyLen: number): number =>
1438
+ devDb.exists(ref, db, handle, keyPtr, keyLen),
1439
+
1440
+ 'data.create': (
1441
+ handle: number,
1442
+ keyPtr: number,
1443
+ keyLen: number,
1444
+ valPtr: number,
1445
+ valLen: number,
1446
+ idemPtr: number,
1447
+ ): number => devDb.create(ref, db, handle, keyPtr, keyLen, valPtr, valLen, idemPtr),
1448
+
1449
+ 'data.patch': (
1450
+ handle: number,
1451
+ keyPtr: number,
1452
+ keyLen: number,
1453
+ patchPtr: number,
1454
+ patchLen: number,
1455
+ idemPtr: number,
1456
+ ): number => devDb.patch(ref, db, handle, keyPtr, keyLen, patchPtr, patchLen, idemPtr),
1457
+
1458
+ 'data.delete': (handle: number, keyPtr: number, keyLen: number, idemPtr: number): number =>
1459
+ devDb.delete(ref, db, handle, keyPtr, keyLen, idemPtr),
1460
+
1461
+ 'data.get_delete': (
1462
+ handle: number,
1463
+ keyPtr: number,
1464
+ keyLen: number,
1465
+ idemPtr: number,
1466
+ ): number => devDb.getDelete(ref, db, handle, keyPtr, keyLen, idemPtr),
1467
+
1468
+ 'data.unique_lookup': (handle: number, keyPtr: number, keyLen: number): number =>
1469
+ devDb.uniqueLookup(ref, db, handle, keyPtr, keyLen),
1470
+
1471
+ 'data.unique_claim': (
1472
+ handle: number,
1473
+ keyPtr: number,
1474
+ keyLen: number,
1475
+ valPtr: number,
1476
+ valLen: number,
1477
+ idemPtr: number,
1478
+ ): number => devDb.uniqueClaim(ref, db, handle, keyPtr, keyLen, valPtr, valLen, idemPtr),
1479
+
1480
+ 'data.unique_release': (
1481
+ handle: number,
1482
+ keyPtr: number,
1483
+ keyLen: number,
1484
+ valPtr: number,
1485
+ valLen: number,
1486
+ _idemPtr: number,
1487
+ ): number => devDb.uniqueRelease(ref, db, handle, keyPtr, keyLen, valPtr, valLen),
1488
+
1489
+ 'data.membership_contains': (
1490
+ handle: number,
1491
+ setPtr: number,
1492
+ setLen: number,
1493
+ memberPtr: number,
1494
+ memberLen: number,
1495
+ ): number =>
1496
+ devDb.membershipContains(ref, db, handle, setPtr, setLen, memberPtr, memberLen),
1497
+
1498
+ 'data.membership_add': (
1499
+ handle: number,
1500
+ setPtr: number,
1501
+ setLen: number,
1502
+ memberPtr: number,
1503
+ memberLen: number,
1504
+ _idemPtr: number,
1505
+ ): number => devDb.membershipAdd(ref, db, handle, setPtr, setLen, memberPtr, memberLen),
1506
+
1507
+ 'data.membership_remove': (
1508
+ handle: number,
1509
+ setPtr: number,
1510
+ setLen: number,
1511
+ memberPtr: number,
1512
+ memberLen: number,
1513
+ _idemPtr: number,
1514
+ ): number => devDb.membershipRemove(ref, db, handle, setPtr, setLen, memberPtr, memberLen),
1515
+
1516
+ 'data.membership_list': (
1517
+ handle: number,
1518
+ setPtr: number,
1519
+ setLen: number,
1520
+ limit: number,
1521
+ ): number => devDb.membershipList(ref, db, handle, setPtr, setLen, limit),
1522
+
1523
+ 'data.view_get': (handle: number, keyPtr: number, keyLen: number): number =>
1524
+ devDb.viewGet(ref, db, handle, keyPtr, keyLen),
1525
+
1526
+ 'data.view_publish': (
1527
+ handle: number,
1528
+ keyPtr: number,
1529
+ keyLen: number,
1530
+ valPtr: number,
1531
+ valLen: number,
1532
+ _idemPtr: number,
1533
+ ): number => devDb.viewPublish(ref, db, handle, keyPtr, keyLen, valPtr, valLen),
1534
+
1535
+ 'data.counter_get': (handle: number, keyPtr: number, keyLen: number): number =>
1536
+ devDb.counterGet(ref, db, handle, keyPtr, keyLen),
1537
+
1538
+ 'data.counter_add': (
1539
+ handle: number,
1540
+ keyPtr: number,
1541
+ keyLen: number,
1542
+ delta: number | bigint,
1543
+ idemPtr: number,
1544
+ ): number => devDb.counterAdd(ref, db, handle, keyPtr, keyLen, delta, idemPtr),
1545
+
1546
+ 'data.append': (
1547
+ handle: number,
1548
+ keyPtr: number,
1549
+ keyLen: number,
1550
+ evPtr: number,
1551
+ evLen: number,
1552
+ idemPtr: number,
1553
+ ): number => devDb.append(ref, db, handle, keyPtr, keyLen, evPtr, evLen, idemPtr),
1554
+
1555
+ 'data.append_once': (
1556
+ handle: number,
1557
+ keyPtr: number,
1558
+ keyLen: number,
1559
+ evidPtr: number,
1560
+ evidLen: number,
1561
+ evPtr: number,
1562
+ evLen: number,
1563
+ ): number =>
1564
+ devDb.appendOnce(ref, db, handle, keyPtr, keyLen, evidPtr, evidLen, evPtr, evLen),
1565
+
1566
+ 'data.enqueue': (
1567
+ handle: number,
1568
+ keyPtr: number,
1569
+ keyLen: number,
1570
+ valPtr: number,
1571
+ valLen: number,
1572
+ idemPtr: number,
1573
+ ): number => devDb.enqueue(ref, db, handle, keyPtr, keyLen, valPtr, valLen, idemPtr),
1574
+
1575
+ 'data.latest': (handle: number, keyPtr: number, keyLen: number, limit: number): number =>
1576
+ devDb.latest(ref, db, handle, keyPtr, keyLen, limit),
1577
+
1578
+ 'data.capacity_set_total': (
1579
+ handle: number,
1580
+ keyPtr: number,
1581
+ keyLen: number,
1582
+ total: number | bigint,
1583
+ _idemPtr: number,
1584
+ ): number => devDb.capacitySetTotal(ref, db, handle, keyPtr, keyLen, total),
1585
+
1586
+ 'data.capacity_available': (handle: number, keyPtr: number, keyLen: number): number =>
1587
+ devDb.capacityAvailable(ref, db, handle, keyPtr, keyLen),
1588
+
1589
+ 'data.capacity_reserve': (
1590
+ handle: number,
1591
+ keyPtr: number,
1592
+ keyLen: number,
1593
+ amount: number | bigint,
1594
+ ttlMs: number | bigint,
1595
+ idemPtr: number,
1596
+ ): number => devDb.capacityReserve(ref, db, handle, keyPtr, keyLen, amount, ttlMs, idemPtr),
1597
+
1598
+ 'data.capacity_confirm': (
1599
+ handle: number,
1600
+ keyPtr: number,
1601
+ keyLen: number,
1602
+ reservationId: number | bigint,
1603
+ _idemPtr: number,
1604
+ ): number => devDb.capacityConfirm(ref, db, handle, keyPtr, keyLen, reservationId),
1605
+
1606
+ 'data.capacity_cancel': (
1607
+ handle: number,
1608
+ keyPtr: number,
1609
+ keyLen: number,
1610
+ reservationId: number | bigint,
1611
+ _idemPtr: number,
1612
+ ): number => devDb.capacityCancel(ref, db, handle, keyPtr, keyLen, reservationId),
1613
+
1614
+ 'data.take_result': (outPtr: number, outCap: number): number =>
1615
+ devDb.takeResult(ref, db, outPtr, outCap),
1616
+
1617
+ 'data.result_schema_version': (): bigint => devDb.resultSchemaVersion(db),
1618
+
1619
+ // `data.write_allowed() -> i32`: 1 if the current call may issue the
1620
+ // record patch used by rewrite-on-read migration convergence.
1621
+ 'data.write_allowed': (): number => (kindAllows(db.functionKind, DbOp.Patch) ? 1 : 0),
1622
+ };
1623
+ }
1624
+
1625
+ /** Test-only: clear the stores + catalog + persistence between unit tests. */
1626
+ export function __resetDbForTests(): void {
1627
+ devDb.resetForTests();
1628
+ }
1629
+
1630
+ /** Test-only: seed the catalog directly. Number values default to record-family entries. */
1631
+ export function __setDbCatalogForTests(entries: Record<string, CatalogSeedEntry>): void {
1632
+ devDb.setCatalogForTests(entries);
1633
+ }