toiljs 0.0.69 → 0.0.70

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 (58) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/build/cli/.tsbuildinfo +1 -1
  3. package/build/client/.tsbuildinfo +1 -1
  4. package/build/client/rpc.js +10 -4
  5. package/build/client/stream/client.js +108 -5
  6. package/build/compiler/.tsbuildinfo +1 -1
  7. package/build/compiler/index.d.ts +2 -0
  8. package/build/compiler/index.js +282 -2
  9. package/build/compiler/toil-docs.generated.js +1 -1
  10. package/build/compiler/vite.js +8 -0
  11. package/build/devserver/.tsbuildinfo +1 -1
  12. package/build/devserver/daemon/host.d.ts +1 -7
  13. package/build/devserver/daemon/host.js +5 -59
  14. package/build/devserver/daemon/index.d.ts +1 -0
  15. package/build/devserver/daemon/index.js +17 -4
  16. package/build/devserver/db/database.js +1 -1
  17. package/build/devserver/db/routeKinds.d.ts +6 -0
  18. package/build/devserver/db/routeKinds.js +40 -0
  19. package/build/devserver/index.d.ts +0 -1
  20. package/build/devserver/index.js +0 -1
  21. package/build/devserver/runtime/module.d.ts +1 -0
  22. package/build/devserver/runtime/module.js +18 -2
  23. package/build/devserver/stream/index.js +4 -3
  24. package/build/devserver/wasm/surface.d.ts +2 -0
  25. package/build/devserver/wasm/surface.js +35 -4
  26. package/docs/streams.md +3 -4
  27. package/examples/basic/server/services/Stats.ts +11 -3
  28. package/examples/basic/server/services/remotes.ts +8 -2
  29. package/package.json +3 -2
  30. package/server/runtime/exports/index.ts +8 -1
  31. package/server/runtime/index.ts +1 -0
  32. package/server/runtime/rpc/Rpc.ts +66 -0
  33. package/src/client/rpc.ts +21 -12
  34. package/src/client/stream/client.ts +133 -5
  35. package/src/compiler/index.ts +352 -2
  36. package/src/compiler/toil-docs.generated.ts +1 -1
  37. package/src/compiler/vite.ts +16 -0
  38. package/src/devserver/daemon/host.ts +10 -110
  39. package/src/devserver/daemon/index.ts +19 -6
  40. package/src/devserver/db/database.ts +1 -1
  41. package/src/devserver/db/routeKinds.ts +44 -0
  42. package/src/devserver/index.ts +0 -1
  43. package/src/devserver/runtime/host.ts +3 -7
  44. package/src/devserver/runtime/module.ts +30 -4
  45. package/src/devserver/stream/index.ts +8 -4
  46. package/src/devserver/wasm/surface.ts +33 -4
  47. package/test/daemon-build.test.ts +53 -0
  48. package/test/daemon-catalog.test.ts +78 -3
  49. package/test/daemon-emulation.test.ts +27 -29
  50. package/test/devserver-database.test.ts +93 -0
  51. package/test/fixtures/bignum-wire/spec.ts +3 -5
  52. package/test/fixtures/daemon-app.ts +25 -21
  53. package/test/rpc-dispatch.test.ts +132 -0
  54. package/test/rpc-kinds.test.ts +18 -0
  55. package/test/rpc.test.ts +20 -4
  56. package/build/devserver/mstore/store.d.ts +0 -18
  57. package/build/devserver/mstore/store.js +0 -82
  58. package/src/devserver/mstore/store.ts +0 -121
@@ -98,8 +98,8 @@ export class DevStreamBox {
98
98
  * cold artifact, a missing `stream_dispatch`/`memory`, or a bad module throws. */
99
99
  static load(wasm: Buffer): DevStreamBox {
100
100
  const surface = parseSurface(wasm);
101
- if (surface === 'invalid' || surface.targetMode !== 'hot') {
102
- throw new Error('stream box requires a hot artifact with a valid toil.surface');
101
+ if (surface === 'invalid' || surface.targetMode !== 'hot' || !surface.flags.stream) {
102
+ throw new Error('stream box requires a hot stream artifact with a valid toil.surface');
103
103
  }
104
104
  const ref: { memory: WebAssembly.Memory | null } = { memory: null };
105
105
  const state = freshStreamBoxState();
@@ -192,7 +192,10 @@ export class DevStreamBox {
192
192
  }
193
193
 
194
194
  private static resolveStreamInfo(e: StreamExports): StreamInfo | null {
195
- if (typeof e.stream_info_offset !== 'function' || typeof e.stream_info_capacity !== 'function') {
195
+ if (
196
+ typeof e.stream_info_offset !== 'function' ||
197
+ typeof e.stream_info_capacity !== 'function'
198
+ ) {
196
199
  return null;
197
200
  }
198
201
  return { offset: e.stream_info_offset() >>> 0, cap: e.stream_info_capacity() >>> 0 };
@@ -277,7 +280,8 @@ export class DevStreamBox {
277
280
  dv.setUint16(f + 2, 0, true); // flags
278
281
  dv.setUint32(f + 4, n, true); // length
279
282
  dv.setUint32(f + 8, 0, true); // msg_seq
280
- if (n > 0) new Uint8Array(this.exports.memory.buffer, f + RING_FRAME_HEADER, n).set(inbound);
283
+ if (n > 0)
284
+ new Uint8Array(this.exports.memory.buffer, f + RING_FRAME_HEADER, n).set(inbound);
281
285
  dv.setUint32(base + RC_WRITE, w + frameLen, true);
282
286
  }
283
287
 
@@ -26,6 +26,20 @@ import { DataReader } from 'toiljs/io';
26
26
 
27
27
  import { customSection } from './sections.js';
28
28
 
29
+ export const SURFACE_FORMAT_VERSION = 1;
30
+ export const SURFACE_ABI_VERSION = 1;
31
+
32
+ const TARGET_HOT = 0;
33
+ const TARGET_COLD = 1;
34
+ const FLAG_REST = 1 << 0;
35
+ const FLAG_STREAM = 1 << 1;
36
+ const FLAG_DAEMON = 1 << 2;
37
+ const FLAG_SCHEDULED = 1 << 3;
38
+ const FLAG_DATABASE = 1 << 4;
39
+ const FLAG_RENDER = 1 << 5;
40
+ const FLAG_KNOWN_MASK =
41
+ FLAG_REST | FLAG_STREAM | FLAG_DAEMON | FLAG_SCHEDULED | FLAG_DATABASE | FLAG_RENDER;
42
+
29
43
  export interface SurfaceFlags {
30
44
  readonly rest: boolean;
31
45
  readonly stream: boolean;
@@ -57,16 +71,31 @@ export function parseSurface(wasm: Buffer): Surface | 'invalid' {
57
71
  if (sec === null) return 'invalid';
58
72
 
59
73
  const r = new DataReader(sec);
60
- r.readU16(); // format_version
61
- const targetMode = r.readU8() === 1 ? 'cold' : 'hot';
62
- r.readU8(); // reserved0
74
+ const version = r.readU16(); // format_version
75
+ if (!r.ok || version !== SURFACE_FORMAT_VERSION) return 'invalid';
76
+ const targetModeByte = r.readU8();
77
+ if (!r.ok || (targetModeByte !== TARGET_HOT && targetModeByte !== TARGET_COLD)) {
78
+ return 'invalid';
79
+ }
80
+ const targetMode = targetModeByte === TARGET_COLD ? 'cold' : 'hot';
81
+ const reserved0 = r.readU8(); // reserved0
82
+ if (!r.ok || reserved0 !== 0) return 'invalid';
63
83
  const f = r.readU32(); // surface_flags
84
+ if (!r.ok || (f & ~FLAG_KNOWN_MASK) !== 0) return 'invalid';
85
+ if ((f & FLAG_SCHEDULED) !== 0 && (f & FLAG_DAEMON) === 0) return 'invalid';
86
+ if (targetMode === 'hot' && (f & (FLAG_DAEMON | FLAG_SCHEDULED)) !== 0) {
87
+ return 'invalid';
88
+ }
89
+ if (targetMode === 'cold' && (f & (FLAG_REST | FLAG_STREAM | FLAG_RENDER)) !== 0) {
90
+ return 'invalid';
91
+ }
64
92
  const abiVersion = r.readU16();
93
+ if (!r.ok || abiVersion !== SURFACE_ABI_VERSION) return 'invalid';
65
94
  const buildId = r.readString();
66
95
  const fingerprint = r.readU32();
67
96
  const dataCoherenceHash = r.readU32(); // exactly THREE u32 after build_id
68
97
  const pairCoherenceHash = r.readU32();
69
- if (!r.ok) return 'invalid'; // PRESENT but corrupt => fail closed
98
+ if (!r.ok || r.remaining() !== 0) return 'invalid'; // PRESENT but corrupt => fail closed
70
99
  return {
71
100
  targetMode,
72
101
  flags: {
@@ -33,6 +33,7 @@ import { fileURLToPath } from 'node:url';
33
33
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
34
34
 
35
35
  import {
36
+ assertNoStreamInRequestTier,
36
37
  buildServer,
37
38
  serverArtifacts,
38
39
  splitSurfaceFiles,
@@ -183,6 +184,58 @@ describe('splitSurfaceFiles per-pass classification', () => {
183
184
  expect(split.request).toContain('server/both.ts');
184
185
  expect(split.stream).not.toContain('server/both.ts');
185
186
  });
187
+
188
+ it('exposes the actual @stream modules in streamModules (not @rest or shared helpers)', () => {
189
+ const rels = lay({
190
+ 'server/chat.ts': "@stream('chat')\nclass C {}\n",
191
+ 'server/api.ts': '@rest\nclass A {}\n',
192
+ 'server/util.ts': 'export function helper(): i32 { return 1; }\n',
193
+ });
194
+ const split = splitSurfaceFiles(tmp, rels);
195
+ expect(split.streamModules).toEqual(['server/chat.ts']);
196
+ });
197
+
198
+ it('REJECTS a value-import of a @stream module; allows clean/type-only/commented (audit #17/#7)', () => {
199
+ const rels = lay({
200
+ 'server/main.ts': "import { A } from './api';\n", // request-tier (shared) entry
201
+ 'server/api.ts': '@rest\nclass A {}\n',
202
+ 'server/chat.ts': "@stream('chat')\nclass C {}\n",
203
+ });
204
+ const check = () => assertNoStreamInRequestTier(tmp, splitSurfaceFiles(tmp, rels));
205
+
206
+ // main imports only the @rest surface -> no stream code leaks into release.wasm.
207
+ expect(check).not.toThrow();
208
+
209
+ // The footgun: a request-tier file value-imports the @stream module.
210
+ writeFileSync(join(tmp, 'server', 'main.ts'), "import { C } from './chat';\nvoid C;\n");
211
+ expect(check).toThrow(/@stream class would be compiled into the REQUEST tier/);
212
+
213
+ // A type-only import is erased and never compiles @stream code -> allowed.
214
+ writeFileSync(join(tmp, 'server', 'main.ts'), "import type { C } from './chat';\n");
215
+ expect(check).not.toThrow();
216
+
217
+ // A COMMENTED-OUT import is dead code and must not false-positive (audit #7).
218
+ writeFileSync(
219
+ join(tmp, 'server', 'main.ts'),
220
+ "// import { C } from './chat';\nimport { A } from './api';\n",
221
+ );
222
+ expect(check).not.toThrow();
223
+ });
224
+
225
+ it('catches a @stream reached transitively through a plain helper NOT in the surface list (audit #6)', () => {
226
+ // util.ts is an UNDECORATED helper -> not in serverEntryFiles, so production hands splitSurfaceFiles
227
+ // only the entries+surfaces. The filesystem-resolving BFS must still traverse util.ts to reach the
228
+ // @stream module behind it.
229
+ lay({
230
+ 'server/main.ts': "import './util';\n",
231
+ 'server/util.ts': "import { C } from './chat';\nexport const x = 1;\n",
232
+ 'server/chat.ts': "@stream('chat')\nclass C {}\n",
233
+ });
234
+ const surfacesOnly = ['server/main.ts', 'server/chat.ts']; // no plain util.ts, as in production
235
+ expect(() => assertNoStreamInRequestTier(tmp, splitSurfaceFiles(tmp, surfacesOnly))).toThrow(
236
+ /@stream class would be compiled into the REQUEST tier/,
237
+ );
238
+ });
186
239
  });
187
240
 
188
241
  // Needs the local toilscript dev build (with --targetMode) linked as a sibling
@@ -158,15 +158,17 @@ describe('parseDaemonCatalog (Part 5)', () => {
158
158
  // --- toil.surface (Part 5) -------------------------------------------------
159
159
 
160
160
  function buildSurfaceBytes(opts: {
161
- mode: 0 | 1;
161
+ mode: number;
162
162
  flags: number;
163
+ version?: number;
164
+ reserved0?: number;
163
165
  abi?: number;
164
166
  buildId?: string;
165
167
  }): Uint8Array {
166
168
  const w = new DataWriter();
167
- w.writeU16(1); // format_version
169
+ w.writeU16(opts.version ?? 1); // format_version
168
170
  w.writeU8(opts.mode); // target_mode
169
- w.writeU8(0); // reserved0
171
+ w.writeU8(opts.reserved0 ?? 0); // reserved0
170
172
  w.writeU32(opts.flags); // surface_flags
171
173
  w.writeU16(opts.abi ?? 1); // abi_version
172
174
  w.writeString(opts.buildId ?? ''); // build_id
@@ -188,6 +190,7 @@ describe('parseSurface (Part 5)', () => {
188
190
  expect(s.flags.daemon).toBe(true);
189
191
  expect(s.flags.scheduled).toBe(true);
190
192
  expect(s.flags.rest).toBe(false);
193
+ expect(s.abiVersion).toBe(1);
191
194
  expect(s.fingerprint).toBe(0xdeadbeef);
192
195
  expect(s.dataCoherenceHash).toBe(0x11111111);
193
196
  expect(s.pairCoherenceHash).toBe(0x22222222);
@@ -211,6 +214,78 @@ describe('parseSurface (Part 5)', () => {
211
214
  const truncated = full.subarray(0, full.length - 3); // chop a trailing hash
212
215
  expect(parseSurface(wasmWithSection('toil.surface', truncated))).toBe('invalid');
213
216
  });
217
+
218
+ it("fails closed: trailing bytes after toil.surface are 'invalid'", () => {
219
+ const full = Buffer.from(buildSurfaceBytes({ mode: 0, flags: 1 }));
220
+ const withGarbage = Buffer.concat([full, Buffer.from([0xff])]);
221
+ expect(parseSurface(wasmWithSection('toil.surface', withGarbage))).toBe('invalid');
222
+ });
223
+
224
+ it("fails closed: unsupported format version is 'invalid'", () => {
225
+ expect(
226
+ parseSurface(
227
+ wasmWithSection(
228
+ 'toil.surface',
229
+ buildSurfaceBytes({ version: 2, mode: 0, flags: 1 }),
230
+ ),
231
+ ),
232
+ ).toBe('invalid');
233
+ });
234
+
235
+ it("fails closed: unsupported abi_version is 'invalid'", () => {
236
+ expect(
237
+ parseSurface(
238
+ wasmWithSection('toil.surface', buildSurfaceBytes({ mode: 0, flags: 1, abi: 2 })),
239
+ ),
240
+ ).toBe('invalid');
241
+ });
242
+
243
+ it('fails closed: target_mode 2 is not silently treated as hot', () => {
244
+ expect(
245
+ parseSurface(wasmWithSection('toil.surface', buildSurfaceBytes({ mode: 2, flags: 1 }))),
246
+ ).toBe('invalid');
247
+ });
248
+
249
+ it("fails closed: nonzero reserved byte is 'invalid'", () => {
250
+ expect(
251
+ parseSurface(
252
+ wasmWithSection(
253
+ 'toil.surface',
254
+ buildSurfaceBytes({ mode: 0, flags: 1, reserved0: 1 }),
255
+ ),
256
+ ),
257
+ ).toBe('invalid');
258
+ });
259
+
260
+ it("fails closed: reserved surface flag bits are 'invalid'", () => {
261
+ expect(
262
+ parseSurface(
263
+ wasmWithSection('toil.surface', buildSurfaceBytes({ mode: 0, flags: 1 << 6 })),
264
+ ),
265
+ ).toBe('invalid');
266
+ });
267
+
268
+ it('fails closed: daemon flags are illegal on hot artifacts', () => {
269
+ expect(
270
+ parseSurface(
271
+ wasmWithSection('toil.surface', buildSurfaceBytes({ mode: 0, flags: 1 | 4 })),
272
+ ),
273
+ ).toBe('invalid');
274
+ });
275
+
276
+ it('fails closed: stream flags are illegal on cold artifacts', () => {
277
+ expect(
278
+ parseSurface(
279
+ wasmWithSection('toil.surface', buildSurfaceBytes({ mode: 1, flags: 4 | 2 })),
280
+ ),
281
+ ).toBe('invalid');
282
+ });
283
+
284
+ it('fails closed: scheduled requires daemon', () => {
285
+ expect(
286
+ parseSurface(wasmWithSection('toil.surface', buildSurfaceBytes({ mode: 1, flags: 8 }))),
287
+ ).toBe('invalid');
288
+ });
214
289
  });
215
290
 
216
291
  describe('customSection bounds-checking', () => {
@@ -10,9 +10,9 @@
10
10
  * - `daemon.is_leader()` / `current_epoch()` / `task_count()` stubs answer.
11
11
  * - the epoch bumps on a cold-artifact reload (the fencing token).
12
12
  *
13
- * The fixture records its activity into the shared dev MemoryStore (the real
14
- * `mstore.*` host import path), which the test reads back through
15
- * `devMemoryStore`. Interval ticks are driven with vitest fake timers.
13
+ * The fixture records its activity into resident daemon wasm memory and exports
14
+ * scalar accessors for the test. Interval ticks are driven with vitest fake
15
+ * timers.
16
16
  */
17
17
 
18
18
  import { spawnSync } from 'node:child_process';
@@ -25,7 +25,6 @@ import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest
25
25
 
26
26
  import { DaemonHost } from '../src/devserver/daemon/index.js';
27
27
  import type { ResolvedDaemonConfig } from '../src/devserver/daemon/host.js';
28
- import { devMemoryStore } from '../src/devserver/mstore/store.js';
29
28
 
30
29
  const here = dirname(fileURLToPath(import.meta.url));
31
30
  const FIXTURE = join(here, 'fixtures', 'daemon-app.ts');
@@ -50,7 +49,10 @@ function compileCold(src: string, outWasm: string): { ok: boolean; output: strin
50
49
  [LOCAL_TOILSCRIPT_BIN, src, '-o', outWasm, '--runtime', 'stub', '--targetMode', 'cold'],
51
50
  { encoding: 'utf8' },
52
51
  );
53
- return { ok: r.status === 0 && existsSync(outWasm), output: (r.stdout ?? '') + (r.stderr ?? '') };
52
+ return {
53
+ ok: r.status === 0 && existsSync(outWasm),
54
+ output: (r.stdout ?? '') + (r.stderr ?? ''),
55
+ };
54
56
  }
55
57
 
56
58
  let tmp: string;
@@ -72,13 +74,9 @@ afterAll(() => {
72
74
 
73
75
  afterEach(() => {
74
76
  vi.useRealTimers();
75
- devMemoryStore.__reset();
76
77
  });
77
78
 
78
- const counter = (key: string): number => {
79
- const v = devMemoryStore.get(key);
80
- return v === null ? 0 : Number(v.toString('utf8'));
81
- };
79
+ const counter = (host: DaemonHost, name: string): number => host.callI32Export(`${name}Count`) ?? 0;
82
80
 
83
81
  // Needs the local toilscript dev build (with --targetMode); skip where the
84
82
  // sibling repo is absent (e.g. CI, which has only the published dep).
@@ -86,9 +84,10 @@ describe.skipIf(!existsSync(LOCAL_TOILSCRIPT_BIN))('dev daemon emulation', () =>
86
84
  it('compiles the @daemon fixture to a cold artifact', () => {
87
85
  // Guard: every assertion below depends on the local toilscript link. A hard
88
86
  // failure here (rather than a silent skip) surfaces the cross-repo break.
89
- expect(existsSync(LOCAL_TOILSCRIPT_BIN), `local toilscript not found at ${LOCAL_TOILSCRIPT_BIN}`).toBe(
90
- true,
91
- );
87
+ expect(
88
+ existsSync(LOCAL_TOILSCRIPT_BIN),
89
+ `local toilscript not found at ${LOCAL_TOILSCRIPT_BIN}`,
90
+ ).toBe(true);
92
91
  expect(toilscriptAvailable, 'cold compile of the @daemon fixture failed').toBe(true);
93
92
  });
94
93
 
@@ -98,14 +97,14 @@ describe.skipIf(!existsSync(LOCAL_TOILSCRIPT_BIN))('dev daemon emulation', () =>
98
97
  try {
99
98
  expect(host.active).toBe(true);
100
99
  // onStart ran once -> "started" counter is exactly 1.
101
- expect(counter('started')).toBe(1);
100
+ expect(counter(host, 'started')).toBe(1);
102
101
  // The leader / epoch / task_count stubs all answered during onStart.
103
- expect(counter('leader')).toBe(1);
104
- expect(counter('epoch:nonneg')).toBe(1);
105
- expect(counter('taskcount:2')).toBe(1);
102
+ expect(counter(host, 'leader')).toBe(1);
103
+ expect(counter(host, 'epochNonneg')).toBe(1);
104
+ expect(counter(host, 'taskcount2')).toBe(1);
106
105
  // refresh() with no mtime change is a no-op -> daemon_start does NOT re-run.
107
106
  expect(host.refresh()).toBe(false);
108
- expect(counter('started')).toBe(1);
107
+ expect(counter(host, 'started')).toBe(1);
109
108
  } finally {
110
109
  host.close();
111
110
  }
@@ -116,13 +115,13 @@ describe.skipIf(!existsSync(LOCAL_TOILSCRIPT_BIN))('dev daemon emulation', () =>
116
115
  const host = new DaemonHost(coldWasm, DAEMON_CFG, 'all', () => {});
117
116
  host.refresh();
118
117
  try {
119
- expect(counter('tick:fast')).toBe(0); // not fired yet
118
+ expect(counter(host, 'tickFast')).toBe(0); // not fired yet
120
119
  vi.advanceTimersByTime(1000);
121
- expect(counter('tick:fast')).toBe(1); // one interval elapsed -> one tick
120
+ expect(counter(host, 'tickFast')).toBe(1); // one interval elapsed -> one tick
122
121
  vi.advanceTimersByTime(3000);
123
- expect(counter('tick:fast')).toBe(4); // three more ticks
122
+ expect(counter(host, 'tickFast')).toBe(4); // three more ticks
124
123
  // The cron task ("0 */6 * * *") must NOT have fired in 4 simulated seconds.
125
- expect(counter('tick:cron')).toBe(0);
124
+ expect(counter(host, 'tickCron')).toBe(0);
126
125
  } finally {
127
126
  host.close();
128
127
  }
@@ -135,13 +134,13 @@ describe.skipIf(!existsSync(LOCAL_TOILSCRIPT_BIN))('dev daemon emulation', () =>
135
134
  const host = new DaemonHost(coldWasm, DAEMON_CFG, 'all', () => {});
136
135
  host.refresh();
137
136
  try {
138
- expect(counter('tick:cron')).toBe(0);
137
+ expect(counter(host, 'tickCron')).toBe(0);
139
138
  // Advance to 06:00:00 (30s) -> the cron one-shot fires exactly once.
140
139
  vi.advanceTimersByTime(30_000);
141
- expect(counter('tick:cron')).toBe(1);
140
+ expect(counter(host, 'tickCron')).toBe(1);
142
141
  // It dispatched task_index 1 (sixHourly), NOT the interval task body.
143
142
  // (tick:fast also advanced on its own 1s timer; assert cron fired once.)
144
- expect(counter('tick:cron')).toBe(1);
143
+ expect(counter(host, 'tickCron')).toBe(1);
145
144
  } finally {
146
145
  host.close();
147
146
  }
@@ -152,7 +151,7 @@ describe.skipIf(!existsSync(LOCAL_TOILSCRIPT_BIN))('dev daemon emulation', () =>
152
151
  host.refresh();
153
152
  try {
154
153
  expect(host.active).toBe(false);
155
- expect(counter('started')).toBe(0);
154
+ expect(counter(host, 'started')).toBe(0);
156
155
  } finally {
157
156
  host.close();
158
157
  }
@@ -170,9 +169,8 @@ describe.skipIf(!existsSync(LOCAL_TOILSCRIPT_BIN))('dev daemon emulation', () =>
170
169
  const reloaded = host.refresh();
171
170
  expect(reloaded).toBe(true);
172
171
  expect(host.epoch()).toBe(e1 + 1n);
173
- // A reload is a full restart -> daemon_start ran again on fresh memory.
174
- // (devMemoryStore is shared/persistent, so "started" accumulates.)
175
- expect(counter('started')).toBe(2);
172
+ // A reload is a full restart -> the new resident daemon memory starts fresh.
173
+ expect(counter(host, 'started')).toBe(1);
176
174
  } finally {
177
175
  host.close();
178
176
  }
@@ -11,7 +11,9 @@ import {
11
11
  __setDbCatalogForTests,
12
12
  buildDatabaseImports,
13
13
  configureDbPersistence,
14
+ derivesForWrites,
14
15
  freshDbState,
16
+ parseDerives,
15
17
  persistDb,
16
18
  setDbCatalog,
17
19
  } from '../src/devserver/db/index.js';
@@ -92,6 +94,24 @@ function routeKindsSection(routes: readonly (readonly [number, number, string])[
92
94
  return Buffer.concat(chunks);
93
95
  }
94
96
 
97
+ function derivesSection(entries: readonly (readonly [number, string, string])[]): Buffer {
98
+ const chunks: Buffer[] = [Buffer.alloc(4)];
99
+ chunks[0].writeUInt16LE(1, 0); // format_version
100
+ chunks[0].writeUInt16LE(entries.length, 2); // n_derives
101
+ for (const [id, db, method] of entries) {
102
+ const head = Buffer.alloc(2);
103
+ head.writeUInt16LE(id, 0);
104
+ chunks.push(head);
105
+ for (const s of [db, method]) {
106
+ const b = Buffer.from(s, 'utf8');
107
+ const len = Buffer.alloc(4);
108
+ len.writeUInt32LE(b.length, 0);
109
+ chunks.push(len, b);
110
+ }
111
+ }
112
+ return Buffer.concat(chunks);
113
+ }
114
+
95
115
  function catalogSectionV1(fillMaxWaitMs: number, fillAllowStale: number, replication = 5): Buffer {
96
116
  const chunks: Buffer[] = [];
97
117
  const u8 = (v: number) => chunks.push(Buffer.from([v & 0xff]));
@@ -761,6 +781,20 @@ describe('toildb dev emulator (migration + persistence)', () => {
761
781
  expect(rsv(imports)).toBe(0x1234n); // the woven decoder dispatches on this
762
782
  });
763
783
 
784
+ it('patch surfaces the current catalog schema_version (regression: was -1)', () => {
785
+ const { imports, buf } = setup();
786
+ __setDbCatalogForTests({ 'App/users': 0x1234 });
787
+ const h = resolve(imports, buf, 'App/users');
788
+ const [kPtr, kLen] = put(buf, 32, 'u1');
789
+ const [vPtr, vLen] = put(buf, 64, 'orig');
790
+ imports['data.create'](h, kPtr, kLen, vPtr, vLen, 0);
791
+ const [pPtr, pLen] = put(buf, 96, 'patched');
792
+ // patch stashes the patched record + its schema_version; the guest's woven
793
+ // decoder dispatches on that version, so it MUST be the current one, not -1.
794
+ imports['data.patch'](h, kPtr, kLen, pPtr, pLen, 0);
795
+ expect(rsv(imports)).toBe(0x1234n);
796
+ });
797
+
764
798
  it('an evolved @data type leaves old rows stamped with the OLD version', () => {
765
799
  const { imports, buf } = setup();
766
800
  __setDbCatalogForTests({ 'App/users': 100 }); // version A
@@ -894,3 +928,62 @@ describe('toildb dev emulator (migration + persistence)', () => {
894
928
  }
895
929
  });
896
930
  });
931
+
932
+ describe('toildb dev derives (toildb.derives parser + write routing)', () => {
933
+ const id = (d: { deriveId: number }) => d.deriveId;
934
+
935
+ it('parses a valid toildb.derives section in declaration order', () => {
936
+ const wasm = wasmWithSection(
937
+ 'toildb.derives',
938
+ derivesSection([
939
+ [0, 'App', 'rebuild'],
940
+ [1, 'App', 'refresh'],
941
+ [2, 'Other', 'rollup'],
942
+ ]),
943
+ );
944
+ expect(parseDerives(wasm).map((d) => `${d.deriveId}:${d.dbName}.${d.methodName}`)).toEqual([
945
+ '0:App.rebuild',
946
+ '1:App.refresh',
947
+ '2:Other.rollup',
948
+ ]);
949
+ });
950
+
951
+ it('returns [] when there is no toildb.derives section', () => {
952
+ expect(parseDerives(wasmWithSection('toildb.other', Buffer.from([1, 2, 3])))).toEqual([]);
953
+ });
954
+
955
+ it('fails closed on a bad version, trailing bytes, or an empty db name', () => {
956
+ const badVersion = derivesSection([[0, 'App', 'r']]);
957
+ badVersion.writeUInt16LE(2, 0); // unknown format_version
958
+ expect(parseDerives(wasmWithSection('toildb.derives', badVersion))).toEqual([]);
959
+
960
+ const trailing = Buffer.concat([derivesSection([[0, 'App', 'r']]), Buffer.from([0])]);
961
+ expect(parseDerives(wasmWithSection('toildb.derives', trailing))).toEqual([]);
962
+
963
+ const emptyDb = derivesSection([[0, '', 'r']]);
964
+ expect(parseDerives(wasmWithSection('toildb.derives', emptyDb))).toEqual([]);
965
+ });
966
+
967
+ it('routes a source-collection write to its database derives, coalesced', () => {
968
+ const derives = parseDerives(
969
+ wasmWithSection(
970
+ 'toildb.derives',
971
+ derivesSection([
972
+ [0, 'App', 'rebuild'],
973
+ [1, 'App', 'refresh'],
974
+ [2, 'Other', 'rollup'],
975
+ ]),
976
+ ),
977
+ );
978
+ // two App collections written in one dispatch -> App's two derives, once each
979
+ expect(derivesForWrites(derives, new Set(['App/users', 'App/posts'])).map(id)).toEqual([
980
+ 0, 1,
981
+ ]);
982
+ // a write to the other database -> only its derive
983
+ expect(derivesForWrites(derives, new Set(['Other/log'])).map(id)).toEqual([2]);
984
+ // a write to an unrelated database -> nothing runs
985
+ expect(derivesForWrites(derives, new Set(['Nope/x'])).map(id)).toEqual([]);
986
+ // no writes -> nothing runs
987
+ expect(derivesForWrites(derives, new Set<string>()).map(id)).toEqual([]);
988
+ });
989
+ });
@@ -20,8 +20,6 @@ class Account {
20
20
  ids: u256[] = [];
21
21
  }
22
22
 
23
- // A free @remote so buildServerModule emits a surface (it returns null otherwise).
24
- @remote
25
- function touch(n: i32): i32 {
26
- return n;
27
- }
23
+ // The two @data classes above ARE the surface: buildServerModule emits their codec + the bignum
24
+ // helpers for any @data. (No @remote here - a @remote injects a server-side Rpc registration that
25
+ // needs the full server runtime, which this `--runtime stub` fixture deliberately does not link.)
@@ -1,17 +1,14 @@
1
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.
2
+ // `daemon.*` host imports directly (so it needs no toiljs globals lib) and records
3
+ // its activity into resident daemon wasm memory. Compiled with `--targetMode cold`
4
+ // by the test.
5
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 * * *)
6
+ // onStart() -> increments `started` and stamps the lease epoch
7
+ // tick() @scheduled -> increments `tickFast` (1s interval)
8
+ // sixHourly() cron -> increments `tickCron` (0 */6 * * *)
9
9
 
10
10
  // @ts-nocheck — this is AssemblyScript source compiled by toilscript, not TS.
11
11
 
12
- @external("env", "mstore.incr")
13
- declare function mstoreIncr(keyPtr: i32, keyLen: i32, delta: i64, ttlSecs: i32): i64;
14
-
15
12
  @external("env", "daemon.is_leader")
16
13
  declare function daemonIsLeader(): i32;
17
14
 
@@ -21,36 +18,43 @@ declare function daemonCurrentEpoch(): i64;
21
18
  @external("env", "daemon.task_count")
22
19
  declare function daemonTaskCount(): i32;
23
20
 
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
- }
21
+ let started: i32 = 0;
22
+ let leaderSeen: i32 = 0;
23
+ let epochNonneg: i32 = 0;
24
+ let taskcount2: i32 = 0;
25
+ let tickFast: i32 = 0;
26
+ let tickCron: i32 = 0;
30
27
 
31
28
  @daemon
32
29
  class Jobs {
33
30
  onStart(): void {
34
31
  // Prove leader=true and that the epoch import is callable; record both so
35
32
  // the test can assert the stubs from outside.
36
- bump("started");
37
- if (daemonIsLeader() == 1) bump("leader");
33
+ started += 1;
34
+ if (daemonIsLeader() == 1) leaderSeen += 1;
38
35
  let epoch = daemonCurrentEpoch();
39
- if (epoch >= 0) bump("epoch:nonneg");
40
- if (daemonTaskCount() == 2) bump("taskcount:2");
36
+ if (epoch >= 0) epochNonneg += 1;
37
+ if (daemonTaskCount() == 2) taskcount2 += 1;
41
38
  }
42
39
 
43
40
  @scheduled("1s")
44
41
  tick(): void {
45
- bump("tick:fast");
42
+ tickFast += 1;
46
43
  }
47
44
 
48
45
  @scheduled("0 */6 * * *")
49
46
  sixHourly(): void {
50
- bump("tick:cron");
47
+ tickCron += 1;
51
48
  }
52
49
  }
53
50
 
51
+ export function startedCount(): i32 { return started; }
52
+ export function leaderCount(): i32 { return leaderSeen; }
53
+ export function epochNonnegCount(): i32 { return epochNonneg; }
54
+ export function taskcount2Count(): i32 { return taskcount2; }
55
+ export function tickFastCount(): i32 { return tickFast; }
56
+ export function tickCronCount(): i32 { return tickCron; }
57
+
54
58
  export function probe(): i32 {
55
59
  return 1;
56
60
  }