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.
- package/.github/workflows/ci.yml +31 -0
- package/CHANGELOG.md +5 -0
- package/build/cli/.tsbuildinfo +1 -1
- package/build/cli/index.js +2 -2
- package/build/client/.tsbuildinfo +1 -1
- package/build/client/index.d.ts +1 -1
- package/build/client/index.js +1 -1
- package/build/client/routing/mount.js +12 -1
- package/build/client/ssr/markers.d.ts +1 -0
- package/build/client/ssr/markers.js +3 -0
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/config.d.ts +21 -0
- package/build/compiler/config.js +35 -0
- package/build/compiler/docs.d.ts +2 -1
- package/build/compiler/docs.js +33 -304
- package/build/compiler/index.d.ts +13 -0
- package/build/compiler/index.js +113 -21
- package/build/compiler/template-build.d.ts +21 -1
- package/build/compiler/template-build.js +110 -26
- package/build/compiler/toil-docs.generated.d.ts +1 -0
- package/build/compiler/toil-docs.generated.js +20 -0
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/daemon/catalog.d.ts +26 -0
- package/build/devserver/daemon/catalog.js +48 -0
- package/build/devserver/daemon/cron.d.ts +4 -0
- package/build/devserver/daemon/cron.js +50 -0
- package/build/devserver/daemon/host.d.ts +37 -0
- package/build/devserver/daemon/host.js +94 -0
- package/build/devserver/daemon/index.d.ts +34 -0
- package/build/devserver/daemon/index.js +241 -0
- package/build/devserver/db/catalog.d.ts +2 -1
- package/build/devserver/db/catalog.js +44 -44
- package/build/devserver/db/database.d.ts +27 -11
- package/build/devserver/db/database.js +539 -169
- package/build/devserver/db/index.d.ts +1 -1
- package/build/devserver/db/index.js +1 -1
- package/build/devserver/db/routeKinds.d.ts +8 -0
- package/build/devserver/db/routeKinds.js +139 -0
- package/build/devserver/db/types.d.ts +64 -1
- package/build/devserver/db/types.js +33 -1
- package/build/devserver/index.d.ts +10 -0
- package/build/devserver/index.js +7 -0
- package/build/devserver/mstore/store.d.ts +18 -0
- package/build/devserver/mstore/store.js +82 -0
- package/build/devserver/runtime/host.d.ts +6 -0
- package/build/devserver/runtime/host.js +45 -1
- package/build/devserver/runtime/module.d.ts +1 -0
- package/build/devserver/runtime/module.js +27 -1
- package/build/devserver/server.d.ts +6 -0
- package/build/devserver/server.js +59 -0
- package/build/devserver/ssr.d.ts +25 -0
- package/build/devserver/ssr.js +114 -0
- package/build/devserver/wasm/sections.d.ts +2 -0
- package/build/devserver/wasm/sections.js +42 -0
- package/build/devserver/wasm/surface.d.ts +18 -0
- package/build/devserver/wasm/surface.js +41 -0
- package/docs/README.md +4 -4
- package/docs/auth-todo.md +6 -6
- package/docs/caching.md +5 -5
- package/docs/cli.md +15 -0
- package/docs/client.md +40 -0
- package/docs/crypto.md +4 -4
- package/docs/data.md +6 -6
- package/docs/email.md +28 -28
- package/docs/environment.md +10 -10
- package/docs/index.md +26 -0
- package/docs/ratelimit.md +10 -10
- package/docs/routing.md +2 -2
- package/docs/server.md +61 -0
- package/docs/ssr.md +561 -113
- package/docs/styling.md +22 -0
- package/docs/time.md +1 -1
- package/eslint.config.js +10 -1
- package/examples/basic/client/components/Header.tsx +3 -0
- package/examples/basic/client/routes/features/actions.tsx +0 -2
- package/examples/basic/client/routes/hello.tsx +89 -19
- package/examples/basic/client/styles/main.css +48 -0
- package/examples/basic/server/SsrHelloRender.ts +97 -0
- package/examples/basic/server/main.ts +5 -0
- package/examples/basic/server/streams/Echo.ts +49 -0
- package/package.json +12 -10
- package/scripts/gen-toil-docs.mjs +96 -0
- package/src/cli/create.ts +2 -2
- package/src/client/index.ts +1 -1
- package/src/client/routing/mount.tsx +18 -2
- package/src/client/ssr/markers.tsx +22 -0
- package/src/compiler/config.ts +88 -2
- package/src/compiler/docs.ts +47 -308
- package/src/compiler/index.ts +236 -32
- package/src/compiler/ssr-codegen.ts +1 -1
- package/src/compiler/template-build.ts +247 -46
- package/src/compiler/toil-docs.generated.ts +26 -0
- package/src/devserver/daemon/catalog.ts +120 -0
- package/src/devserver/daemon/cron.ts +87 -0
- package/src/devserver/daemon/host.ts +224 -0
- package/src/devserver/daemon/index.ts +349 -0
- package/src/devserver/db/catalog.ts +61 -53
- package/src/devserver/db/database.ts +613 -149
- package/src/devserver/db/index.ts +1 -1
- package/src/devserver/db/routeKinds.ts +147 -0
- package/src/devserver/db/types.ts +65 -2
- package/src/devserver/index.ts +12 -0
- package/src/devserver/mstore/store.ts +121 -0
- package/src/devserver/runtime/host.ts +92 -1
- package/src/devserver/runtime/module.ts +35 -1
- package/src/devserver/server.ts +101 -0
- package/src/devserver/ssr.ts +166 -0
- package/src/devserver/wasm/sections.ts +59 -0
- package/src/devserver/wasm/surface.ts +88 -0
- package/test/daemon-build.test.ts +198 -0
- package/test/daemon-catalog.test.ts +265 -0
- package/test/daemon-emulation.test.ts +216 -0
- package/test/devserver-database.test.ts +396 -5
- package/test/email-preview.test.ts +6 -1
- package/test/fixtures/daemon-app.ts +56 -0
- package/test/global-setup.ts +17 -0
- package/test/ssr-render.test.ts +94 -27
- package/test/ssr-template.test.tsx +44 -1
- package/vitest.config.ts +3 -0
|
@@ -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:
|
|
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 {
|
|
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
|
package/src/devserver/index.ts
CHANGED
|
@@ -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;
|