toiljs 0.0.60 → 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 (119) hide show
  1. package/.github/workflows/ci.yml +31 -0
  2. package/CHANGELOG.md +5 -0
  3. package/build/cli/.tsbuildinfo +1 -1
  4. package/build/cli/index.js +2 -2
  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 -1
  32. package/build/devserver/db/catalog.js +44 -44
  33. package/build/devserver/db/database.d.ts +27 -11
  34. package/build/devserver/db/database.js +539 -169
  35. package/build/devserver/db/index.d.ts +1 -1
  36. package/build/devserver/db/index.js +1 -1
  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 +64 -1
  40. package/build/devserver/db/types.js +33 -1
  41. package/build/devserver/index.d.ts +10 -0
  42. package/build/devserver/index.js +7 -0
  43. package/build/devserver/mstore/store.d.ts +18 -0
  44. package/build/devserver/mstore/store.js +82 -0
  45. package/build/devserver/runtime/host.d.ts +6 -0
  46. package/build/devserver/runtime/host.js +45 -1
  47. package/build/devserver/runtime/module.d.ts +1 -0
  48. package/build/devserver/runtime/module.js +27 -1
  49. package/build/devserver/server.d.ts +6 -0
  50. package/build/devserver/server.js +59 -0
  51. package/build/devserver/ssr.d.ts +25 -0
  52. package/build/devserver/ssr.js +114 -0
  53. package/build/devserver/wasm/sections.d.ts +2 -0
  54. package/build/devserver/wasm/sections.js +42 -0
  55. package/build/devserver/wasm/surface.d.ts +18 -0
  56. package/build/devserver/wasm/surface.js +41 -0
  57. package/docs/README.md +4 -4
  58. package/docs/auth-todo.md +6 -6
  59. package/docs/caching.md +5 -5
  60. package/docs/cli.md +15 -0
  61. package/docs/client.md +40 -0
  62. package/docs/crypto.md +4 -4
  63. package/docs/data.md +6 -6
  64. package/docs/email.md +28 -28
  65. package/docs/environment.md +10 -10
  66. package/docs/index.md +26 -0
  67. package/docs/ratelimit.md +10 -10
  68. package/docs/routing.md +2 -2
  69. package/docs/server.md +61 -0
  70. package/docs/ssr.md +561 -113
  71. package/docs/styling.md +22 -0
  72. package/docs/time.md +1 -1
  73. package/eslint.config.js +10 -1
  74. package/examples/basic/client/components/Header.tsx +3 -0
  75. package/examples/basic/client/routes/features/actions.tsx +0 -2
  76. package/examples/basic/client/routes/hello.tsx +89 -19
  77. package/examples/basic/client/styles/main.css +48 -0
  78. package/examples/basic/server/SsrHelloRender.ts +97 -0
  79. package/examples/basic/server/main.ts +5 -0
  80. package/examples/basic/server/streams/Echo.ts +49 -0
  81. package/package.json +12 -10
  82. package/scripts/gen-toil-docs.mjs +96 -0
  83. package/src/cli/create.ts +2 -2
  84. package/src/client/index.ts +1 -1
  85. package/src/client/routing/mount.tsx +18 -2
  86. package/src/client/ssr/markers.tsx +22 -0
  87. package/src/compiler/config.ts +88 -2
  88. package/src/compiler/docs.ts +47 -308
  89. package/src/compiler/index.ts +236 -32
  90. package/src/compiler/ssr-codegen.ts +1 -1
  91. package/src/compiler/template-build.ts +247 -46
  92. package/src/compiler/toil-docs.generated.ts +26 -0
  93. package/src/devserver/daemon/catalog.ts +120 -0
  94. package/src/devserver/daemon/cron.ts +87 -0
  95. package/src/devserver/daemon/host.ts +224 -0
  96. package/src/devserver/daemon/index.ts +349 -0
  97. package/src/devserver/db/catalog.ts +61 -53
  98. package/src/devserver/db/database.ts +613 -149
  99. package/src/devserver/db/index.ts +1 -1
  100. package/src/devserver/db/routeKinds.ts +147 -0
  101. package/src/devserver/db/types.ts +65 -2
  102. package/src/devserver/index.ts +12 -0
  103. package/src/devserver/mstore/store.ts +121 -0
  104. package/src/devserver/runtime/host.ts +92 -1
  105. package/src/devserver/runtime/module.ts +35 -1
  106. package/src/devserver/server.ts +101 -0
  107. package/src/devserver/ssr.ts +166 -0
  108. package/src/devserver/wasm/sections.ts +59 -0
  109. package/src/devserver/wasm/surface.ts +88 -0
  110. package/test/daemon-build.test.ts +198 -0
  111. package/test/daemon-catalog.test.ts +265 -0
  112. package/test/daemon-emulation.test.ts +216 -0
  113. package/test/devserver-database.test.ts +396 -5
  114. package/test/email-preview.test.ts +6 -1
  115. package/test/fixtures/daemon-app.ts +56 -0
  116. package/test/global-setup.ts +17 -0
  117. package/test/ssr-render.test.ts +94 -27
  118. package/test/ssr-template.test.tsx +44 -1
  119. package/vitest.config.ts +3 -0
@@ -17,6 +17,7 @@
17
17
  */
18
18
 
19
19
  import fs from 'node:fs';
20
+ import { createHash } from 'node:crypto';
20
21
  import path from 'node:path';
21
22
 
22
23
  import { DataReader, DataWriter } from 'toiljs/io';
@@ -27,19 +28,31 @@ import {
27
28
  ALREADY_EXISTS,
28
29
  type CapLedger,
29
30
  CODEC_ERR,
31
+ CollectionFamily,
30
32
  CONFLICT,
33
+ type DbCatalogState,
31
34
  type DbDevState,
32
35
  type DbSnapshot,
36
+ DbFunctionKind,
37
+ DEFAULT_FILL_WAIT_MS,
38
+ type DevCollectionHandle,
33
39
  INVALID_HANDLE,
34
40
  MAX_KEY,
41
+ MAX_FILL_WAIT_MS,
35
42
  MAX_NAME,
36
43
  MAX_RESERVATION_TTL_MS,
37
44
  MAX_RESERVATIONS,
38
45
  MAX_VALUE,
46
+ OP_NOT_ALLOWED_FOR_FAMILY,
47
+ OP_NOT_ALLOWED_IN_KIND,
48
+ type RecordOutcomeSnapshot,
39
49
  type Reservation,
40
50
  satI64,
51
+ SCHEMA_UNAVAILABLE,
41
52
  TOO_MANY_KEYS,
42
53
  TOO_SMALL,
54
+ UNAVAILABLE,
55
+ isCollectionFamily,
43
56
  } from './types.js';
44
57
 
45
58
  // ---- schema versions: the dev equivalent of the edge binding the row's
@@ -73,10 +86,201 @@ function storeKey(collection: string, key: Buffer): string {
73
86
  return collection + '\0' + key.toString('latin1');
74
87
  }
75
88
 
76
- function collOf(db: DbDevState, handle: number): string | null {
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 {
77
170
  return handle >= 0 && handle < db.handles.length ? db.handles[handle] : null;
78
171
  }
79
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
+
80
284
  /**
81
285
  * The single-process dev data store: the seven ToilDB families, their per-row
82
286
  * schema_versions, the loaded wasm's catalog, and optional on-disk persistence.
@@ -85,19 +289,24 @@ function collOf(db: DbDevState, handle: number): string | null {
85
289
  export class DevDatabase {
86
290
  /** Process-lifetime store: `"collection\0keyLatin1"` -> value. Shared across dispatches. */
87
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>();
88
296
  /** View family: `"collection\0key"` -> the latest published view blob. */
89
297
  private readonly views = new Map<string, Buffer>();
90
298
  /** Membership family: `"collection\0setKey"` -> (memberLatin1 -> member bytes). */
91
299
  private readonly members = new Map<string, Map<string, Buffer>>();
92
300
  /** Counter family: `"collection\0key"` -> saturating i64 sum of deltas. */
93
301
  private readonly counters = new Map<string, bigint>();
302
+ /** Counter idempotency: collection+key+idem -> original delta. */
303
+ private readonly counterIdem = new Map<string, bigint>();
94
304
  /** Events family: `"collection\0key"` -> append-ordered event blobs (oldest first). */
95
305
  private readonly events = new Map<string, Buffer[]>();
96
306
  /** append_once dedup: `"collection\0key"` -> set of eventIds already appended. */
97
307
  private readonly eventDedup = new Map<string, Set<string>>();
98
308
  /** Capacity family: `"collection\0key"` -> an escrow ledger (ceiling + reservations). */
99
309
  private readonly capacity = new Map<string, CapLedger>();
100
-
101
310
  /** `"collection\0key"` -> the schema_version the record/view/unique-owner was last
102
311
  * written under (single-value families; the edge stores it per StoredValue). */
103
312
  private readonly versions = new Map<string, number>();
@@ -105,22 +314,79 @@ export class DevDatabase {
105
314
  private readonly eventVersions = new Map<string, number[]>();
106
315
  /** Per-member schema_version: `sk` -> (memberLatin1 -> version), parallel to `members`. */
107
316
  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>();
317
+ /** The decoded catalog from the loaded wasm, including family + current schema_version. */
318
+ private catalog: DbCatalogState = { kind: 'no-section' };
110
319
 
111
320
  // ---- on-disk persistence: dev data + its versions survive restarts, so a
112
321
  // developer can write rows, evolve a @data type, restart, and watch the @migrate
113
322
  // run. Delete the file to reset the dev database. JSON with base64 buffers.
114
323
  private persistPath: string | null = null;
115
324
 
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. */
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. */
118
327
  setCatalog(wasm: Buffer): void {
119
328
  this.catalog = parseCatalog(wasm);
120
329
  }
121
330
 
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
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
+ }
124
390
  }
125
391
 
126
392
  /** Point the dev DB at an on-disk file and load any existing snapshot. Call once at
@@ -136,15 +402,25 @@ export class DevDatabase {
136
402
  if (this.persistPath === null) return;
137
403
  const snap: DbSnapshot = {
138
404
  store: {},
405
+ recordIdem: {},
406
+ uniqueIdem: {},
139
407
  views: {},
140
408
  members: {},
141
409
  counters: {},
410
+ counterIdem: {},
142
411
  events: {},
143
412
  eventDedup: {},
144
413
  capacity: {},
145
414
  };
146
415
  for (const [k, v] of this.store)
147
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;
148
424
  for (const [k, v] of this.views)
149
425
  snap.views[k] = { v: v.toString('base64'), sv: this.versions.get(k) ?? 0 };
150
426
  for (const [k, m] of this.members) {
@@ -154,6 +430,7 @@ export class DevDatabase {
154
430
  snap.members[k] = o;
155
431
  }
156
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();
157
434
  for (const [k, log] of this.events) {
158
435
  const ver = this.eventVersions.get(k) ?? [];
159
436
  snap.events[k] = log.map((b, i) => ({ v: b.toString('base64'), sv: ver[i] ?? 0 }));
@@ -194,6 +471,16 @@ export class DevDatabase {
194
471
  this.store.set(k, Buffer.from(e.v, 'base64'));
195
472
  this.versions.set(k, e.sv);
196
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);
197
484
  for (const [k, e] of Object.entries(snap.views ?? {})) {
198
485
  this.views.set(k, Buffer.from(e.v, 'base64'));
199
486
  this.versions.set(k, e.sv);
@@ -209,6 +496,8 @@ export class DevDatabase {
209
496
  this.memberVersions.set(k, ver);
210
497
  }
211
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));
212
501
  for (const [k, log] of Object.entries(snap.events ?? {})) {
213
502
  this.events.set(
214
503
  k,
@@ -239,11 +528,14 @@ export class DevDatabase {
239
528
 
240
529
  private clear(): void {
241
530
  this.store.clear();
531
+ this.recordIdem.clear();
532
+ this.uniqueIdem.clear();
242
533
  this.versions.clear();
243
534
  this.views.clear();
244
535
  this.members.clear();
245
536
  this.memberVersions.clear();
246
537
  this.counters.clear();
538
+ this.counterIdem.clear();
247
539
  this.eventVersions.clear();
248
540
  this.events.clear();
249
541
  this.eventDedup.clear();
@@ -284,8 +576,30 @@ export class DevDatabase {
284
576
  ): number {
285
577
  if (nameLen < 0 || nameLen > MAX_NAME) throw new Error('data: collection name too long');
286
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
+ }
287
601
  const handle = db.handles.length;
288
- db.handles.push(name);
602
+ db.handles.push(coll);
289
603
  const m = mem(ref);
290
604
  if (outHandlePtr < 0 || outHandlePtr + 4 > m.length)
291
605
  throw new Error('data: resolve out-handle out of bounds');
@@ -294,10 +608,10 @@ export class DevDatabase {
294
608
  }
295
609
 
296
610
  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;
611
+ const coll = collForOp(db, handle, DbOp.Get, CollectionFamily.Record);
612
+ if (typeof coll === 'number') return coll;
299
613
  if (keyLen > MAX_KEY) throw new Error('data: key too long');
300
- const sk = storeKey(coll, readKey(ref, keyPtr, keyLen));
614
+ const sk = storeKey(coll.name, readKey(ref, keyPtr, keyLen));
301
615
  const v = this.store.get(sk);
302
616
  if (v === undefined) return ABSENT;
303
617
  db.lastResult = v;
@@ -315,9 +629,16 @@ export class DevDatabase {
315
629
  keysPtr: number,
316
630
  keysLen: number,
317
631
  ): number {
318
- const coll = collOf(db, handle);
319
- if (coll === null) return INVALID_HANDLE;
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;
320
640
  if (keysLen > MAX_VALUE) throw new Error('data: keys blob too large');
641
+ const table = coll.family === CollectionFamily.View ? this.views : this.store;
321
642
  // Keys blob: u32 count, then per key a u32-length-prefixed blob. The shared
322
643
  // DataReader is bounds-safe (empty past end), so a malformed/truncated blob
323
644
  // can't over-read; cap each key at MAX_KEY like the edge's prepare_key.
@@ -332,12 +653,14 @@ export class DevDatabase {
332
653
  for (let i = 0; i < count; i++) {
333
654
  const key = r.readBytes();
334
655
  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);
656
+ const sk = storeKey(coll.name, Buffer.from(key));
657
+ const v = table.get(sk);
337
658
  if (v === undefined) {
338
659
  w.writeU8(0);
339
660
  } else {
340
- w.writeU8(1).writeU32(this.versions.get(sk) ?? 0).writeBytes(v);
661
+ w.writeU8(1)
662
+ .writeU32(this.versions.get(sk) ?? 0)
663
+ .writeBytes(v);
341
664
  }
342
665
  }
343
666
  db.lastResult = Buffer.from(w.toBytes());
@@ -345,9 +668,9 @@ export class DevDatabase {
345
668
  }
346
669
 
347
670
  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;
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;
351
674
  }
352
675
 
353
676
  create(
@@ -358,15 +681,28 @@ export class DevDatabase {
358
681
  keyLen: number,
359
682
  valPtr: number,
360
683
  valLen: number,
684
+ idemPtr: number,
361
685
  ): number {
362
- const coll = collOf(db, handle);
363
- if (coll === null) return INVALID_HANDLE;
686
+ const coll = collForOp(db, handle, DbOp.Create, CollectionFamily.Record);
687
+ if (typeof coll === 'number') return coll;
364
688
  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;
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);
370
706
  }
371
707
 
372
708
  patch(
@@ -377,27 +713,52 @@ export class DevDatabase {
377
713
  keyLen: number,
378
714
  patchPtr: number,
379
715
  patchLen: number,
716
+ idemPtr: number,
380
717
  ): number {
381
- const coll = collOf(db, handle);
382
- if (coll === null) return INVALID_HANDLE;
718
+ const coll = collForOp(db, handle, DbOp.Patch, CollectionFamily.Record);
719
+ if (typeof coll === 'number') return coll;
383
720
  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
721
+ const key = readKey(ref, keyPtr, keyLen);
386
722
  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;
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);
392
738
  }
393
739
 
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));
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);
398
757
  this.store.delete(sk);
399
758
  this.versions.delete(sk);
400
- return 0;
759
+ const outcome: RecordOutcome = { kind: 'unit' };
760
+ this.recordIdemFinish(coll, key, 'D', idem, requestHash, outcome);
761
+ return this.replayRecordOutcome(db, outcome);
401
762
  }
402
763
 
403
764
  // Atomic fetch-and-delete (consume-once); deletes only on a real read.
@@ -407,17 +768,32 @@ export class DevDatabase {
407
768
  handle: number,
408
769
  keyPtr: number,
409
770
  keyLen: number,
771
+ idemPtr: number,
410
772
  ): number {
411
- const coll = collOf(db, handle);
412
- if (coll === null) return INVALID_HANDLE;
413
- const sk = storeKey(coll, readKey(ref, keyPtr, keyLen));
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);
414
782
  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;
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);
421
797
  }
422
798
 
423
799
  // --- unique family (lookup / claim / release) ---
@@ -429,9 +805,9 @@ export class DevDatabase {
429
805
  keyPtr: number,
430
806
  keyLen: number,
431
807
  ): number {
432
- const coll = collOf(db, handle);
433
- if (coll === null) return INVALID_HANDLE;
434
- const sk = storeKey(coll, readKey(ref, keyPtr, keyLen));
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));
435
811
  const v = this.store.get(sk);
436
812
  if (v === undefined) return ABSENT;
437
813
  db.lastResult = v;
@@ -448,19 +824,24 @@ export class DevDatabase {
448
824
  keyLen: number,
449
825
  valPtr: number,
450
826
  valLen: number,
827
+ idemPtr: number,
451
828
  ): number {
452
- const coll = collOf(db, handle);
453
- if (coll === null) return INVALID_HANDLE;
829
+ const coll = collForOp(db, handle, DbOp.UniqueClaim, CollectionFamily.Unique);
830
+ if (typeof coll === 'number') return coll;
454
831
  if (keyLen > MAX_KEY || valLen > MAX_VALUE) throw new Error('data: key/value too large');
455
- const sk = storeKey(coll, readKey(ref, keyPtr, keyLen));
832
+ const sk = storeKey(coll.name, readKey(ref, keyPtr, keyLen));
456
833
  const owner = readCopy(ref, valPtr, valLen);
834
+ const idem = readIdem(ref, idemPtr)?.toString('hex') ?? '';
457
835
  const existing = this.store.get(sk);
458
836
  if (existing === undefined) {
459
837
  this.store.set(sk, owner);
838
+ this.uniqueIdem.set(sk, idem);
460
839
  this.stampVersion(coll, sk);
461
840
  return 0; // Claimed
462
841
  }
463
- if (existing.equals(owner)) return 2; // AlreadyOwnedByCaller
842
+ if (existing.equals(owner)) {
843
+ return (this.uniqueIdem.get(sk) ?? '') === idem ? 2 : CONFLICT;
844
+ }
464
845
  db.lastResult = existing;
465
846
  return 1; // AlreadyClaimed (current owner stashed)
466
847
  }
@@ -474,13 +855,14 @@ export class DevDatabase {
474
855
  valPtr: number,
475
856
  valLen: number,
476
857
  ): number {
477
- const coll = collOf(db, handle);
478
- if (coll === null) return INVALID_HANDLE;
479
- const sk = storeKey(coll, readKey(ref, keyPtr, keyLen));
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));
480
861
  const existing = this.store.get(sk);
481
862
  if (existing === undefined) return 0; // idempotent
482
863
  if (!existing.equals(readCopy(ref, valPtr, valLen))) return CONFLICT; // not the owner
483
864
  this.store.delete(sk);
865
+ this.uniqueIdem.delete(sk);
484
866
  this.versions.delete(sk);
485
867
  return 0;
486
868
  }
@@ -496,9 +878,9 @@ export class DevDatabase {
496
878
  memberPtr: number,
497
879
  memberLen: number,
498
880
  ): 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)));
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)));
502
884
  if (set === undefined) return 0;
503
885
  return set.has(readCopy(ref, memberPtr, memberLen).toString('latin1')) ? 1 : 0;
504
886
  }
@@ -512,11 +894,11 @@ export class DevDatabase {
512
894
  memberPtr: number,
513
895
  memberLen: number,
514
896
  ): number {
515
- const coll = collOf(db, handle);
516
- if (coll === null) return INVALID_HANDLE;
897
+ const coll = collForOp(db, handle, DbOp.MembershipAdd, CollectionFamily.Membership);
898
+ if (typeof coll === 'number') return coll;
517
899
  if (setLen > MAX_KEY || memberLen > MAX_VALUE)
518
900
  throw new Error('data: set/member too large');
519
- const sk = storeKey(coll, readKey(ref, setPtr, setLen));
901
+ const sk = storeKey(coll.name, readKey(ref, setPtr, setLen));
520
902
  const member = readCopy(ref, memberPtr, memberLen);
521
903
  let set = this.members.get(sk);
522
904
  if (set === undefined) {
@@ -530,7 +912,7 @@ export class DevDatabase {
530
912
  mv = new Map();
531
913
  this.memberVersions.set(sk, mv);
532
914
  }
533
- mv.set(ml, this.catalog.get(coll) ?? 0);
915
+ mv.set(ml, this.currentSchemaVersion(coll));
534
916
  return 0;
535
917
  }
536
918
 
@@ -543,9 +925,9 @@ export class DevDatabase {
543
925
  memberPtr: number,
544
926
  memberLen: number,
545
927
  ): number {
546
- const coll = collOf(db, handle);
547
- if (coll === null) return INVALID_HANDLE;
548
- const sk = storeKey(coll, readKey(ref, setPtr, setLen));
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));
549
931
  const ml = readCopy(ref, memberPtr, memberLen).toString('latin1');
550
932
  this.members.get(sk)?.delete(ml);
551
933
  this.memberVersions.get(sk)?.delete(ml);
@@ -562,9 +944,9 @@ export class DevDatabase {
562
944
  setLen: number,
563
945
  limit: number,
564
946
  ): number {
565
- const coll = collOf(db, handle);
566
- if (coll === null) return INVALID_HANDLE;
567
- const sk = storeKey(coll, readKey(ref, setPtr, setLen));
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));
568
950
  const set = this.members.get(sk);
569
951
  const mv = this.memberVersions.get(sk);
570
952
  const n = Math.max(0, Math.min(limit, 0xffff));
@@ -590,9 +972,9 @@ export class DevDatabase {
590
972
  keyPtr: number,
591
973
  keyLen: number,
592
974
  ): number {
593
- const coll = collOf(db, handle);
594
- if (coll === null) return INVALID_HANDLE;
595
- const sk = storeKey(coll, readKey(ref, keyPtr, keyLen));
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));
596
978
  const v = this.views.get(sk);
597
979
  if (v === undefined) return ABSENT;
598
980
  db.lastResult = v;
@@ -610,10 +992,10 @@ export class DevDatabase {
610
992
  valPtr: number,
611
993
  valLen: number,
612
994
  ): number {
613
- const coll = collOf(db, handle);
614
- if (coll === null) return INVALID_HANDLE;
995
+ const coll = collForOp(db, handle, DbOp.ViewPublish, CollectionFamily.View);
996
+ if (typeof coll === 'number') return coll;
615
997
  if (keyLen > MAX_KEY || valLen > MAX_VALUE) throw new Error('data: key/view too large');
616
- const sk = storeKey(coll, readKey(ref, keyPtr, keyLen));
998
+ const sk = storeKey(coll.name, readKey(ref, keyPtr, keyLen));
617
999
  this.views.set(sk, readCopy(ref, valPtr, valLen));
618
1000
  this.stampVersion(coll, sk);
619
1001
  return 0;
@@ -630,9 +1012,9 @@ export class DevDatabase {
630
1012
  keyPtr: number,
631
1013
  keyLen: number,
632
1014
  ): 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;
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;
636
1018
  const out = Buffer.alloc(8);
637
1019
  out.writeBigInt64LE(sum);
638
1020
  db.lastResult = out;
@@ -648,11 +1030,21 @@ export class DevDatabase {
648
1030
  keyPtr: number,
649
1031
  keyLen: number,
650
1032
  delta: number | bigint,
1033
+ idemPtr: number,
651
1034
  ): 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)));
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));
656
1048
  return 0;
657
1049
  }
658
1050
 
@@ -666,14 +1058,27 @@ export class DevDatabase {
666
1058
  keyLen: number,
667
1059
  evPtr: number,
668
1060
  evLen: number,
1061
+ idemPtr: number,
669
1062
  ): number {
670
- const coll = collOf(db, handle);
671
- if (coll === null) return INVALID_HANDLE;
1063
+ const coll = collForOp(db, handle, DbOp.Append, CollectionFamily.Events);
1064
+ if (typeof coll === 'number') return coll;
672
1065
  if (keyLen > MAX_KEY || evLen > MAX_VALUE) throw new Error('data: key/event too large');
673
- const sk = storeKey(coll, readKey(ref, keyPtr, keyLen));
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
+ }
674
1079
  const log = this.events.get(sk);
675
1080
  const ev = readCopy(ref, evPtr, evLen);
676
- const sv = this.catalog.get(coll) ?? 0;
1081
+ const sv = this.currentSchemaVersion(coll);
677
1082
  if (log === undefined) {
678
1083
  this.events.set(sk, [ev]);
679
1084
  this.eventVersions.set(sk, [sv]);
@@ -697,10 +1102,10 @@ export class DevDatabase {
697
1102
  evPtr: number,
698
1103
  evLen: number,
699
1104
  ): number {
700
- const coll = collOf(db, handle);
701
- if (coll === null) return INVALID_HANDLE;
1105
+ const coll = collForOp(db, handle, DbOp.AppendOnce, CollectionFamily.Events);
1106
+ if (typeof coll === 'number') return coll;
702
1107
  if (keyLen > MAX_KEY || evLen > MAX_VALUE) throw new Error('data: key/event too large');
703
- const sk = storeKey(coll, readKey(ref, keyPtr, keyLen));
1108
+ const sk = storeKey(coll.name, readKey(ref, keyPtr, keyLen));
704
1109
  const evid = readCopy(ref, evidPtr, evidLen).toString('latin1');
705
1110
  let seen = this.eventDedup.get(sk);
706
1111
  if (seen === undefined) {
@@ -709,7 +1114,7 @@ export class DevDatabase {
709
1114
  }
710
1115
  if (seen.has(evid)) return 0; // already appended under this id
711
1116
  const ev = readCopy(ref, evPtr, evLen);
712
- const sv = this.catalog.get(coll) ?? 0;
1117
+ const sv = this.currentSchemaVersion(coll);
713
1118
  const log = this.events.get(sk);
714
1119
  if (log === undefined) {
715
1120
  this.events.set(sk, [ev]);
@@ -733,15 +1138,28 @@ export class DevDatabase {
733
1138
  keyLen: number,
734
1139
  valPtr: number,
735
1140
  valLen: number,
1141
+ idemPtr: number,
736
1142
  ): number {
737
- const coll = collOf(db, handle);
738
- if (coll === null) return INVALID_HANDLE;
1143
+ const coll = collForOp(db, handle, DbOp.Enqueue, CollectionFamily.Record);
1144
+ if (typeof coll === 'number') return coll;
739
1145
  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;
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);
745
1163
  }
746
1164
 
747
1165
  // Frame the newest-`limit` events as `u32 count` then per event a
@@ -755,9 +1173,9 @@ export class DevDatabase {
755
1173
  keyLen: number,
756
1174
  limit: number,
757
1175
  ): number {
758
- const coll = collOf(db, handle);
759
- if (coll === null) return INVALID_HANDLE;
760
- const sk = storeKey(coll, readKey(ref, keyPtr, keyLen));
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));
761
1179
  const log = this.events.get(sk) ?? [];
762
1180
  const vers = this.eventVersions.get(sk) ?? [];
763
1181
  const n = Math.max(0, Math.min(limit, 0xffff));
@@ -787,9 +1205,9 @@ export class DevDatabase {
787
1205
  keyLen: number,
788
1206
  total: number | bigint,
789
1207
  ): 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)));
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)));
793
1211
  const t = BigInt(total);
794
1212
  l.total = satI64(t < 0n ? 0n : t);
795
1213
  return 0;
@@ -803,9 +1221,9 @@ export class DevDatabase {
803
1221
  keyPtr: number,
804
1222
  keyLen: number,
805
1223
  ): 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)));
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)));
809
1227
  this.capPrune(l, Date.now());
810
1228
  const avail = l.total - this.capReserved(l);
811
1229
  const out = Buffer.alloc(8);
@@ -827,18 +1245,32 @@ export class DevDatabase {
827
1245
  keyLen: number,
828
1246
  amount: number | bigint,
829
1247
  ttlMs: number | bigint,
1248
+ idemPtr: number,
830
1249
  ): number {
831
- const coll = collOf(db, handle);
832
- if (coll === null) return INVALID_HANDLE;
1250
+ const coll = collForOp(db, handle, DbOp.CapacityReserve, CollectionFamily.Capacity);
1251
+ if (typeof coll === 'number') return coll;
833
1252
  const want = BigInt(amount);
834
1253
  if (want <= 0n) return CODEC_ERR; // BadAmount (edge: -1006)
835
- const l = this.capLedger(storeKey(coll, readKey(ref, keyPtr, keyLen)));
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));
836
1259
  const now = Date.now();
837
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
+ }
838
1271
  if (l.total - this.capReserved(l) < want || l.reservations.size >= MAX_RESERVATIONS)
839
1272
  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++;
1273
+ const id = requestedId ?? l.nextId++;
842
1274
  l.reservations.set(id, { amount: want, expiresMs: now + ttl, confirmed: false });
843
1275
  const out = Buffer.alloc(8);
844
1276
  out.writeBigUInt64LE(id);
@@ -857,9 +1289,9 @@ export class DevDatabase {
857
1289
  keyLen: number,
858
1290
  reservationId: number | bigint,
859
1291
  ): 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)));
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)));
863
1295
  this.capPrune(l, Date.now());
864
1296
  const r = l.reservations.get(BigInt(reservationId));
865
1297
  if (r === undefined) return 0;
@@ -877,9 +1309,9 @@ export class DevDatabase {
877
1309
  keyLen: number,
878
1310
  reservationId: number | bigint,
879
1311
  ): 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)));
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)));
883
1315
  this.capPrune(l, Date.now());
884
1316
  const id = BigInt(reservationId);
885
1317
  const r = l.reservations.get(id);
@@ -919,13 +1351,46 @@ export class DevDatabase {
919
1351
  /** Test-only: clear the stores + catalog + persistence between unit tests. */
920
1352
  resetForTests(): void {
921
1353
  this.clear();
922
- this.catalog = new Map();
1354
+ this.catalog = { kind: 'no-section' };
923
1355
  this.persistPath = null;
924
1356
  }
925
1357
 
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));
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 };
929
1394
  }
930
1395
  }
931
1396
 
@@ -978,8 +1443,8 @@ export function buildDatabaseImports(
978
1443
  keyLen: number,
979
1444
  valPtr: number,
980
1445
  valLen: number,
981
- _idemPtr: number,
982
- ): number => devDb.create(ref, db, handle, keyPtr, keyLen, valPtr, valLen),
1446
+ idemPtr: number,
1447
+ ): number => devDb.create(ref, db, handle, keyPtr, keyLen, valPtr, valLen, idemPtr),
983
1448
 
984
1449
  'data.patch': (
985
1450
  handle: number,
@@ -987,18 +1452,18 @@ export function buildDatabaseImports(
987
1452
  keyLen: number,
988
1453
  patchPtr: number,
989
1454
  patchLen: number,
990
- _idemPtr: number,
991
- ): number => devDb.patch(ref, db, handle, keyPtr, keyLen, patchPtr, patchLen),
1455
+ idemPtr: number,
1456
+ ): number => devDb.patch(ref, db, handle, keyPtr, keyLen, patchPtr, patchLen, idemPtr),
992
1457
 
993
- 'data.delete': (handle: number, keyPtr: number, keyLen: number, _idemPtr: number): number =>
994
- devDb.delete(ref, db, handle, keyPtr, keyLen),
1458
+ 'data.delete': (handle: number, keyPtr: number, keyLen: number, idemPtr: number): number =>
1459
+ devDb.delete(ref, db, handle, keyPtr, keyLen, idemPtr),
995
1460
 
996
1461
  'data.get_delete': (
997
1462
  handle: number,
998
1463
  keyPtr: number,
999
1464
  keyLen: number,
1000
- _idemPtr: number,
1001
- ): number => devDb.getDelete(ref, db, handle, keyPtr, keyLen),
1465
+ idemPtr: number,
1466
+ ): number => devDb.getDelete(ref, db, handle, keyPtr, keyLen, idemPtr),
1002
1467
 
1003
1468
  'data.unique_lookup': (handle: number, keyPtr: number, keyLen: number): number =>
1004
1469
  devDb.uniqueLookup(ref, db, handle, keyPtr, keyLen),
@@ -1009,8 +1474,8 @@ export function buildDatabaseImports(
1009
1474
  keyLen: number,
1010
1475
  valPtr: number,
1011
1476
  valLen: number,
1012
- _idemPtr: number,
1013
- ): number => devDb.uniqueClaim(ref, db, handle, keyPtr, keyLen, valPtr, valLen),
1477
+ idemPtr: number,
1478
+ ): number => devDb.uniqueClaim(ref, db, handle, keyPtr, keyLen, valPtr, valLen, idemPtr),
1014
1479
 
1015
1480
  'data.unique_release': (
1016
1481
  handle: number,
@@ -1075,8 +1540,8 @@ export function buildDatabaseImports(
1075
1540
  keyPtr: number,
1076
1541
  keyLen: number,
1077
1542
  delta: number | bigint,
1078
- _idemPtr: number,
1079
- ): number => devDb.counterAdd(ref, db, handle, keyPtr, keyLen, delta),
1543
+ idemPtr: number,
1544
+ ): number => devDb.counterAdd(ref, db, handle, keyPtr, keyLen, delta, idemPtr),
1080
1545
 
1081
1546
  'data.append': (
1082
1547
  handle: number,
@@ -1084,8 +1549,8 @@ export function buildDatabaseImports(
1084
1549
  keyLen: number,
1085
1550
  evPtr: number,
1086
1551
  evLen: number,
1087
- _idemPtr: number,
1088
- ): number => devDb.append(ref, db, handle, keyPtr, keyLen, evPtr, evLen),
1552
+ idemPtr: number,
1553
+ ): number => devDb.append(ref, db, handle, keyPtr, keyLen, evPtr, evLen, idemPtr),
1089
1554
 
1090
1555
  'data.append_once': (
1091
1556
  handle: number,
@@ -1104,8 +1569,8 @@ export function buildDatabaseImports(
1104
1569
  keyLen: number,
1105
1570
  valPtr: number,
1106
1571
  valLen: number,
1107
- _idemPtr: number,
1108
- ): number => devDb.enqueue(ref, db, handle, keyPtr, keyLen, valPtr, valLen),
1572
+ idemPtr: number,
1573
+ ): number => devDb.enqueue(ref, db, handle, keyPtr, keyLen, valPtr, valLen, idemPtr),
1109
1574
 
1110
1575
  'data.latest': (handle: number, keyPtr: number, keyLen: number, limit: number): number =>
1111
1576
  devDb.latest(ref, db, handle, keyPtr, keyLen, limit),
@@ -1127,8 +1592,8 @@ export function buildDatabaseImports(
1127
1592
  keyLen: number,
1128
1593
  amount: number | bigint,
1129
1594
  ttlMs: number | bigint,
1130
- _idemPtr: number,
1131
- ): number => devDb.capacityReserve(ref, db, handle, keyPtr, keyLen, amount, ttlMs),
1595
+ idemPtr: number,
1596
+ ): number => devDb.capacityReserve(ref, db, handle, keyPtr, keyLen, amount, ttlMs, idemPtr),
1132
1597
 
1133
1598
  'data.capacity_confirm': (
1134
1599
  handle: number,
@@ -1151,10 +1616,9 @@ export function buildDatabaseImports(
1151
1616
 
1152
1617
  'data.result_schema_version': (): bigint => devDb.resultSchemaVersion(db),
1153
1618
 
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,
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),
1158
1622
  };
1159
1623
  }
1160
1624
 
@@ -1163,7 +1627,7 @@ export function __resetDbForTests(): void {
1163
1627
  devDb.resetForTests();
1164
1628
  }
1165
1629
 
1166
- /** Test-only: seed the catalog (collection -> current schema_version) directly. */
1167
- export function __setDbCatalogForTests(entries: Record<string, number>): void {
1630
+ /** Test-only: seed the catalog directly. Number values default to record-family entries. */
1631
+ export function __setDbCatalogForTests(entries: Record<string, CatalogSeedEntry>): void {
1168
1632
  devDb.setCatalogForTests(entries);
1169
1633
  }