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
@@ -0,0 +1,132 @@
1
+ /**
2
+ * @service / @remote RPC: a real round trip into the example project's toilscript-compiled server
3
+ * wasm. A `POST /__toil_rpc` carrying the FNV method id in the `toil-rpc` header is dispatched by the
4
+ * runtime `Rpc` registry (compiler-injected `__rpcDispatch` + `Rpc.register`) to the @remote method,
5
+ * and the @data/scalar result comes back encoded with the same DataWriter codec the client decodes.
6
+ */
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ import { describe, expect, it } from 'vitest';
12
+
13
+ import { WasmServerModule } from '../src/devserver/index.js';
14
+
15
+ const EXAMPLE_WASM = path.resolve(
16
+ path.dirname(fileURLToPath(import.meta.url)),
17
+ '../examples/basic/build/server/release.wasm',
18
+ );
19
+
20
+ /** FNV-1a (the toilscript `dataTypeId`); the client + the parser injection must agree on this. */
21
+ function fnv1a(s: string): number {
22
+ let h = 0x811c9dc5;
23
+ for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 0x01000193) >>> 0;
24
+ return h >>> 0;
25
+ }
26
+
27
+ describe.skipIf(!fs.existsSync(EXAMPLE_WASM))('@service RPC dispatch', () => {
28
+ const load = (): WasmServerModule => {
29
+ const m = new WasmServerModule(EXAMPLE_WASM);
30
+ m.refresh();
31
+ return m;
32
+ };
33
+
34
+ it('dispatches Server.stats.playerCount() over /__toil_rpc', () => {
35
+ const id = fnv1a('Stats.playerCount');
36
+ expect(id).toBe(118912982); // matches the generated __toilRpc client
37
+ const r = load().dispatch({
38
+ method: 'POST',
39
+ path: '/__toil_rpc',
40
+ headers: [['toil-rpc', String(id)]],
41
+ body: new Uint8Array(0),
42
+ });
43
+ expect(r.unhandled).toBe(false);
44
+ expect(r.status).toBe(200);
45
+ // The result is a DataWriter-encoded i32 (the seeded player count = 3).
46
+ expect(r.body.length).toBe(4);
47
+ const dv = new DataView(r.body.buffer, r.body.byteOffset, r.body.length);
48
+ expect(dv.getInt32(0, true)).toBe(3);
49
+ });
50
+
51
+ it('dispatches a free Server.ping(n) over /__toil_rpc', () => {
52
+ const id = fnv1a('ping');
53
+ const body = new Uint8Array(4);
54
+ new DataView(body.buffer).setInt32(0, 41, true); // arg n = 41 (DataWriter writeI32, LE)
55
+ const r = load().dispatch({
56
+ method: 'POST',
57
+ path: '/__toil_rpc',
58
+ headers: [['toil-rpc', String(id)]],
59
+ body,
60
+ });
61
+ expect(r.status).toBe(200);
62
+ expect(r.body.length).toBe(4);
63
+ const dv = new DataView(r.body.buffer, r.body.byteOffset, r.body.length);
64
+ expect(dv.getInt32(0, true)).toBe(42); // ping(41) = 42
65
+ });
66
+
67
+ it('round-trips a Uint8Array[] arg + result (writeBytes/readBytes loop, the #5 fix)', () => {
68
+ const id = fnv1a('echoParts');
69
+ const parts = [Uint8Array.from([1, 2]), Uint8Array.from([3, 4, 5])];
70
+ const buf: number[] = [];
71
+ const pushU32 = (v: number) => buf.push(v & 255, (v >> 8) & 255, (v >> 16) & 255, (v >>> 24) & 255);
72
+ pushU32(parts.length); // u32 count, then per part: u32 len + bytes (DataWriter.writeBytes)
73
+ for (const p of parts) {
74
+ pushU32(p.length);
75
+ for (const b of p) buf.push(b);
76
+ }
77
+ const r = load().dispatch({
78
+ method: 'POST',
79
+ path: '/__toil_rpc',
80
+ headers: [['toil-rpc', String(id)]],
81
+ body: Uint8Array.from(buf),
82
+ });
83
+ expect(r.status).toBe(200);
84
+ const dv = new DataView(r.body.buffer, r.body.byteOffset, r.body.length);
85
+ let off = 0;
86
+ const count = dv.getUint32(off, true);
87
+ off += 4;
88
+ const out: number[][] = [];
89
+ for (let i = 0; i < count; i++) {
90
+ const len = dv.getUint32(off, true);
91
+ off += 4;
92
+ out.push([...r.body.subarray(off, off + len)]);
93
+ off += len;
94
+ }
95
+ expect(out).toEqual([
96
+ [1, 2],
97
+ [3, 4, 5],
98
+ ]);
99
+ });
100
+
101
+ it('rejects an @auth @remote with 401 when there is no session', () => {
102
+ const id = fnv1a('Stats.secretCount');
103
+ const r = load().dispatch({
104
+ method: 'POST',
105
+ path: '/__toil_rpc',
106
+ headers: [['toil-rpc', String(id)]],
107
+ body: new Uint8Array(0),
108
+ });
109
+ expect(r.status).toBe(401);
110
+ });
111
+
112
+ it('returns 400 for an unknown method id', () => {
113
+ const r = load().dispatch({
114
+ method: 'POST',
115
+ path: '/__toil_rpc',
116
+ headers: [['toil-rpc', '999999']],
117
+ body: new Uint8Array(0),
118
+ });
119
+ expect(r.status).toBe(400);
120
+ });
121
+
122
+ it('does not intercept a non-RPC request (no toil-rpc header)', () => {
123
+ const r = load().dispatch({
124
+ method: 'GET',
125
+ path: '/json',
126
+ headers: [['host', 'localhost:3000']],
127
+ body: new Uint8Array(0),
128
+ });
129
+ expect(r.status).toBe(200);
130
+ expect(Buffer.from(r.body).toString()).toBe('{"hello":"toiljs"}\n');
131
+ });
132
+ });
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The dev-server RPC DB-kind resolution (audit #2): an `@action` @remote may write; a plain/read-only
3
+ * @remote (absent from `toildb.rpc_kinds`) defaults to read-only Query, matching the edge host gate.
4
+ */
5
+ import { describe, expect, it } from 'vitest';
6
+
7
+ import { rpcKindForId, type RpcKindEntry } from '../src/devserver/db/routeKinds.js';
8
+ import { DbFunctionKind } from '../src/devserver/db/types.js';
9
+
10
+ describe('rpcKindForId', () => {
11
+ it('returns the @action kind for a listed id, and read-only Query for an absent one', () => {
12
+ const methods: RpcKindEntry[] = [{ methodId: 42, kind: DbFunctionKind.Action }];
13
+ expect(rpcKindForId(methods, 42)).toBe(DbFunctionKind.Action);
14
+ // An id NOT in rpc_kinds (a plain/read-only @remote) defaults to Query - the safe default.
15
+ expect(rpcKindForId(methods, 999)).toBe(DbFunctionKind.Query);
16
+ expect(rpcKindForId([], 42)).toBe(DbFunctionKind.Query);
17
+ });
18
+ });
package/test/rpc.test.ts CHANGED
@@ -2,9 +2,14 @@ import { afterEach, describe, expect, it } from 'vitest';
2
2
 
3
3
  import { Server } from '../src/client/rpc';
4
4
 
5
- // `Server` is the runtime behind the generated typed surface. Until the transport
6
- // is wired, it is a recursive proxy that throws on call.
7
- describe('Server RPC stub', () => {
5
+ // `Server` is the runtime behind the generated typed surface. The RPC branch surfaces
6
+ // `globalThis.__toilRpc` (attached by the generated `shared/server.ts`); when that client has
7
+ // not loaded, the proxy throws a helpful "client has not loaded" error naming the path.
8
+ describe('Server RPC stub (client not loaded)', () => {
9
+ afterEach(() => {
10
+ delete (globalThis as { __toilRpc?: unknown }).__toilRpc;
11
+ });
12
+
8
13
  it('throws on a direct call, naming the path', () => {
9
14
  const s = Server as { ping: () => unknown };
10
15
  expect(() => s.ping()).toThrow(/Server\.ping\(\)/);
@@ -13,7 +18,18 @@ describe('Server RPC stub', () => {
13
18
  it('throws on a nested service.method call', () => {
14
19
  const s = Server as { accounts: { getUser: () => unknown } };
15
20
  expect(() => s.accounts.getUser()).toThrow(/Server\.accounts\.getUser\(\)/);
16
- expect(() => s.accounts.getUser()).toThrow(/not available yet/);
21
+ expect(() => s.accounts.getUser()).toThrow(/has not loaded/);
22
+ });
23
+
24
+ it('surfaces the attached RPC client (service method + free remote) once loaded', async () => {
25
+ const fake = { stats: { playerCount: async () => 3 }, ping: async (n: number) => n + 1 };
26
+ (globalThis as { __toilRpc?: unknown }).__toilRpc = fake;
27
+ const s = Server as {
28
+ stats: { playerCount: () => Promise<number> };
29
+ ping: (n: number) => Promise<number>;
30
+ };
31
+ expect(await s.stats.playerCount()).toBe(3);
32
+ expect(await s.ping(41)).toBe(42);
17
33
  });
18
34
 
19
35
  it('is not thenable (so it is not mistaken for a promise)', () => {
@@ -1,18 +0,0 @@
1
- export declare class DevMemoryStore {
2
- private readonly map;
3
- private now;
4
- private live;
5
- private exp;
6
- get(key: string): Buffer | null;
7
- set(key: string, value: Buffer, ttlSecs: number): void;
8
- delete(key: string): boolean;
9
- incr(key: string, delta: bigint, ttlSecs: number): bigint | null;
10
- cas(key: string, expect: Buffer | null, next: Buffer, ttlSecs: number): boolean;
11
- expire(key: string, ttlSecs: number): boolean;
12
- scan(prefix: string, cursor: bigint): {
13
- next: bigint;
14
- keys: string[];
15
- } | null;
16
- __reset(): void;
17
- }
18
- export declare const devMemoryStore: DevMemoryStore;
@@ -1,82 +0,0 @@
1
- export class DevMemoryStore {
2
- map = new Map();
3
- now() {
4
- return Date.now();
5
- }
6
- live(key) {
7
- const e = this.map.get(key);
8
- if (e === undefined)
9
- return null;
10
- if (e.expiresAtMs !== 0 && e.expiresAtMs <= this.now()) {
11
- this.map.delete(key);
12
- return null;
13
- }
14
- return e;
15
- }
16
- exp(ttlSecs) {
17
- return ttlSecs > 0 ? this.now() + ttlSecs * 1000 : 0;
18
- }
19
- get(key) {
20
- const e = this.live(key);
21
- return e ? e.value : null;
22
- }
23
- set(key, value, ttlSecs) {
24
- this.map.set(key, { value: Buffer.from(value), expiresAtMs: this.exp(ttlSecs) });
25
- }
26
- delete(key) {
27
- return this.map.delete(key);
28
- }
29
- incr(key, delta, ttlSecs) {
30
- const e = this.live(key);
31
- let cur = 0n;
32
- if (e !== null) {
33
- const s = e.value.toString('utf8').trim();
34
- if (!/^-?\d+$/.test(s))
35
- return null;
36
- try {
37
- cur = BigInt(s);
38
- }
39
- catch {
40
- return null;
41
- }
42
- }
43
- const next = BigInt.asIntN(64, cur + delta);
44
- this.map.set(key, {
45
- value: Buffer.from(next.toString(), 'utf8'),
46
- expiresAtMs: ttlSecs > 0 ? this.exp(ttlSecs) : (e?.expiresAtMs ?? 0),
47
- });
48
- return next;
49
- }
50
- cas(key, expect, next, ttlSecs) {
51
- const e = this.live(key);
52
- if (expect === null) {
53
- if (e !== null)
54
- return false;
55
- }
56
- else if (e === null || !e.value.equals(expect)) {
57
- return false;
58
- }
59
- this.map.set(key, { value: Buffer.from(next), expiresAtMs: this.exp(ttlSecs) });
60
- return true;
61
- }
62
- expire(key, ttlSecs) {
63
- const e = this.live(key);
64
- if (!e)
65
- return false;
66
- e.expiresAtMs = this.exp(ttlSecs);
67
- return true;
68
- }
69
- scan(prefix, cursor) {
70
- const live = [...this.map.keys()].filter((k) => this.live(k) !== null && k.startsWith(prefix));
71
- live.sort();
72
- const start = Number(cursor);
73
- if (start < 0 || start > live.length)
74
- return null;
75
- const batch = live.slice(start);
76
- return { next: BigInt(live.length), keys: batch };
77
- }
78
- __reset() {
79
- this.map.clear();
80
- }
81
- }
82
- export const devMemoryStore = new DevMemoryStore();
@@ -1,121 +0,0 @@
1
- /**
2
- * Dev MemoryStore: a single in-memory `Map` with per-entry TTL, one per process,
3
- * persisted nowhere (ephemeral by definition). Backs the `mstore.*` host imports
4
- * (RECONCILIATION Part 4, F2: HANDLELESS, ttl in SECONDS, inline drain). Keys are
5
- * auto-scoped to host+region; the dev process is one host/region, so the key is
6
- * used verbatim. Shared by streams (Phase 4) AND the daemon (both reference the
7
- * same `devMemoryStore` singleton), matching doc 06's "shared across
8
- * streams/handlers on the same region".
9
- *
10
- * TTL is enforced LAZILY on read (no background sweep), mirroring the dev DB's
11
- * no-background-thread design. The error space is RECONCILIATION Part 3's 0x03xx
12
- * registry; the host-import layer (daemon/host.ts) maps these results onto the
13
- * Part 3 negative-return bridge.
14
- */
15
-
16
- interface MStoreEntry {
17
- value: Buffer;
18
- /** `0` means no TTL; otherwise the epoch-ms the entry expires at. */
19
- expiresAtMs: number;
20
- }
21
-
22
- export class DevMemoryStore {
23
- private readonly map = new Map<string, MStoreEntry>();
24
-
25
- private now(): number {
26
- return Date.now();
27
- }
28
-
29
- /** The live entry for `key`, collecting it lazily if its TTL has passed. */
30
- private live(key: string): MStoreEntry | null {
31
- const e = this.map.get(key);
32
- if (e === undefined) return null;
33
- if (e.expiresAtMs !== 0 && e.expiresAtMs <= this.now()) {
34
- this.map.delete(key);
35
- return null;
36
- }
37
- return e;
38
- }
39
-
40
- private exp(ttlSecs: number): number {
41
- return ttlSecs > 0 ? this.now() + ttlSecs * 1000 : 0;
42
- }
43
-
44
- /** The value, or `null` (=> 0x0301 MSTORE_NOT_FOUND). */
45
- get(key: string): Buffer | null {
46
- const e = this.live(key);
47
- return e ? e.value : null;
48
- }
49
-
50
- set(key: string, value: Buffer, ttlSecs: number): void {
51
- this.map.set(key, { value: Buffer.from(value), expiresAtMs: this.exp(ttlSecs) });
52
- }
53
-
54
- delete(key: string): boolean {
55
- return this.map.delete(key);
56
- }
57
-
58
- /** Add `delta` to the i64 stored at `key`; `null` => 0x0306 MSTORE_NOT_A_NUMBER. */
59
- incr(key: string, delta: bigint, ttlSecs: number): bigint | null {
60
- const e = this.live(key);
61
- let cur = 0n;
62
- if (e !== null) {
63
- const s = e.value.toString('utf8').trim();
64
- if (!/^-?\d+$/.test(s)) return null;
65
- try {
66
- cur = BigInt(s);
67
- } catch {
68
- return null;
69
- }
70
- }
71
- const next = BigInt.asIntN(64, cur + delta);
72
- this.map.set(key, {
73
- value: Buffer.from(next.toString(), 'utf8'),
74
- // An incr on an existing key keeps its TTL unless a new one is given.
75
- expiresAtMs: ttlSecs > 0 ? this.exp(ttlSecs) : (e?.expiresAtMs ?? 0),
76
- });
77
- return next;
78
- }
79
-
80
- /** `expect === null` means expect-absent (the dev mapping of `expect_len == 0`).
81
- * Returns `false` => 0x0304 MSTORE_CONFLICT. */
82
- cas(key: string, expect: Buffer | null, next: Buffer, ttlSecs: number): boolean {
83
- const e = this.live(key);
84
- if (expect === null) {
85
- if (e !== null) return false; // expect-absent, but present
86
- } else if (e === null || !e.value.equals(expect)) {
87
- return false; // expect-match failed
88
- }
89
- this.map.set(key, { value: Buffer.from(next), expiresAtMs: this.exp(ttlSecs) });
90
- return true;
91
- }
92
-
93
- /** Re-arm the TTL of a live key; `false` => key absent (0x0301). */
94
- expire(key: string, ttlSecs: number): boolean {
95
- const e = this.live(key);
96
- if (!e) return false;
97
- e.expiresAtMs = this.exp(ttlSecs);
98
- return true;
99
- }
100
-
101
- /**
102
- * Prefix walk. `cursor` is an opaque resume index; a stale cursor (one that
103
- * points past the current live key set after deletions) returns `null`
104
- * (=> 0x0307 MSTORE_SCAN_BUSY). Returns the next cursor + the matched keys.
105
- */
106
- scan(prefix: string, cursor: bigint): { next: bigint; keys: string[] } | null {
107
- const live = [...this.map.keys()].filter((k) => this.live(k) !== null && k.startsWith(prefix));
108
- live.sort();
109
- const start = Number(cursor);
110
- if (start < 0 || start > live.length) return null; // stale cursor
111
- const batch = live.slice(start);
112
- return { next: BigInt(live.length), keys: batch };
113
- }
114
-
115
- /** Test-only: drop all entries. */
116
- __reset(): void {
117
- this.map.clear();
118
- }
119
- }
120
-
121
- export const devMemoryStore = new DevMemoryStore();