toiljs 0.0.68 → 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 (62) hide show
  1. package/CHANGELOG.md +10 -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 +3 -2
  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/derive.md +159 -0
  27. package/docs/index.md +1 -1
  28. package/docs/streams.md +49 -18
  29. package/examples/basic/server/services/Stats.ts +11 -3
  30. package/examples/basic/server/services/remotes.ts +8 -2
  31. package/package.json +3 -2
  32. package/server/runtime/exports/index.ts +8 -1
  33. package/server/runtime/index.ts +1 -0
  34. package/server/runtime/rpc/Rpc.ts +66 -0
  35. package/src/client/rpc.ts +21 -12
  36. package/src/client/stream/client.ts +138 -8
  37. package/src/compiler/index.ts +352 -2
  38. package/src/compiler/toil-docs.generated.ts +3 -2
  39. package/src/compiler/vite.ts +16 -0
  40. package/src/devserver/daemon/host.ts +10 -110
  41. package/src/devserver/daemon/index.ts +19 -6
  42. package/src/devserver/db/database.ts +1 -1
  43. package/src/devserver/db/routeKinds.ts +44 -0
  44. package/src/devserver/index.ts +0 -1
  45. package/src/devserver/runtime/host.ts +3 -7
  46. package/src/devserver/runtime/module.ts +30 -4
  47. package/src/devserver/stream/index.ts +8 -4
  48. package/src/devserver/wasm/surface.ts +33 -4
  49. package/test/daemon-build.test.ts +53 -0
  50. package/test/daemon-catalog.test.ts +78 -3
  51. package/test/daemon-emulation.test.ts +27 -29
  52. package/test/devserver-database.test.ts +93 -0
  53. package/test/fixtures/bignum-wire/spec.ts +3 -5
  54. package/test/fixtures/daemon-app.ts +25 -21
  55. package/test/fixtures/stream-typed.ts +41 -0
  56. package/test/rpc-dispatch.test.ts +132 -0
  57. package/test/rpc-kinds.test.ts +18 -0
  58. package/test/rpc.test.ts +20 -4
  59. package/test/stream-emulation.test.ts +39 -0
  60. package/build/devserver/mstore/store.d.ts +0 -18
  61. package/build/devserver/mstore/store.js +0 -82
  62. package/src/devserver/mstore/store.ts +0 -121
@@ -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
  }
@@ -0,0 +1,41 @@
1
+ // A TYPED @stream fixture: @stream({ message: ChatMsg }) makes @message receive the DECODED @data
2
+ // (doc 03 2.5), not raw bytes. The hook replies with the decoded text's length, so a host round-trip
3
+ // proves the @data decode ran at runtime (not just compiled). The seed* exports hand the host a real
4
+ // ChatMsg.encode() payload so the test never hand-crafts the @data wire format.
5
+
6
+ @data
7
+ class ChatMsg {
8
+ text: string = '';
9
+ }
10
+
11
+ @stream({ message: ChatMsg })
12
+ class TypedChat {
13
+ @message
14
+ onMsg(m: ChatMsg): StreamOutbound {
15
+ // Reply with the DECODED text length (one byte): "hi" -> 2 proves the decode produced the string.
16
+ const r = new Uint8Array(1);
17
+ r[0] = <u8>m.text.length;
18
+ return StreamOutbound.reply(r);
19
+ }
20
+ }
21
+
22
+ // Seed buffer: the guest encodes a ChatMsg the host feeds back, so the host never hand-crafts @data
23
+ // bytes (the encode/decode pair is the wire contract). A StaticArray<u8>'s pointer IS its data start.
24
+ let seedBuf = new StaticArray<u8>(64);
25
+ let seedLen: i32 = 0;
26
+
27
+ export function seedHi(): void {
28
+ const m = new ChatMsg();
29
+ m.text = 'hi';
30
+ const b = m.encode();
31
+ seedLen = b.length;
32
+ for (let i = 0, n = b.length; i < n; i++) seedBuf[i] = b[i];
33
+ }
34
+
35
+ export function seedOffset(): i32 {
36
+ return changetype<i32>(seedBuf);
37
+ }
38
+
39
+ export function seedLength(): i32 {
40
+ return seedLen;
41
+ }
@@ -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)', () => {
@@ -18,6 +18,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
18
18
 
19
19
  import { makeStreamClient } from '../src/client/stream/client.js';
20
20
  import { matchStreamRoute, parseStreamCatalog } from '../src/devserver/stream/catalog.js';
21
+ import { buildStreamImports, freshStreamBoxState } from '../src/devserver/stream/host.js';
21
22
  import { wireStreams } from '../src/devserver/stream/wire.js';
22
23
  import { DevStreamBox } from '../src/devserver/stream/index.js';
23
24
  import { StreamDevHost } from '../src/devserver/stream/manager.js';
@@ -38,6 +39,7 @@ let GATE_PATH: string;
38
39
  let TRAP_PATH: string;
39
40
  let ECHO: Buffer;
40
41
  let GATE: Buffer;
42
+ let TYPED: Buffer;
41
43
 
42
44
  function compile(srcName: string): { path: string; wasm: Buffer } {
43
45
  const src = join(here, 'fixtures', srcName);
@@ -62,12 +64,49 @@ beforeAll(() => {
62
64
  GATE_PATH = gate.path;
63
65
  GATE = gate.wasm;
64
66
  TRAP_PATH = compile('stream-trap.ts').path;
67
+ TYPED = compile('stream-typed.ts').wasm;
65
68
  });
66
69
 
67
70
  afterAll(() => {
68
71
  if (tmp) rmSync(tmp, { recursive: true, force: true });
69
72
  });
70
73
 
74
+ describe('dev stream box: typed @data @message decode (doc 03 2.5)', () => {
75
+ it('decodes a guest-seeded @data payload at runtime and replies with the decoded field', () => {
76
+ // Seed a real ChatMsg.encode() from a raw instance (deterministic), so the host feeds VALID
77
+ // @data bytes without hand-crafting the wire format.
78
+ const ref: { memory: WebAssembly.Memory | null } = { memory: null };
79
+ const state = freshStreamBoxState();
80
+ const seedInst = new WebAssembly.Instance(
81
+ new WebAssembly.Module(new Uint8Array(TYPED)),
82
+ buildStreamImports(ref, state),
83
+ );
84
+ const sx = seedInst.exports as unknown as {
85
+ memory: WebAssembly.Memory;
86
+ seedHi: () => void;
87
+ seedOffset: () => number;
88
+ seedLength: () => number;
89
+ };
90
+ ref.memory = sx.memory;
91
+ sx.seedHi();
92
+ const off = sx.seedOffset() >>> 0;
93
+ const len = sx.seedLength() >>> 0;
94
+ expect(len).toBeGreaterThan(0);
95
+ const payload = Buffer.from(new Uint8Array(sx.memory.buffer, off, len));
96
+
97
+ // Drive the typed @message: the guest DECODES ChatMsg{text:"hi"} and replies with text.length.
98
+ const box = DevStreamBox.load(TYPED);
99
+ const tid = 0x0000_0000_0000_0011n;
100
+ expect(box.onConnect(tid, 'localhost', '/').kind).toBe('accept');
101
+ const out = box.onMessage(tid, payload);
102
+ expect(out.kind).toBe('reply');
103
+ if (out.kind === 'reply') {
104
+ expect(out.frames.length).toBe(1);
105
+ expect(out.frames[0][0]).toBe(2); // decoded "hi" -> text.length 2
106
+ }
107
+ });
108
+ });
109
+
71
110
  describe('dev stream box: the @message ring bridge', () => {
72
111
  const id = 0x0000_0007_0000_0005n;
73
112
 
@@ -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();