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
@@ -5,16 +5,34 @@ import { join } from 'node:path';
5
5
  import { afterEach, describe, expect, it } from 'vitest';
6
6
 
7
7
  import {
8
+ CollectionFamily,
9
+ DbFunctionKind,
8
10
  __resetDbForTests,
9
11
  __setDbCatalogForTests,
10
12
  buildDatabaseImports,
11
13
  configureDbPersistence,
12
14
  freshDbState,
13
15
  persistDb,
16
+ setDbCatalog,
14
17
  } from '../src/devserver/db/index.js';
18
+ import { parseRouteKinds, routeKindForRequest } from '../src/devserver/db/routeKinds.js';
15
19
  import type { MemoryRef } from '../src/devserver/runtime/host.js';
16
20
 
17
- function setup() {
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() {
18
36
  const memory = new WebAssembly.Memory({ initial: 1 });
19
37
  const ref: MemoryRef = { memory };
20
38
  const db = freshDbState();
@@ -23,6 +41,11 @@ function setup() {
23
41
  return { ref, db, imports, buf };
24
42
  }
25
43
 
44
+ function setup() {
45
+ __setDbCatalogForTests(DEFAULT_CATALOG);
46
+ return setupRaw();
47
+ }
48
+
26
49
  /** Write bytes at `offset`, returning the `[ptr, len]` pair the imports expect. */
27
50
  function put(buf: Buffer, offset: number, data: string): [number, number] {
28
51
  const b = Buffer.from(data);
@@ -30,17 +53,213 @@ function put(buf: Buffer, offset: number, data: string): [number, number] {
30
53
  return [offset, b.length];
31
54
  }
32
55
 
33
- 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 {
34
61
  const [p, l] = put(buf, 0, name);
35
62
  expect(imports['data.resolve_collection'](p, l, 16)).toBe(0);
36
63
  return buf.readUInt32LE(16);
37
64
  }
38
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
+
39
133
  afterEach(() => {
40
134
  __resetDbForTests();
41
135
  });
42
136
 
43
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
+
44
263
  it('resolve + create + get + take_result round-trips', () => {
45
264
  const { imports, buf } = setup();
46
265
  const h = resolve(imports, buf, 'App/users');
@@ -70,6 +289,27 @@ describe('toildb dev emulator (record family)', () => {
70
289
  expect(buf.toString('utf8', 128, 134)).toBe('world!');
71
290
  });
72
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
+
73
313
  it('patch on a missing key is NotFound', () => {
74
314
  const { imports, buf } = setup();
75
315
  const h = resolve(imports, buf, 'App/users');
@@ -92,6 +332,24 @@ describe('toildb dev emulator (record family)', () => {
92
332
  expect(imports['data.get_delete'](h, kPtr, kLen, 0)).toBe(-2);
93
333
  });
94
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
+
95
353
  it('absent / invalid handle / buffer-too-small return the edge codes', () => {
96
354
  const { imports, buf } = setup();
97
355
  const h = resolve(imports, buf, 'App/users');
@@ -127,6 +385,19 @@ describe('toildb dev emulator (record family)', () => {
127
385
  expect(buf.toString('utf8', 200, 206)).toBe('user_1');
128
386
  });
129
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
+
130
401
  it('unique release: only the owner may release', () => {
131
402
  const { imports, buf } = setup();
132
403
  const h = resolve(imports, buf, 'App/usernames');
@@ -263,6 +534,14 @@ describe('toildb dev emulator (record family)', () => {
263
534
  imports['data.take_result'](72, 8);
264
535
  expect(buf.readBigInt64LE(72)).toBe(3n);
265
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
+
266
545
  // invalid handle still rejected
267
546
  expect(imports['data.counter_add'](999, kPtr, kLen, 1, 0)).toBe(-1001);
268
547
  });
@@ -327,11 +606,14 @@ describe('toildb dev emulator (record family)', () => {
327
606
  expect(imports['data.append_once'](feed, kPtr, kLen, idPtr, idLen, evPtr, evLen)).toBe(0);
328
607
  const [id2P, id2L] = put(buf, 128, 'evt-2');
329
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);
330
612
  // latest frames exactly 2 events (the duplicate did not double-append).
331
613
  const total = imports['data.latest'](feed, kPtr, kLen, 10);
332
614
  expect(total).toBeGreaterThan(0);
333
615
  imports['data.take_result'](512, total);
334
- expect(buf.readUInt32LE(512)).toBe(2);
616
+ expect(buf.readUInt32LE(512)).toBe(3);
335
617
 
336
618
  // enqueue: absent -> ABSENT (-2); after create -> replaces (0); get sees the new value.
337
619
  const docs = resolve(imports, buf, 'App/docs');
@@ -376,9 +658,19 @@ describe('toildb dev emulator (capacity family)', () => {
376
658
  expect(id > 0n).toBe(true);
377
659
  expect(avail(imports, buf, h, kPtr, kLen)).toBe(7n);
378
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
+
379
671
  // cancel returns the hold -> back to 10; a double-cancel is a no-op.
380
672
  expect(imports['data.capacity_cancel'](h, kPtr, kLen, Number(id), 0)).toBe(1);
381
- expect(avail(imports, buf, h, kPtr, kLen)).toBe(10n);
673
+ expect(avail(imports, buf, h, kPtr, kLen)).toBe(8n);
382
674
  expect(imports['data.capacity_cancel'](h, kPtr, kLen, Number(id), 0)).toBe(0);
383
675
 
384
676
  // reserve 4 then confirm -> a permanent consume; available holds at 6.
@@ -386,7 +678,7 @@ describe('toildb dev emulator (capacity family)', () => {
386
678
  imports['data.take_result'](512, 16);
387
679
  const id2 = Number(buf.readBigUInt64LE(512));
388
680
  expect(imports['data.capacity_confirm'](h, kPtr, kLen, id2, 0)).toBe(1);
389
- expect(avail(imports, buf, h, kPtr, kLen)).toBe(6n);
681
+ expect(avail(imports, buf, h, kPtr, kLen)).toBe(4n);
390
682
  // a confirmed sale cannot be cancelled (0); re-confirm is idempotent (1).
391
683
  expect(imports['data.capacity_cancel'](h, kPtr, kLen, id2, 0)).toBe(0);
392
684
  expect(imports['data.capacity_confirm'](h, kPtr, kLen, id2, 0)).toBe(1);
@@ -405,6 +697,17 @@ describe('toildb dev emulator (capacity family)', () => {
405
697
  expect(imports['data.capacity_reserve'](h, kPtr, kLen, 0, 60000, 0)).toBe(-1006);
406
698
  expect(imports['data.capacity_available'](999, kPtr, kLen)).toBe(-1001);
407
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
+ });
408
711
  });
409
712
 
410
713
  describe('toildb dev emulator (migration + persistence)', () => {
@@ -466,4 +769,92 @@ describe('toildb dev emulator (migration + persistence)', () => {
466
769
  rmSync(dir, { recursive: true, force: true });
467
770
  }
468
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
+ });
469
860
  });
@@ -1,3 +1,4 @@
1
+ import { existsSync } from 'node:fs';
1
2
  import path from 'node:path';
2
3
 
3
4
  import { createServer } from 'vite';
@@ -14,7 +15,11 @@ import { createViteConfig } from '../src/compiler/vite';
14
15
 
15
16
  const EXAMPLE = path.resolve(__dirname, '../examples/basic');
16
17
 
17
- describe('email preview end-to-end (examples/basic)', () => {
18
+ // Drives a Vite server over examples/basic; skip where the example's deps are
19
+ // not installed (e.g. CI without the example set up).
20
+ describe.skipIf(!existsSync(path.join(EXAMPLE, 'node_modules')))(
21
+ 'email preview end-to-end (examples/basic)',
22
+ () => {
18
23
  it('lists Welcome and inlines its emails/styles/email.css; client/* alias resolves', async () => {
19
24
  const cfg = await loadConfig({ root: EXAMPLE });
20
25
  const items = listEmails(cfg);
@@ -0,0 +1,56 @@
1
+ // A minimal @daemon fixture for the dev daemon-emulation test. It declares the
2
+ // `daemon.*` / `mstore.*` host imports directly (so it needs no toiljs globals
3
+ // lib) and records its activity into the dev MemoryStore, which the test reads
4
+ // back through `devMemoryStore`. Compiled with `--targetMode cold` by the test.
5
+ //
6
+ // onStart() -> mstore.incr("started", 1) and stamps the lease epoch
7
+ // tick() @scheduled -> mstore.incr("tick:fast", 1) (1s interval)
8
+ // sixHourly() cron -> mstore.incr("tick:cron", 1) (0 */6 * * *)
9
+
10
+ // @ts-nocheck — this is AssemblyScript source compiled by toilscript, not TS.
11
+
12
+ @external("env", "mstore.incr")
13
+ declare function mstoreIncr(keyPtr: i32, keyLen: i32, delta: i64, ttlSecs: i32): i64;
14
+
15
+ @external("env", "daemon.is_leader")
16
+ declare function daemonIsLeader(): i32;
17
+
18
+ @external("env", "daemon.current_epoch")
19
+ declare function daemonCurrentEpoch(): i64;
20
+
21
+ @external("env", "daemon.task_count")
22
+ declare function daemonTaskCount(): i32;
23
+
24
+ // Bump the i64 counter stored at the (utf8) `key` by 1. The host reads the key
25
+ // bytes straight out of linear memory (handleless mstore, ttl in seconds).
26
+ function bump(key: string): void {
27
+ let bytes = String.UTF8.encode(key);
28
+ mstoreIncr(changetype<i32>(bytes), bytes.byteLength, 1, 0);
29
+ }
30
+
31
+ @daemon
32
+ class Jobs {
33
+ onStart(): void {
34
+ // Prove leader=true and that the epoch import is callable; record both so
35
+ // the test can assert the stubs from outside.
36
+ bump("started");
37
+ if (daemonIsLeader() == 1) bump("leader");
38
+ let epoch = daemonCurrentEpoch();
39
+ if (epoch >= 0) bump("epoch:nonneg");
40
+ if (daemonTaskCount() == 2) bump("taskcount:2");
41
+ }
42
+
43
+ @scheduled("1s")
44
+ tick(): void {
45
+ bump("tick:fast");
46
+ }
47
+
48
+ @scheduled("0 */6 * * *")
49
+ sixHourly(): void {
50
+ bump("tick:cron");
51
+ }
52
+ }
53
+
54
+ export function probe(): i32 {
55
+ return 1;
56
+ }
@@ -0,0 +1,17 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ /**
6
+ * Vitest global setup: generate `src/compiler/toil-docs.generated.ts` before the
7
+ * suite runs. `src/compiler/docs.ts` imports that module, but it is gitignored
8
+ * and only (re)created by the build's `gen:docs` step, so a fresh checkout that
9
+ * runs the tests without a prior build would otherwise fail to resolve it
10
+ * ("Cannot find module './toil-docs.generated.js'"). Running the same generator
11
+ * the npm scripts use keeps the tests self-contained. Runs once per vitest
12
+ * invocation, so it also covers `npx vitest` / watch mode.
13
+ */
14
+ export default function setup(): void {
15
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
16
+ execFileSync('node', ['scripts/gen-toil-docs.mjs'], { cwd: root, stdio: 'ignore' });
17
+ }