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
@@ -1,9 +1,38 @@
1
- import { afterEach, describe, expect, it } from 'vitest';
1
+ import { mkdtempSync, rmSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
2
4
 
3
- import { __resetDbForTests, buildDatabaseImports, freshDbState } from '../src/devserver/database.js';
4
- import type { MemoryRef } from '../src/devserver/host.js';
5
+ import { afterEach, describe, expect, it } from 'vitest';
5
6
 
6
- function setup() {
7
+ import {
8
+ CollectionFamily,
9
+ DbFunctionKind,
10
+ __resetDbForTests,
11
+ __setDbCatalogForTests,
12
+ buildDatabaseImports,
13
+ configureDbPersistence,
14
+ freshDbState,
15
+ persistDb,
16
+ setDbCatalog,
17
+ } from '../src/devserver/db/index.js';
18
+ import { parseRouteKinds, routeKindForRequest } from '../src/devserver/db/routeKinds.js';
19
+ import type { MemoryRef } from '../src/devserver/runtime/host.js';
20
+
21
+ const DEFAULT_CATALOG = {
22
+ 'App/users': { family: CollectionFamily.Record },
23
+ 'App/posts': { family: CollectionFamily.Record },
24
+ 'App/docs': { family: CollectionFamily.Record },
25
+ 'App/challenges': { family: CollectionFamily.Record },
26
+ 'App/pages': { family: CollectionFamily.View },
27
+ 'App/feed': { family: CollectionFamily.Events },
28
+ 'App/likes': { family: CollectionFamily.Counter },
29
+ 'App/rooms': { family: CollectionFamily.Membership },
30
+ 'App/usernames': { family: CollectionFamily.Unique },
31
+ 'App/seats': { family: CollectionFamily.Capacity },
32
+ 'App/tickets': { family: CollectionFamily.Capacity },
33
+ };
34
+
35
+ function setupRaw() {
7
36
  const memory = new WebAssembly.Memory({ initial: 1 });
8
37
  const ref: MemoryRef = { memory };
9
38
  const db = freshDbState();
@@ -12,6 +41,11 @@ function setup() {
12
41
  return { ref, db, imports, buf };
13
42
  }
14
43
 
44
+ function setup() {
45
+ __setDbCatalogForTests(DEFAULT_CATALOG);
46
+ return setupRaw();
47
+ }
48
+
15
49
  /** Write bytes at `offset`, returning the `[ptr, len]` pair the imports expect. */
16
50
  function put(buf: Buffer, offset: number, data: string): [number, number] {
17
51
  const b = Buffer.from(data);
@@ -19,17 +53,213 @@ function put(buf: Buffer, offset: number, data: string): [number, number] {
19
53
  return [offset, b.length];
20
54
  }
21
55
 
22
- function resolve(imports: Record<string, (...a: number[]) => number>, buf: Buffer, name: string): number {
56
+ function resolve(
57
+ imports: Record<string, (...a: number[]) => number>,
58
+ buf: Buffer,
59
+ name: string,
60
+ ): number {
23
61
  const [p, l] = put(buf, 0, name);
24
62
  expect(imports['data.resolve_collection'](p, l, 16)).toBe(0);
25
63
  return buf.readUInt32LE(16);
26
64
  }
27
65
 
66
+ function wasmWithSection(name: string, payload: Uint8Array): Buffer {
67
+ const nameBytes = Buffer.from(name);
68
+ const sectionPayload = Buffer.concat([
69
+ Buffer.from([nameBytes.length]),
70
+ nameBytes,
71
+ Buffer.from(payload),
72
+ ]);
73
+ return Buffer.concat([
74
+ Buffer.from([0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x00, sectionPayload.length]),
75
+ sectionPayload,
76
+ ]);
77
+ }
78
+
79
+ function routeKindsSection(routes: readonly (readonly [number, number, string])[]): Buffer {
80
+ const chunks: Buffer[] = [Buffer.alloc(4)];
81
+ chunks[0].writeUInt16LE(1, 0);
82
+ chunks[0].writeUInt16LE(routes.length, 2);
83
+ for (const [method, kind, pattern] of routes) {
84
+ const p = Buffer.from(pattern, 'utf8');
85
+ const header = Buffer.alloc(6);
86
+ header.writeUInt8(method, 0);
87
+ header.writeUInt8(kind, 1);
88
+ header.writeUInt32LE(p.length, 2);
89
+ chunks.push(header, p);
90
+ }
91
+ return Buffer.concat(chunks);
92
+ }
93
+
94
+ function catalogSectionV2(fillMaxWaitMs: number, fillAllowStale: number): Buffer {
95
+ const chunks: Buffer[] = [];
96
+ const u8 = (v: number) => chunks.push(Buffer.from([v & 0xff]));
97
+ const u16 = (v: number) => {
98
+ const b = Buffer.alloc(2);
99
+ b.writeUInt16LE(v);
100
+ chunks.push(b);
101
+ };
102
+ const u32 = (v: number) => {
103
+ const b = Buffer.alloc(4);
104
+ b.writeUInt32LE(v >>> 0);
105
+ chunks.push(b);
106
+ };
107
+ const str = (s: string) => {
108
+ const b = Buffer.from(s, 'utf8');
109
+ u32(b.length);
110
+ chunks.push(b);
111
+ };
112
+
113
+ u16(2); // catalog version
114
+ u16(1); // databases
115
+ str('App');
116
+ u16(1); // collections
117
+ str('users');
118
+ u8(CollectionFamily.Record);
119
+ str('UserId');
120
+ str('User');
121
+ u32(0xaaaa);
122
+ u32(0x1234);
123
+ u32(0); // generation
124
+ u8(5); // replication = localOnly, accepted by the backend ABI
125
+ u8(0); // placement = hashKey
126
+ u32(fillMaxWaitMs);
127
+ u8(fillAllowStale);
128
+ u16(0); // fields
129
+ u16(0); // migrations
130
+ return Buffer.concat(chunks);
131
+ }
132
+
28
133
  afterEach(() => {
29
134
  __resetDbForTests();
30
135
  });
31
136
 
32
137
  describe('toildb dev emulator (record family)', () => {
138
+ it('parses route-kind metadata and only tightens matching mutating routes', () => {
139
+ const wasm = wasmWithSection(
140
+ 'toildb.route_kinds',
141
+ routeKindsSection([
142
+ [1, 0, '/api/search'],
143
+ [1, 0, '/api/items/:id'],
144
+ ]),
145
+ );
146
+ const routes = parseRouteKinds(wasm);
147
+
148
+ expect(routeKindForRequest(routes, 'POST', '/api/search?q=x')).toBe(DbFunctionKind.Query);
149
+ expect(routeKindForRequest(routes, 'POST', '/api/items/42')).toBe(DbFunctionKind.Query);
150
+ expect(routeKindForRequest(routes, 'GET', '/api/search')).toBeNull();
151
+ expect(routeKindForRequest(routes, 'POST', '/api/items')).toBeNull();
152
+ });
153
+
154
+ it('ignores malformed route-kind metadata like the edge method clamp fallback', () => {
155
+ expect(
156
+ parseRouteKinds(wasmWithSection('toildb.route_kinds', Buffer.from([1, 0, 1, 0, 1]))),
157
+ ).toEqual([]);
158
+ expect(
159
+ parseRouteKinds(
160
+ wasmWithSection('toildb.route_kinds', routeKindsSection([[1, 0, 'relative']])),
161
+ ),
162
+ ).toEqual([]);
163
+ expect(
164
+ parseRouteKinds(
165
+ wasmWithSection('toildb.route_kinds', routeKindsSection([[1, 0, '/snowman-☃']])),
166
+ ),
167
+ ).toEqual([]);
168
+
169
+ const invalidUtf8 = Buffer.concat([
170
+ Buffer.from([1, 0, 1, 0, 1, 0, 2, 0, 0, 0]),
171
+ Buffer.from([0xc3, 0x28]),
172
+ ]);
173
+ expect(parseRouteKinds(wasmWithSection('toildb.route_kinds', invalidUtf8))).toEqual([]);
174
+ });
175
+
176
+ it('rejects unknown catalog collections instead of minting arbitrary handles', () => {
177
+ const { imports, buf } = setup();
178
+ const [p, l] = put(buf, 0, 'App/missing');
179
+ buf.writeUInt32LE(0xdeadbeef, 16);
180
+ expect(imports['data.resolve_collection'](p, l, 16)).toBe(-1070);
181
+ expect(buf.readUInt32LE(16)).toBe(0xdeadbeef);
182
+ });
183
+
184
+ it('rejects a present but malformed catalog section', () => {
185
+ setDbCatalog(wasmWithSection('toildb.catalog', Buffer.from([0xff, 0x00, 0xde, 0xad])));
186
+ const { imports, buf } = setupRaw();
187
+ const [p, l] = put(buf, 0, 'App/users');
188
+
189
+ expect(imports['data.resolve_collection'](p, l, 16)).toBe(-1070);
190
+ });
191
+
192
+ it('parses catalog v2 fill policy and backend replication bytes', () => {
193
+ setDbCatalog(wasmWithSection('toildb.catalog', catalogSectionV2(7, 0)));
194
+ const { imports, buf, db } = setupRaw();
195
+ const h = resolve(imports, buf, 'App/users');
196
+
197
+ expect(db.handles[h]).toMatchObject({
198
+ name: 'App/users',
199
+ family: CollectionFamily.Record,
200
+ schemaVersion: 0x1234,
201
+ replication: 5,
202
+ placement: 0,
203
+ fillMaxWaitMs: 7,
204
+ fillAllowStale: false,
205
+ });
206
+ });
207
+
208
+ it('rejects malformed catalog v2 fill policy', () => {
209
+ setDbCatalog(wasmWithSection('toildb.catalog', catalogSectionV2(7, 2)));
210
+ const { imports, buf } = setupRaw();
211
+ const [p, l] = put(buf, 0, 'App/users');
212
+
213
+ expect(imports['data.resolve_collection'](p, l, 16)).toBe(-1070);
214
+ });
215
+
216
+ it('rejects handles used with the wrong collection family', () => {
217
+ const { imports, buf } = setup();
218
+ const users = resolve(imports, buf, 'App/users');
219
+ const likes = resolve(imports, buf, 'App/likes');
220
+ const [kPtr, kLen] = put(buf, 32, 'k');
221
+ const [vPtr, vLen] = put(buf, 48, 'v');
222
+
223
+ expect(imports['data.counter_add'](users, kPtr, kLen, 1, 0)).toBe(-1010);
224
+ expect(imports['data.create'](likes, kPtr, kLen, vPtr, vLen, 0)).toBe(-1010);
225
+ });
226
+
227
+ it('query-kind dispatch denies writes and write_allowed is false', () => {
228
+ const { imports, buf, db } = setup();
229
+ db.functionKind = DbFunctionKind.Query;
230
+ const h = resolve(imports, buf, 'App/users');
231
+ const [kPtr, kLen] = put(buf, 32, 'u1');
232
+ const [vPtr, vLen] = put(buf, 48, 'hello');
233
+
234
+ expect(imports['data.write_allowed']()).toBe(0);
235
+ expect(imports['data.get'](h, kPtr, kLen)).toBe(-2);
236
+ expect(imports['data.create'](h, kPtr, kLen, vPtr, vLen, 0)).toBe(-1011);
237
+ });
238
+
239
+ it('query/action dispatch deny scan-class imports', () => {
240
+ const { imports, buf, db } = setup();
241
+ const feed = resolve(imports, buf, 'App/feed');
242
+ const rooms = resolve(imports, buf, 'App/rooms');
243
+ const [kPtr, kLen] = put(buf, 32, 'room1');
244
+ const [vPtr, vLen] = put(buf, 48, 'ev1');
245
+ const [mPtr, mLen] = put(buf, 64, 'alice');
246
+
247
+ expect(imports['data.append'](feed, kPtr, kLen, vPtr, vLen, 0)).toBe(0);
248
+ expect(imports['data.membership_add'](rooms, kPtr, kLen, mPtr, mLen, 0)).toBe(0);
249
+
250
+ db.functionKind = DbFunctionKind.Query;
251
+ expect(imports['data.latest'](feed, kPtr, kLen, 10)).toBe(-1011);
252
+ expect(imports['data.membership_list'](rooms, kPtr, kLen, 10)).toBe(-1011);
253
+
254
+ db.functionKind = DbFunctionKind.Action;
255
+ expect(imports['data.latest'](feed, kPtr, kLen, 10)).toBe(-1011);
256
+ expect(imports['data.membership_list'](rooms, kPtr, kLen, 10)).toBe(-1011);
257
+
258
+ db.functionKind = DbFunctionKind.Job;
259
+ expect(imports['data.latest'](feed, kPtr, kLen, 10)).toBeGreaterThan(0);
260
+ expect(imports['data.membership_list'](rooms, kPtr, kLen, 10)).toBeGreaterThan(0);
261
+ });
262
+
33
263
  it('resolve + create + get + take_result round-trips', () => {
34
264
  const { imports, buf } = setup();
35
265
  const h = resolve(imports, buf, 'App/users');
@@ -37,7 +267,7 @@ describe('toildb dev emulator (record family)', () => {
37
267
  const [vPtr, vLen] = put(buf, 48, 'hello');
38
268
 
39
269
  expect(imports['data.create'](h, kPtr, kLen, vPtr, vLen, 0)).toBe(0);
40
- expect(imports['data.create'](h, kPtr, kLen, vPtr, vLen, 0)).toBe(-1000); // AlreadyExists
270
+ expect(imports['data.create'](h, kPtr, kLen, vPtr, vLen, 0)).toBe(-1003); // AlreadyExists
41
271
  expect(imports['data.exists'](h, kPtr, kLen)).toBe(1);
42
272
 
43
273
  expect(imports['data.get'](h, kPtr, kLen)).toBe(5);
@@ -59,12 +289,33 @@ describe('toildb dev emulator (record family)', () => {
59
289
  expect(buf.toString('utf8', 128, 134)).toBe('world!');
60
290
  });
61
291
 
292
+ it('record patch idempotency replays and rejects request mismatch', () => {
293
+ const { imports, buf } = setup();
294
+ const h = resolve(imports, buf, 'App/users');
295
+ const [kPtr, kLen] = put(buf, 32, 'u1');
296
+ const [vPtr, vLen] = put(buf, 48, 'hello');
297
+ const [idemPtr] = put(buf, 64, '1234567890abcdef');
298
+ imports['data.create'](h, kPtr, kLen, vPtr, vLen, 0);
299
+
300
+ const [pPtr, pLen] = put(buf, 96, 'world!');
301
+ expect(imports['data.patch'](h, kPtr, kLen, pPtr, pLen, idemPtr)).toBe(6);
302
+ expect(imports['data.patch'](h, kPtr, kLen, pPtr, pLen, idemPtr)).toBe(6);
303
+ expect(imports['data.take_result'](128, 64)).toBe(6);
304
+ expect(buf.toString('utf8', 128, 134)).toBe('world!');
305
+
306
+ const [otherPtr, otherLen] = put(buf, 160, 'again!');
307
+ expect(imports['data.patch'](h, kPtr, kLen, otherPtr, otherLen, idemPtr)).toBe(-1004);
308
+ expect(imports['data.get'](h, kPtr, kLen)).toBe(6);
309
+ expect(imports['data.take_result'](192, 64)).toBe(6);
310
+ expect(buf.toString('utf8', 192, 198)).toBe('world!');
311
+ });
312
+
62
313
  it('patch on a missing key is NotFound', () => {
63
314
  const { imports, buf } = setup();
64
315
  const h = resolve(imports, buf, 'App/users');
65
316
  const [kPtr, kLen] = put(buf, 32, 'ghost');
66
317
  const [pPtr, pLen] = put(buf, 48, 'x');
67
- expect(imports['data.patch'](h, kPtr, kLen, pPtr, pLen, 0)).toBe(-1000);
318
+ expect(imports['data.patch'](h, kPtr, kLen, pPtr, pLen, 0)).toBe(-2); // NotFound -> ABSENT
68
319
  });
69
320
 
70
321
  it('consume-once get_delete deletes exactly once', () => {
@@ -81,6 +332,24 @@ describe('toildb dev emulator (record family)', () => {
81
332
  expect(imports['data.get_delete'](h, kPtr, kLen, 0)).toBe(-2);
82
333
  });
83
334
 
335
+ it('record get_delete idempotency replays the consumed value', () => {
336
+ const { imports, buf } = setup();
337
+ const h = resolve(imports, buf, 'App/challenges');
338
+ const [kPtr, kLen] = put(buf, 32, 'chal');
339
+ const [vPtr, vLen] = put(buf, 48, 'nonce');
340
+ const [idemPtr] = put(buf, 80, 'consume-idem-001');
341
+ imports['data.create'](h, kPtr, kLen, vPtr, vLen, 0);
342
+
343
+ expect(imports['data.get_delete'](h, kPtr, kLen, idemPtr)).toBe(5);
344
+ expect(imports['data.take_result'](96, 64)).toBe(5);
345
+ expect(buf.toString('utf8', 96, 101)).toBe('nonce');
346
+
347
+ expect(imports['data.get_delete'](h, kPtr, kLen, idemPtr)).toBe(5);
348
+ expect(imports['data.take_result'](128, 64)).toBe(5);
349
+ expect(buf.toString('utf8', 128, 133)).toBe('nonce');
350
+ expect(imports['data.get'](h, kPtr, kLen)).toBe(-2);
351
+ });
352
+
84
353
  it('absent / invalid handle / buffer-too-small return the edge codes', () => {
85
354
  const { imports, buf } = setup();
86
355
  const h = resolve(imports, buf, 'App/users');
@@ -116,6 +385,19 @@ describe('toildb dev emulator (record family)', () => {
116
385
  expect(buf.toString('utf8', 200, 206)).toBe('user_1');
117
386
  });
118
387
 
388
+ it('unique claim: same owner must replay only with the same idempotency key', () => {
389
+ const { imports, buf } = setup();
390
+ const h = resolve(imports, buf, 'App/usernames');
391
+ const [kPtr, kLen] = put(buf, 32, 'grace');
392
+ const [u1Ptr, u1Len] = put(buf, 48, 'user_1');
393
+ const [idem1Ptr] = put(buf, 80, 'idem-claim-0001');
394
+ const [idem2Ptr] = put(buf, 112, 'idem-claim-0002');
395
+
396
+ expect(imports['data.unique_claim'](h, kPtr, kLen, u1Ptr, u1Len, idem1Ptr)).toBe(0);
397
+ expect(imports['data.unique_claim'](h, kPtr, kLen, u1Ptr, u1Len, idem1Ptr)).toBe(2);
398
+ expect(imports['data.unique_claim'](h, kPtr, kLen, u1Ptr, u1Len, idem2Ptr)).toBe(-1004);
399
+ });
400
+
119
401
  it('unique release: only the owner may release', () => {
120
402
  const { imports, buf } = setup();
121
403
  const h = resolve(imports, buf, 'App/usernames');
@@ -124,7 +406,7 @@ describe('toildb dev emulator (record family)', () => {
124
406
  const [u2Ptr, u2Len] = put(buf, 64, 'user_2');
125
407
  imports['data.unique_claim'](h, kPtr, kLen, u1Ptr, u1Len, 0);
126
408
 
127
- expect(imports['data.unique_release'](h, kPtr, kLen, u2Ptr, u2Len, 0)).toBe(-1000); // not owner
409
+ expect(imports['data.unique_release'](h, kPtr, kLen, u2Ptr, u2Len, 0)).toBe(-1004); // not owner
128
410
  expect(imports['data.unique_release'](h, kPtr, kLen, u1Ptr, u1Len, 0)).toBe(0); // owner releases
129
411
  expect(imports['data.unique_lookup'](h, kPtr, kLen)).toBe(-2); // gone
130
412
  });
@@ -162,6 +444,7 @@ describe('toildb dev emulator (record family)', () => {
162
444
  got.push(null);
163
445
  continue;
164
446
  }
447
+ p += 4; // per-item schema_version (0 in dev)
165
448
  const len = buf.readUInt32LE(p);
166
449
  p += 4;
167
450
  got.push(buf.toString('utf8', p, p + len));
@@ -194,6 +477,7 @@ describe('toildb dev emulator (record family)', () => {
194
477
  expect(count).toBe(2);
195
478
  const out: string[] = [];
196
479
  for (let i = 0; i < count; i++) {
480
+ off += 4; // per-item schema_version (0 in dev)
197
481
  const len = buf.readUInt32LE(off);
198
482
  off += 4;
199
483
  out.push(buf.toString('utf8', off, off + len));
@@ -250,6 +534,14 @@ describe('toildb dev emulator (record family)', () => {
250
534
  imports['data.take_result'](72, 8);
251
535
  expect(buf.readBigInt64LE(72)).toBe(3n);
252
536
 
537
+ const [idemPtr] = put(buf, 96, 'counter-idem-001');
538
+ expect(imports['data.counter_add'](h, kPtr, kLen, 7, idemPtr)).toBe(0);
539
+ expect(imports['data.counter_add'](h, kPtr, kLen, 7, idemPtr)).toBe(0);
540
+ expect(imports['data.counter_add'](h, kPtr, kLen, 8, idemPtr)).toBe(-1004);
541
+ expect(imports['data.counter_get'](h, kPtr, kLen)).toBe(8);
542
+ imports['data.take_result'](104, 8);
543
+ expect(buf.readBigInt64LE(104)).toBe(10n);
544
+
253
545
  // invalid handle still rejected
254
546
  expect(imports['data.counter_add'](999, kPtr, kLen, 1, 0)).toBe(-1001);
255
547
  });
@@ -274,6 +566,7 @@ describe('toildb dev emulator (record family)', () => {
274
566
  expect(count).toBe(2);
275
567
  const out: string[] = [];
276
568
  for (let i = 0; i < count; i++) {
569
+ off += 4; // per-item schema_version (0 in dev)
277
570
  const len = buf.readUInt32LE(off);
278
571
  off += 4;
279
572
  out.push(buf.toString('utf8', off, off + len));
@@ -301,6 +594,39 @@ describe('toildb dev emulator (record family)', () => {
301
594
  // the same logical key in another collection is absent.
302
595
  expect(imports['data.get'](posts, kPtr, kLen)).toBe(-2);
303
596
  });
597
+
598
+ it('append_once dedups on eventId; enqueue replaces an existing record', () => {
599
+ const { imports, buf } = setup();
600
+ const feed = resolve(imports, buf, 'App/feed');
601
+ const [kPtr, kLen] = put(buf, 32, 'room1');
602
+ const [idPtr, idLen] = put(buf, 64, 'evt-1');
603
+ const [evPtr, evLen] = put(buf, 96, 'hello');
604
+ // first appendOnce appends (1); the same id is a no-op (0); a new id appends (1).
605
+ expect(imports['data.append_once'](feed, kPtr, kLen, idPtr, idLen, evPtr, evLen)).toBe(1);
606
+ expect(imports['data.append_once'](feed, kPtr, kLen, idPtr, idLen, evPtr, evLen)).toBe(0);
607
+ const [id2P, id2L] = put(buf, 128, 'evt-2');
608
+ expect(imports['data.append_once'](feed, kPtr, kLen, id2P, id2L, evPtr, evLen)).toBe(1);
609
+ const [appendIdemPtr] = put(buf, 144, 'append-idem-0001');
610
+ expect(imports['data.append'](feed, kPtr, kLen, evPtr, evLen, appendIdemPtr)).toBe(0);
611
+ expect(imports['data.append'](feed, kPtr, kLen, evPtr, evLen, appendIdemPtr)).toBe(0);
612
+ // latest frames exactly 2 events (the duplicate did not double-append).
613
+ const total = imports['data.latest'](feed, kPtr, kLen, 10);
614
+ expect(total).toBeGreaterThan(0);
615
+ imports['data.take_result'](512, total);
616
+ expect(buf.readUInt32LE(512)).toBe(3);
617
+
618
+ // enqueue: absent -> ABSENT (-2); after create -> replaces (0); get sees the new value.
619
+ const docs = resolve(imports, buf, 'App/docs');
620
+ const [dkP, dkL] = put(buf, 160, 'doc1');
621
+ const [v1P, v1L] = put(buf, 192, 'AAAA');
622
+ expect(imports['data.enqueue'](docs, dkP, dkL, v1P, v1L, 0)).toBe(-2);
623
+ expect(imports['data.create'](docs, dkP, dkL, v1P, v1L, 0)).toBe(0);
624
+ const [v2P, v2L] = put(buf, 224, 'BBBB');
625
+ expect(imports['data.enqueue'](docs, dkP, dkL, v2P, v2L, 0)).toBe(0);
626
+ expect(imports['data.get'](docs, dkP, dkL)).toBe(4);
627
+ imports['data.take_result'](256, 4);
628
+ expect(buf.toString('utf8', 256, 260)).toBe('BBBB');
629
+ });
304
630
  });
305
631
 
306
632
  type Imports = Record<string, (...args: number[]) => number>;
@@ -332,9 +658,19 @@ describe('toildb dev emulator (capacity family)', () => {
332
658
  expect(id > 0n).toBe(true);
333
659
  expect(avail(imports, buf, h, kPtr, kLen)).toBe(7n);
334
660
 
661
+ const [idemPtr] = put(buf, 64, 'reserve-idem-001');
662
+ expect(imports['data.capacity_reserve'](h, kPtr, kLen, 2, 60000, idemPtr)).toBe(8);
663
+ expect(imports['data.take_result'](520, 16)).toBe(8);
664
+ const idemId = buf.readBigUInt64LE(520);
665
+ expect(imports['data.capacity_reserve'](h, kPtr, kLen, 2, 60000, idemPtr)).toBe(8);
666
+ expect(imports['data.take_result'](528, 16)).toBe(8);
667
+ expect(buf.readBigUInt64LE(528)).toBe(idemId);
668
+ expect(imports['data.capacity_reserve'](h, kPtr, kLen, 1, 60000, idemPtr)).toBe(-1004);
669
+ expect(avail(imports, buf, h, kPtr, kLen)).toBe(5n);
670
+
335
671
  // cancel returns the hold -> back to 10; a double-cancel is a no-op.
336
672
  expect(imports['data.capacity_cancel'](h, kPtr, kLen, Number(id), 0)).toBe(1);
337
- expect(avail(imports, buf, h, kPtr, kLen)).toBe(10n);
673
+ expect(avail(imports, buf, h, kPtr, kLen)).toBe(8n);
338
674
  expect(imports['data.capacity_cancel'](h, kPtr, kLen, Number(id), 0)).toBe(0);
339
675
 
340
676
  // reserve 4 then confirm -> a permanent consume; available holds at 6.
@@ -342,10 +678,10 @@ describe('toildb dev emulator (capacity family)', () => {
342
678
  imports['data.take_result'](512, 16);
343
679
  const id2 = Number(buf.readBigUInt64LE(512));
344
680
  expect(imports['data.capacity_confirm'](h, kPtr, kLen, id2, 0)).toBe(1);
345
- expect(avail(imports, buf, h, kPtr, kLen)).toBe(6n);
346
- // a confirmed hold cannot be cancelled nor re-confirmed.
681
+ expect(avail(imports, buf, h, kPtr, kLen)).toBe(4n);
682
+ // a confirmed sale cannot be cancelled (0); re-confirm is idempotent (1).
347
683
  expect(imports['data.capacity_cancel'](h, kPtr, kLen, id2, 0)).toBe(0);
348
- expect(imports['data.capacity_confirm'](h, kPtr, kLen, id2, 0)).toBe(0);
684
+ expect(imports['data.capacity_confirm'](h, kPtr, kLen, id2, 0)).toBe(1);
349
685
  });
350
686
 
351
687
  it('never oversells (a reserve beyond available is refused)', () => {
@@ -357,8 +693,168 @@ describe('toildb dev emulator (capacity family)', () => {
357
693
  // a hold for all 5 succeeds; a further hold for 1 is refused (-2 -> guest 0).
358
694
  expect(imports['data.capacity_reserve'](h, kPtr, kLen, 5, 60000, 0)).toBe(8);
359
695
  expect(imports['data.capacity_reserve'](h, kPtr, kLen, 1, 60000, 0)).toBe(-2);
360
- // a non-positive amount is refused, and an invalid handle is rejected.
361
- expect(imports['data.capacity_reserve'](h, kPtr, kLen, 0, 60000, 0)).toBe(-2);
696
+ // a non-positive amount is a typed error (BadAmount), invalid handle rejected.
697
+ expect(imports['data.capacity_reserve'](h, kPtr, kLen, 0, 60000, 0)).toBe(-1006);
362
698
  expect(imports['data.capacity_available'](999, kPtr, kLen)).toBe(-1001);
363
699
  });
700
+
701
+ it('does not cache an idempotent insufficient reserve forever', () => {
702
+ const { imports, buf } = setup();
703
+ const h = resolve(imports, buf, 'App/tickets');
704
+ const [kPtr, kLen] = put(buf, 32, 'late-restock');
705
+ const [idemPtr] = put(buf, 64, 'reserve-idem-001');
706
+
707
+ expect(imports['data.capacity_reserve'](h, kPtr, kLen, 1, 60000, idemPtr)).toBe(-2);
708
+ expect(imports['data.capacity_set_total'](h, kPtr, kLen, 1, 0)).toBe(0);
709
+ expect(imports['data.capacity_reserve'](h, kPtr, kLen, 1, 60000, idemPtr)).toBe(8);
710
+ });
711
+ });
712
+
713
+ describe('toildb dev emulator (migration + persistence)', () => {
714
+ const rsv = (imports: Imports): bigint =>
715
+ (imports['data.result_schema_version'] as () => bigint)();
716
+
717
+ it('stamps writes with the catalog schema_version and surfaces it on read', () => {
718
+ const { imports, buf } = setup();
719
+ __setDbCatalogForTests({ 'App/users': 0x1234 });
720
+ const h = resolve(imports, buf, 'App/users');
721
+ const [kPtr, kLen] = put(buf, 32, 'u1');
722
+ const [vPtr, vLen] = put(buf, 64, 'data');
723
+ imports['data.create'](h, kPtr, kLen, vPtr, vLen, 0);
724
+ expect(imports['data.get'](h, kPtr, kLen)).toBe(4);
725
+ expect(rsv(imports)).toBe(0x1234n); // the woven decoder dispatches on this
726
+ });
727
+
728
+ it('an evolved @data type leaves old rows stamped with the OLD version', () => {
729
+ const { imports, buf } = setup();
730
+ __setDbCatalogForTests({ 'App/users': 100 }); // version A
731
+ const h = resolve(imports, buf, 'App/users');
732
+ const [kPtr, kLen] = put(buf, 32, 'u1');
733
+ const [vPtr, vLen] = put(buf, 64, 'old');
734
+ imports['data.create'](h, kPtr, kLen, vPtr, vLen, 0);
735
+ // the @data type evolves + the wasm rebuilds -> the catalog version changes.
736
+ __setDbCatalogForTests({ 'App/users': 200 }); // version B
737
+ // a read of the existing row still reports the OLD version -> guest migrates it.
738
+ imports['data.get'](h, kPtr, kLen);
739
+ expect(rsv(imports)).toBe(100n);
740
+ // a NEW write stamps the current version.
741
+ const [k2, kl2] = put(buf, 96, 'u2');
742
+ imports['data.create'](h, k2, kl2, vPtr, vLen, 0);
743
+ imports['data.get'](h, k2, kl2);
744
+ expect(rsv(imports)).toBe(200n);
745
+ });
746
+
747
+ it('persists data + versions to disk and reloads them', () => {
748
+ const dir = mkdtempSync(join(tmpdir(), 'toildb-'));
749
+ const file = join(dir, 'devdata.json');
750
+ try {
751
+ const a = setup();
752
+ __setDbCatalogForTests({ 'App/users': 777 });
753
+ configureDbPersistence(file);
754
+ const h = resolve(a.imports, a.buf, 'App/users');
755
+ const [kPtr, kLen] = put(a.buf, 32, 'u1');
756
+ const [vPtr, vLen] = put(a.buf, 64, 'persisted');
757
+ a.imports['data.create'](h, kPtr, kLen, vPtr, vLen, 0);
758
+ persistDb();
759
+
760
+ // simulate a restart: wipe memory + catalog, then reload from disk.
761
+ __resetDbForTests();
762
+ configureDbPersistence(file);
763
+ const b = setup();
764
+ const h2 = resolve(b.imports, b.buf, 'App/users');
765
+ const [k2, kl2] = put(b.buf, 32, 'u1');
766
+ expect(b.imports['data.get'](h2, k2, kl2)).toBe(9); // "persisted" survived restart
767
+ expect(rsv(b.imports)).toBe(777n); // and so did its schema_version stamp
768
+ } finally {
769
+ rmSync(dir, { recursive: true, force: true });
770
+ }
771
+ });
772
+
773
+ it('persists counter idempotency across devserver restart', () => {
774
+ const dir = mkdtempSync(join(tmpdir(), 'toildb-'));
775
+ const file = join(dir, 'devdata.json');
776
+ try {
777
+ const a = setup();
778
+ configureDbPersistence(file);
779
+ const h = resolve(a.imports, a.buf, 'App/likes');
780
+ const [kPtr, kLen] = put(a.buf, 32, 'post-1');
781
+ const [idemPtr] = put(a.buf, 64, 'counter-idem-001');
782
+ expect(a.imports['data.counter_add'](h, kPtr, kLen, 7, idemPtr)).toBe(0);
783
+ persistDb();
784
+
785
+ __resetDbForTests();
786
+ configureDbPersistence(file);
787
+ const b = setup();
788
+ const h2 = resolve(b.imports, b.buf, 'App/likes');
789
+ const [k2, kl2] = put(b.buf, 32, 'post-1');
790
+ const [idem2] = put(b.buf, 64, 'counter-idem-001');
791
+ expect(b.imports['data.counter_add'](h2, k2, kl2, 7, idem2)).toBe(0);
792
+ expect(b.imports['data.counter_add'](h2, k2, kl2, 8, idem2)).toBe(-1004);
793
+ expect(b.imports['data.counter_get'](h2, k2, kl2)).toBe(8);
794
+ expect(b.imports['data.take_result'](96, 8)).toBe(8);
795
+ expect(b.buf.readBigInt64LE(96)).toBe(7n);
796
+ } finally {
797
+ rmSync(dir, { recursive: true, force: true });
798
+ }
799
+ });
800
+
801
+ it('persists unique claim idempotency across devserver restart', () => {
802
+ const dir = mkdtempSync(join(tmpdir(), 'toildb-'));
803
+ const file = join(dir, 'devdata.json');
804
+ try {
805
+ const a = setup();
806
+ configureDbPersistence(file);
807
+ const h = resolve(a.imports, a.buf, 'App/usernames');
808
+ const [kPtr, kLen] = put(a.buf, 32, 'hopper');
809
+ const [u1Ptr, u1Len] = put(a.buf, 48, 'user_1');
810
+ const [idemPtr] = put(a.buf, 80, 'idem-claim-0001');
811
+ expect(a.imports['data.unique_claim'](h, kPtr, kLen, u1Ptr, u1Len, idemPtr)).toBe(0);
812
+ persistDb();
813
+
814
+ __resetDbForTests();
815
+ configureDbPersistence(file);
816
+ const b = setup();
817
+ const h2 = resolve(b.imports, b.buf, 'App/usernames');
818
+ const [k2, kl2] = put(b.buf, 32, 'hopper');
819
+ const [u2, u2l] = put(b.buf, 48, 'user_1');
820
+ const [sameIdem] = put(b.buf, 80, 'idem-claim-0001');
821
+ const [otherIdem] = put(b.buf, 112, 'idem-claim-0002');
822
+ expect(b.imports['data.unique_claim'](h2, k2, kl2, u2, u2l, sameIdem)).toBe(2);
823
+ expect(b.imports['data.unique_claim'](h2, k2, kl2, u2, u2l, otherIdem)).toBe(-1004);
824
+ } finally {
825
+ rmSync(dir, { recursive: true, force: true });
826
+ }
827
+ });
828
+
829
+ it('replays idempotent capacity reservations across devserver restart', () => {
830
+ const dir = mkdtempSync(join(tmpdir(), 'toildb-'));
831
+ const file = join(dir, 'devdata.json');
832
+ try {
833
+ const a = setup();
834
+ configureDbPersistence(file);
835
+ const h = resolve(a.imports, a.buf, 'App/seats');
836
+ const [kPtr, kLen] = put(a.buf, 32, 'showB');
837
+ const [idemPtr] = put(a.buf, 64, 'reserve-idem-001');
838
+ expect(a.imports['data.capacity_set_total'](h, kPtr, kLen, 3, 0)).toBe(0);
839
+ expect(a.imports['data.capacity_reserve'](h, kPtr, kLen, 2, 60000, idemPtr)).toBe(8);
840
+ expect(a.imports['data.take_result'](96, 16)).toBe(8);
841
+ const firstId = a.buf.readBigUInt64LE(96);
842
+ persistDb();
843
+
844
+ __resetDbForTests();
845
+ configureDbPersistence(file);
846
+ const b = setup();
847
+ const h2 = resolve(b.imports, b.buf, 'App/seats');
848
+ const [k2, kl2] = put(b.buf, 32, 'showB');
849
+ const [idem2] = put(b.buf, 64, 'reserve-idem-001');
850
+ expect(b.imports['data.capacity_reserve'](h2, k2, kl2, 2, 60000, idem2)).toBe(8);
851
+ expect(b.imports['data.take_result'](96, 16)).toBe(8);
852
+ expect(b.buf.readBigUInt64LE(96)).toBe(firstId);
853
+ expect(b.imports['data.capacity_available'](h2, k2, kl2)).toBe(8);
854
+ expect(b.imports['data.take_result'](112, 16)).toBe(8);
855
+ expect(b.buf.readBigInt64LE(112)).toBe(1n);
856
+ } finally {
857
+ rmSync(dir, { recursive: true, force: true });
858
+ }
859
+ });
364
860
  });
@@ -10,7 +10,7 @@ import { describe, expect, it } from 'vitest';
10
10
 
11
11
  import { ml_kem768 } from '@dacely/noble-post-quantum/ml-kem.js';
12
12
 
13
- import { buildCryptoImports, freshCryptoState } from '../src/devserver/crypto.js';
13
+ import { buildCryptoImports, freshCryptoState } from '../src/devserver/runtime/crypto.js';
14
14
 
15
15
  type Ref = { memory: WebAssembly.Memory | null };
16
16
 
@@ -1,6 +1,10 @@
1
1
  import { afterEach, describe, expect, it, vi } from 'vitest';
2
2
 
3
- import { buildHostImports, freshDispatchState, type MemoryRef } from '../src/devserver/host.js';
3
+ import {
4
+ buildHostImports,
5
+ freshDispatchState,
6
+ type MemoryRef,
7
+ } from '../src/devserver/runtime/host.js';
4
8
 
5
9
  /**
6
10
  * The dev host warns (once) when the guest reads a framework auth secret that is unset, since the