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
@@ -15,4 +15,4 @@ export {
15
15
  setDbCatalog,
16
16
  } from './database.js';
17
17
  export { parseCatalog } from './catalog.js';
18
- export { type DbDevState, freshDbState } from './types.js';
18
+ export { CollectionFamily, DbFunctionKind, type DbDevState, freshDbState } from './types.js';
@@ -0,0 +1,147 @@
1
+ import { customSection } from '../wasm/sections.js';
2
+ import { DbFunctionKind } from './types.js';
3
+
4
+ export interface RouteKindEntry {
5
+ readonly method: number;
6
+ readonly kind: DbFunctionKind;
7
+ readonly pattern: string;
8
+ }
9
+
10
+ const SECTION = 'toildb.route_kinds';
11
+ const VERSION = 1;
12
+ const MAX_SECTION_BYTES = 128 * 1024;
13
+ const MAX_ROUTES = 2048;
14
+ const MAX_PATTERN_BYTES = 2048;
15
+ const UTF8_DECODER = new TextDecoder('utf-8', { fatal: true });
16
+
17
+ const METHOD_CODES: Readonly<Record<string, number>> = {
18
+ GET: 0,
19
+ POST: 1,
20
+ PUT: 2,
21
+ DELETE: 3,
22
+ PATCH: 4,
23
+ HEAD: 5,
24
+ OPTIONS: 6,
25
+ };
26
+
27
+ export function parseRouteKinds(wasm: Buffer): readonly RouteKindEntry[] {
28
+ let section: Buffer | null;
29
+ try {
30
+ section = customSection(wasm, SECTION);
31
+ } catch {
32
+ return [];
33
+ }
34
+ if (section === null) return [];
35
+ if (section.length > MAX_SECTION_BYTES) return [];
36
+
37
+ const r = new Reader(section);
38
+ const version = r.u16();
39
+ if (!r.ok || version !== VERSION) return [];
40
+ const count = r.u16();
41
+ if (!r.ok || count > MAX_ROUTES) return [];
42
+
43
+ const routes: RouteKindEntry[] = [];
44
+ for (let i = 0; i < count && r.ok; i++) {
45
+ const method = r.u8();
46
+ const kindByte = r.u8();
47
+ const pattern = r.string();
48
+ const kind =
49
+ kindByte === 0 ? DbFunctionKind.Query : kindByte === 1 ? DbFunctionKind.Action : null;
50
+ if (!r.ok || method < 0 || method > 6 || kind === null || !validPattern(pattern)) return [];
51
+ routes.push({ method, kind, pattern });
52
+ }
53
+ if (!r.ok || r.remaining() !== 0) return [];
54
+ return routes;
55
+ }
56
+
57
+ export function routeKindForRequest(
58
+ routes: readonly RouteKindEntry[],
59
+ method: string,
60
+ path: string,
61
+ ): DbFunctionKind | null {
62
+ const methodCode = METHOD_CODES[method.toUpperCase()];
63
+ if (methodCode === undefined) return null;
64
+ for (const route of routes) {
65
+ if (route.method === methodCode && routeMatches(route.pattern, path)) return route.kind;
66
+ }
67
+ return null;
68
+ }
69
+
70
+ function validPattern(pattern: string): boolean {
71
+ if (pattern.length === 0 || !pattern.startsWith('/')) return false;
72
+ for (let i = 0; i < pattern.length; i++) {
73
+ const c = pattern.charCodeAt(i);
74
+ if (c < 0x20 || c > 0x7e) return false;
75
+ }
76
+ return true;
77
+ }
78
+
79
+ function routeMatches(pattern: string, pathWithQuery: string): boolean {
80
+ const q = pathWithQuery.indexOf('?');
81
+ const path = q >= 0 ? pathWithQuery.slice(0, q) : pathWithQuery;
82
+ const patternSegs = pattern.split('/').filter(Boolean);
83
+ const pathSegs = path.split('/').filter(Boolean);
84
+ if (patternSegs.length !== pathSegs.length) return false;
85
+ for (let i = 0; i < patternSegs.length; i++) {
86
+ const p = patternSegs[i] ?? '';
87
+ const a = pathSegs[i] ?? '';
88
+ if (p.startsWith(':') && p.length > 1 && a.length > 0) continue;
89
+ if (p !== a) return false;
90
+ }
91
+ return true;
92
+ }
93
+
94
+ class Reader {
95
+ private pos = 0;
96
+ ok = true;
97
+
98
+ constructor(private readonly bytes: Buffer) {}
99
+
100
+ remaining(): number {
101
+ return this.bytes.length - this.pos;
102
+ }
103
+
104
+ u8(): number {
105
+ if (!this.ok || this.pos + 1 > this.bytes.length) {
106
+ this.ok = false;
107
+ return 0;
108
+ }
109
+ return this.bytes[this.pos++] ?? 0;
110
+ }
111
+
112
+ u16(): number {
113
+ if (!this.ok || this.pos + 2 > this.bytes.length) {
114
+ this.ok = false;
115
+ return 0;
116
+ }
117
+ const out = this.bytes.readUInt16LE(this.pos);
118
+ this.pos += 2;
119
+ return out;
120
+ }
121
+
122
+ u32(): number {
123
+ if (!this.ok || this.pos + 4 > this.bytes.length) {
124
+ this.ok = false;
125
+ return 0;
126
+ }
127
+ const out = this.bytes.readUInt32LE(this.pos);
128
+ this.pos += 4;
129
+ return out;
130
+ }
131
+
132
+ string(): string {
133
+ const len = this.u32();
134
+ if (!this.ok || len > MAX_PATTERN_BYTES || this.pos + len > this.bytes.length) {
135
+ this.ok = false;
136
+ return '';
137
+ }
138
+ try {
139
+ const out = UTF8_DECODER.decode(this.bytes.subarray(this.pos, this.pos + len));
140
+ this.pos += len;
141
+ return out;
142
+ } catch {
143
+ this.ok = false;
144
+ return '';
145
+ }
146
+ }
147
+ }
@@ -6,16 +6,59 @@
6
6
  * `<= -1000` a typed error (`-(1000+TDLnnn)`).
7
7
  */
8
8
 
9
+ export enum CollectionFamily {
10
+ Record = 0,
11
+ View = 1,
12
+ Events = 2,
13
+ Counter = 3,
14
+ Membership = 4,
15
+ Unique = 5,
16
+ Capacity = 6,
17
+ }
18
+
19
+ export enum DbFunctionKind {
20
+ Query = 'query',
21
+ Action = 'action',
22
+ Derive = 'derive',
23
+ Job = 'job',
24
+ Admin = 'admin',
25
+ }
26
+
27
+ export function isCollectionFamily(value: number): value is CollectionFamily {
28
+ return value >= CollectionFamily.Record && value <= CollectionFamily.Capacity;
29
+ }
30
+
31
+ export interface DevCollectionHandle {
32
+ name: string;
33
+ family: CollectionFamily;
34
+ schemaVersion: number;
35
+ replication: number;
36
+ placement: number;
37
+ fillMaxWaitMs: number;
38
+ fillAllowStale: boolean;
39
+ }
40
+
41
+ export type DbCatalogState =
42
+ | { kind: 'no-section' }
43
+ | { kind: 'malformed' }
44
+ | { kind: 'present'; collections: Map<string, DevCollectionHandle> };
45
+
9
46
  /** Per-request data state: resolved handles + the last variable-length result +
10
47
  * the schema_version of that result's row (surfaced via result_schema_version). */
11
48
  export interface DbDevState {
12
- handles: string[];
49
+ handles: DevCollectionHandle[];
13
50
  lastResult: Buffer | null;
14
51
  lastResultVersion: number;
52
+ functionKind: DbFunctionKind;
15
53
  }
16
54
 
17
55
  export function freshDbState(): DbDevState {
18
- return { handles: [], lastResult: null, lastResultVersion: -1 };
56
+ return {
57
+ handles: [],
58
+ lastResult: null,
59
+ lastResultVersion: -1,
60
+ functionKind: DbFunctionKind.Job,
61
+ };
19
62
  }
20
63
 
21
64
  /** A finite-resource escrow: a ceiling + a set of reservations, each held (in
@@ -35,9 +78,15 @@ export interface CapLedger {
35
78
  /** The on-disk snapshot shape: dev data + its versions, JSON with base64 buffers. */
36
79
  export interface DbSnapshot {
37
80
  store: Record<string, { v: string; sv: number }>; // records + unique owners (+ schema_version)
81
+ recordIdem?: Record<
82
+ string,
83
+ { requestHash: string; state: 'pending' | 'done'; outcome?: RecordOutcomeSnapshot }
84
+ >;
85
+ uniqueIdem?: Record<string, string>;
38
86
  views: Record<string, { v: string; sv: number }>;
39
87
  members: Record<string, Record<string, { v: string; sv: number }>>;
40
88
  counters: Record<string, string>;
89
+ counterIdem?: Record<string, string>;
41
90
  events: Record<string, { v: string; sv: number }[]>;
42
91
  eventDedup: Record<string, string[]>;
43
92
  capacity: Record<
@@ -50,6 +99,14 @@ export interface DbSnapshot {
50
99
  >;
51
100
  }
52
101
 
102
+ export type RecordOutcomeSnapshot =
103
+ | { kind: 'unit' }
104
+ | { kind: 'value'; v: string; sv: number }
105
+ | { kind: 'absent' }
106
+ | { kind: 'already_exists' }
107
+ | { kind: 'not_found' }
108
+ | { kind: 'conflict' };
109
+
53
110
  /** Edge caps (toildb::capacity::escrow): bound the reservation count + the hold TTL. */
54
111
  export const MAX_RESERVATIONS = 4096;
55
112
  export const MAX_RESERVATION_TTL_MS = 86_400_000; // 24h
@@ -57,6 +114,8 @@ export const MAX_RESERVATION_TTL_MS = 86_400_000; // 24h
57
114
  export const MAX_NAME = 512;
58
115
  export const MAX_KEY = 4096;
59
116
  export const MAX_VALUE = 256 * 1024;
117
+ export const DEFAULT_FILL_WAIT_MS = 50;
118
+ export const MAX_FILL_WAIT_MS = 60_000;
60
119
 
61
120
  // i64 saturation bounds (the edge `MemEngine`/`ScyllaEngine` counters are i64).
62
121
  const I64_MIN = -(2n ** 63n);
@@ -72,5 +131,9 @@ export const TOO_SMALL = -1;
72
131
  export const INVALID_HANDLE = -1001; // TDL001
73
132
  export const ALREADY_EXISTS = -1003; // TDL003 (create on an existing key)
74
133
  export const CONFLICT = -1004; // TDL004 (e.g. unique release by a non-owner)
134
+ export const UNAVAILABLE = -1031; // TDL031 (retryable in-flight/uncertain op)
75
135
  export const CODEC_ERR = -1006; // TDL006 (e.g. a non-positive reserve amount)
136
+ export const OP_NOT_ALLOWED_FOR_FAMILY = -1010; // TDL010
137
+ export const OP_NOT_ALLOWED_IN_KIND = -1011; // TDL011
76
138
  export const TOO_MANY_KEYS = -1020; // TDL020 (get_many over the per-call cap)
139
+ export const SCHEMA_UNAVAILABLE = -1070; // TDL070
@@ -28,3 +28,15 @@ export type { WasmDispatchResult } from './runtime/module.js';
28
28
  export { buildHostImports, freshDispatchState } from './runtime/host.js';
29
29
  export type { DispatchState, MemoryRef } from './runtime/host.js';
30
30
  export type { ViteTarget } from './http/proxy.js';
31
+
32
+ // Dev DAEMON (L4) emulation (RECONCILIATION Part 2/5; doc 08 section 5).
33
+ export { DaemonHost, daemonEmulationEnabled } from './daemon/index.js';
34
+ export { parseDaemonCatalog } from './daemon/catalog.js';
35
+ export type { DaemonCatalog, ScheduledTask, CronMasks } from './daemon/catalog.js';
36
+ export { buildDaemonImports, freshDaemonState } from './daemon/host.js';
37
+ export type { DaemonState, DaemonRuntime, ResolvedDaemonConfig } from './daemon/host.js';
38
+ export { cronMatches, cronNeverFires, nextCronFireMs } from './daemon/cron.js';
39
+ export { parseSurface } from './wasm/surface.js';
40
+ export type { Surface, SurfaceFlags } from './wasm/surface.js';
41
+ export { customSection } from './wasm/sections.js';
42
+ export { DevMemoryStore, devMemoryStore } from './mstore/store.js';
@@ -0,0 +1,121 @@
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();
@@ -91,13 +91,33 @@ function mem(ref: MemoryRef): Buffer {
91
91
  }
92
92
 
93
93
  /** Bounds-checked byte read out of guest linear memory. */
94
- function readBytes(ref: MemoryRef, ptr: number, len: number): Buffer {
94
+ export function readBytes(ref: MemoryRef, ptr: number, len: number): Buffer {
95
95
  const m = mem(ref);
96
96
  if (ptr < 0 || len < 0 || ptr + len > m.length)
97
97
  throw new Error(`host import read out of bounds: ptr=${String(ptr)} len=${String(len)}`);
98
98
  return m.subarray(ptr, ptr + len);
99
99
  }
100
100
 
101
+ /**
102
+ * Bounds-checked write of a variable-length result into a guest out-buffer, with
103
+ * the edge's inline-drain return protocol: the byte length on success, or `-1`
104
+ * (STATUS_TOO_SMALL) when `outCap` is too small (the guest retries with a bigger
105
+ * buffer). Used by the handleless `mstore.*` imports (RECONCILIATION Part 4 F2).
106
+ */
107
+ export function writeBytesOut(
108
+ ref: MemoryRef,
109
+ bytes: Buffer,
110
+ outPtr: number,
111
+ outCap: number,
112
+ ): number {
113
+ if (bytes.length > outCap) return -1; // TOO_SMALL
114
+ const m = mem(ref);
115
+ if (outPtr < 0 || outPtr + bytes.length > m.length)
116
+ throw new Error('host import write out of bounds');
117
+ bytes.copy(m, outPtr);
118
+ return bytes.length;
119
+ }
120
+
101
121
  /**
102
122
  * Read a ToilScript string (UTF-16LE payload, byte length in the u32 at
103
123
  * `ptr - 4`). Used by `abort`, whose pointers reference string objects rather
@@ -169,6 +189,77 @@ function envLookup(
169
189
  return bytes.length;
170
190
  }
171
191
 
192
+ /**
193
+ * The portion of the `env.*` request surface that is SHARED by the daemon (cold)
194
+ * box: panic hook, `Environment.get`/`getSecure`, `email_send`, `thread_spawn`,
195
+ * and `Date.now`. It deliberately EXCLUDES the response/stream functions a cold
196
+ * box must not have (`set_status`/`set_header`/`respond_file`/`client_ip`/
197
+ * `ratelimit_check`), which stay in {@link buildHostImports}. None of these read
198
+ * the per-dispatch response state, so they need only `ref`. The crypto and DB
199
+ * namespaces are spread on top by each box's loader (they carry their own state).
200
+ */
201
+ export function buildEnvImports(
202
+ ref: MemoryRef,
203
+ _state: { crypto: CryptoState; db: DbDevState },
204
+ ): Record<string, (...a: never[]) => unknown> {
205
+ return {
206
+ abort: (msgPtr: number, filePtr: number, line: number, col: number): void => {
207
+ throw new WasmAbortError(
208
+ readGuestString(ref, msgPtr),
209
+ readGuestString(ref, filePtr),
210
+ line,
211
+ col,
212
+ );
213
+ },
214
+
215
+ // `Environment.get` / `getSecure`: copy one tenant env value into the
216
+ // guest buffer. Returns the byte length (0 = present-but-empty), -1 if
217
+ // the buffer is too small (the guest retries bigger), -2 if absent.
218
+ env_get: (keyPtr: number, keyLen: number, outPtr: number, outCap: number): number =>
219
+ envLookup(ref, keyPtr, keyLen, outPtr, outCap, false),
220
+ env_get_secure: (
221
+ keyPtr: number,
222
+ keyLen: number,
223
+ outPtr: number,
224
+ outCap: number,
225
+ ): number => envLookup(ref, keyPtr, keyLen, outPtr, outCap, true),
226
+
227
+ thread_spawn: (_startArg: number): number => -1,
228
+
229
+ // `Date.now()` -> wall-clock milliseconds, matching the edge host.
230
+ 'Date.now': (): bigint => BigInt(Date.now()),
231
+
232
+ // `env::email_send`: the FULL email pipeline in dev. A daemon may send
233
+ // mail, so this stays in the shared subset (00 B2 / doc 08 AN-8).
234
+ email_send: (reqPtr: number, reqLen: number): number => {
235
+ const raw = readBytes(ref, reqPtr, reqLen);
236
+ const svc = getEmailService();
237
+ if (svc === null) {
238
+ const to = parseEmailBlob(raw)?.to ?? '<unparsed>';
239
+ process.stdout.write(` ✉ dev email_send -> ${to} (no email config; not sent)\n`);
240
+ return EmailStatus.Sent;
241
+ }
242
+ const { status, parsed } = svc.prepare(raw);
243
+ if (parsed === null) {
244
+ process.stdout.write(` ✉ dev email_send -> ${EmailStatus[status]}\n`);
245
+ return status;
246
+ }
247
+ void svc
248
+ .deliver(parsed)
249
+ .then((s) => {
250
+ const label = s === EmailStatus.Sent ? 'sent' : EmailStatus[s];
251
+ process.stdout.write(` ✉ dev email_send -> ${parsed.to} (${label})\n`);
252
+ })
253
+ .catch((e: unknown) => {
254
+ process.stdout.write(
255
+ ` ✉ dev email_send -> ${parsed.to} (error: ${String(e)})\n`,
256
+ );
257
+ });
258
+ return EmailStatus.Sent; // optimistic; sync wasm can't await the send
259
+ },
260
+ };
261
+ }
262
+
172
263
  /**
173
264
  * Build the `env` import object for one instance. `state` collects what the
174
265
  * imperative imports produce during a dispatch; bind a fresh state per request.
@@ -13,7 +13,8 @@
13
13
 
14
14
  import fs from 'node:fs';
15
15
 
16
- import { persistDb, setDbCatalog } from '../db/index.js';
16
+ import { DbFunctionKind, persistDb, setDbCatalog } from '../db/index.js';
17
+ import { parseRouteKinds, routeKindForRequest, type RouteKindEntry } from '../db/routeKinds.js';
17
18
  import {
18
19
  decodeResponseEnvelope,
19
20
  encodeRequestEnvelope,
@@ -35,6 +36,21 @@ export const UNHANDLED_HEADER = 'x-toil-unhandled';
35
36
 
36
37
  const WASM_PAGE = 65536;
37
38
 
39
+ function dbKindForHttpMethod(method: string): DbFunctionKind {
40
+ switch (method.toUpperCase()) {
41
+ case 'GET':
42
+ case 'HEAD':
43
+ case 'OPTIONS':
44
+ return DbFunctionKind.Query;
45
+ case 'POST':
46
+ case 'PUT':
47
+ case 'PATCH':
48
+ case 'DELETE':
49
+ default:
50
+ return DbFunctionKind.Action;
51
+ }
52
+ }
53
+
38
54
  /** The shaped outcome of one guest dispatch. */
39
55
  export interface WasmDispatchResult {
40
56
  readonly status: number;
@@ -117,6 +133,7 @@ const PROVIDED_IMPORTS = new Set([
117
133
  export class WasmServerModule {
118
134
  private module: WebAssembly.Module | null = null;
119
135
  private loadedMtimeMs = -1;
136
+ private routeKinds: readonly RouteKindEntry[] = [];
120
137
 
121
138
  constructor(private readonly wasmPath: string) {}
122
139
 
@@ -137,6 +154,7 @@ export class WasmServerModule {
137
154
  mtimeMs = fs.statSync(this.wasmPath).mtimeMs;
138
155
  } catch {
139
156
  this.module = null;
157
+ this.routeKinds = [];
140
158
  this.loadedMtimeMs = -1;
141
159
  return false;
142
160
  }
@@ -149,6 +167,7 @@ export class WasmServerModule {
149
167
  // Refresh collection -> current schema_version so writes stamp the live layout;
150
168
  // after a @data type evolves + rebuild, old on-disk rows now look out of date.
151
169
  setDbCatalog(bytes);
170
+ this.routeKinds = parseRouteKinds(bytes);
152
171
  this.module = module;
153
172
  this.loadedMtimeMs = mtimeMs;
154
173
  return true;
@@ -167,6 +186,20 @@ export class WasmServerModule {
167
186
  const ref: MemoryRef = { memory: null };
168
187
  const state = freshDispatchState();
169
188
  state.clientIp = req.clientIp ?? '';
189
+ // Enforce per-route DB-kind gating ONLY when the guest declares its route
190
+ // kinds (the `toildb.route_kinds` custom section). A guest built with a
191
+ // toolchain that does not emit that section leaves `routeKinds` empty;
192
+ // inferring a kind from the HTTP method and enforcing it would wrongly
193
+ // reject legitimate bounded reads (e.g. a GET that reads `events.latest`,
194
+ // a scan-class op denied in `Query`). With no declarations we keep the
195
+ // unenforced default (`Job`, see `freshDbState`).
196
+ const routeKind = routeKindForRequest(this.routeKinds, req.method, req.path);
197
+ state.db.functionKind =
198
+ this.routeKinds.length === 0
199
+ ? DbFunctionKind.Job
200
+ : routeKind === DbFunctionKind.Query
201
+ ? DbFunctionKind.Query
202
+ : dbKindForHttpMethod(req.method);
170
203
  const instance = new WebAssembly.Instance(this.module, buildHostImports(ref, state));
171
204
  const exports = instance.exports as unknown as HandleExports;
172
205
  ref.memory = exports.memory;
@@ -228,6 +261,7 @@ export class WasmServerModule {
228
261
  const ref: MemoryRef = { memory: null };
229
262
  const state = freshDispatchState();
230
263
  state.clientIp = req.clientIp ?? '';
264
+ state.db.functionKind = DbFunctionKind.Query;
231
265
  const instance = new WebAssembly.Instance(this.module, buildHostImports(ref, state));
232
266
  const exports = instance.exports as unknown as HandleExports & {
233
267
  render?: (reqOfs: number, reqLen: number) => bigint;