toiljs 0.0.59 → 0.0.60
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/CHANGELOG.md +10 -0
- package/build/cli/.tsbuildinfo +1 -1
- package/build/cli/index.js +309 -116
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/db/catalog.d.ts +1 -0
- package/build/devserver/db/catalog.js +80 -0
- package/build/devserver/db/database.d.ts +64 -0
- package/build/devserver/db/database.js +662 -0
- package/build/devserver/db/index.d.ts +3 -0
- package/build/devserver/db/index.js +3 -0
- package/build/devserver/db/types.d.ts +58 -0
- package/build/devserver/db/types.js +20 -0
- package/build/devserver/email/index.js +1 -1
- package/build/devserver/index.d.ts +9 -24
- package/build/devserver/index.js +4 -165
- package/build/devserver/{host.d.ts → runtime/host.d.ts} +1 -1
- package/build/devserver/{host.js → runtime/host.js} +6 -6
- package/build/devserver/{module.d.ts → runtime/module.d.ts} +1 -1
- package/build/devserver/{module.js → runtime/module.js} +8 -1
- package/build/devserver/server.d.ts +17 -0
- package/build/devserver/server.js +164 -0
- package/docs/time.md +2 -2
- package/examples/basic/server/migrations/GuestEntry.migration.ts +39 -0
- package/package.json +2 -2
- package/server/runtime/time.ts +3 -3
- package/src/cli/create.ts +38 -1
- package/src/cli/db.ts +158 -0
- package/src/cli/diagnostics.ts +19 -0
- package/src/cli/doctor.ts +20 -0
- package/src/cli/index.ts +10 -0
- package/src/cli/update.ts +58 -0
- package/src/devserver/db/catalog.ts +100 -0
- package/src/devserver/db/database.ts +1169 -0
- package/src/devserver/db/index.ts +18 -0
- package/src/devserver/db/types.ts +76 -0
- package/src/devserver/email/index.ts +1 -1
- package/src/devserver/index.ts +19 -287
- package/src/devserver/{host.ts → runtime/host.ts} +6 -6
- package/src/devserver/{module.ts → runtime/module.ts} +13 -1
- package/src/devserver/server.ts +292 -0
- package/test/db.test.ts +0 -0
- package/test/devserver-database.test.ts +114 -9
- package/test/devserver-pqauth.test.ts +1 -1
- package/test/devserver-secrets.test.ts +5 -1
- package/test/doctor.test.ts +13 -0
- package/test/example-guestbook.test.ts +43 -1
- package/test/pqauth-e2e.test.ts +1 -1
- package/build/devserver/database.d.ts +0 -8
- package/build/devserver/database.js +0 -418
- package/src/devserver/database.ts +0 -618
- /package/build/devserver/{dotenv.d.ts → config/dotenv.d.ts} +0 -0
- /package/build/devserver/{dotenv.js → config/dotenv.js} +0 -0
- /package/build/devserver/{env.d.ts → config/env.d.ts} +0 -0
- /package/build/devserver/{env.js → config/env.js} +0 -0
- /package/build/devserver/{ratelimit.d.ts → config/ratelimit.d.ts} +0 -0
- /package/build/devserver/{ratelimit.js → config/ratelimit.js} +0 -0
- /package/build/devserver/{cache.d.ts → http/cache.d.ts} +0 -0
- /package/build/devserver/{cache.js → http/cache.js} +0 -0
- /package/build/devserver/{envelope.d.ts → http/envelope.d.ts} +0 -0
- /package/build/devserver/{envelope.js → http/envelope.js} +0 -0
- /package/build/devserver/{proxy.d.ts → http/proxy.d.ts} +0 -0
- /package/build/devserver/{proxy.js → http/proxy.js} +0 -0
- /package/build/devserver/{crypto.d.ts → runtime/crypto.d.ts} +0 -0
- /package/build/devserver/{crypto.js → runtime/crypto.js} +0 -0
- /package/src/devserver/{dotenv.ts → config/dotenv.ts} +0 -0
- /package/src/devserver/{env.ts → config/env.ts} +0 -0
- /package/src/devserver/{ratelimit.ts → config/ratelimit.ts} +0 -0
- /package/src/devserver/{cache.ts → http/cache.ts} +0 -0
- /package/src/devserver/{envelope.ts → http/envelope.ts} +0 -0
- /package/src/devserver/{proxy.ts → http/proxy.ts} +0 -0
- /package/src/devserver/{crypto.ts → runtime/crypto.ts} +0 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The toiljs WASM dev server: a uWebSockets.js front (via @dacely/hyper-express,
|
|
3
|
+
* the same stack as `toiljs/backend`) that dispatches HTTP requests into the
|
|
4
|
+
* ToilScript-compiled server wasm exactly like the production edge does, and
|
|
5
|
+
* proxies everything the server does not claim to an internal Vite dev server,
|
|
6
|
+
* so dev keeps 100% of Vite's behavior (HMR, transforms, toolbar endpoints,
|
|
7
|
+
* public assets, SPA fallback).
|
|
8
|
+
*
|
|
9
|
+
* Request flow:
|
|
10
|
+
*
|
|
11
|
+
* browser ── uWS :port ──► wasm `handle()` (fresh instance, envelope ABI)
|
|
12
|
+
* │ │
|
|
13
|
+
* │ └─ "unhandled" marker (no route matched)
|
|
14
|
+
* ▼ │
|
|
15
|
+
* Vite dev server (loopback) ◄──────────────┘
|
|
16
|
+
*
|
|
17
|
+
* Dev intentionally skips the edge's metering, gas, pooling and snapshot-reset
|
|
18
|
+
* machinery; the ABI (envelope layout, `handle(ofs, len) -> i64`, host import
|
|
19
|
+
* surface, trap isolation) is identical so a server that runs here runs there.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
|
|
25
|
+
import { type Request, type Response, Server } from '@dacely/hyper-express';
|
|
26
|
+
import pc from 'picocolors';
|
|
27
|
+
|
|
28
|
+
import type { EmailBackendConfig } from 'toiljs/shared';
|
|
29
|
+
|
|
30
|
+
import { configureDbPersistence } from './db/index.js';
|
|
31
|
+
import { initEmailService } from './email/index.js';
|
|
32
|
+
import { applyCacheRule, lookupCache } from './http/cache.js';
|
|
33
|
+
import { type EnvelopeRequest, METHOD_CODES } from './http/envelope.js';
|
|
34
|
+
import { proxyToVite, type ViteTarget, wireWebsocketProxy } from './http/proxy.js';
|
|
35
|
+
import { WasmServerModule } from './runtime/module.js';
|
|
36
|
+
|
|
37
|
+
const DEFAULT_MAX_BODY_LENGTH = 1024 * 1024 * 8;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Paths that are Vite's own by construction; skipping the wasm round-trip for
|
|
41
|
+
* them keeps the hot path of module serving untouched. Everything else is
|
|
42
|
+
* offered to the server first (it answers or yields via the unhandled marker).
|
|
43
|
+
*/
|
|
44
|
+
const VITE_PREFIXES = ['/@', '/node_modules/', '/__toil/'];
|
|
45
|
+
|
|
46
|
+
/** Minimal type map for `respond_file` bodies when the guest set no content-type. */
|
|
47
|
+
const MIME: Readonly<Record<string, string>> = {
|
|
48
|
+
'.html': 'text/html; charset=utf-8',
|
|
49
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
50
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
51
|
+
'.css': 'text/css; charset=utf-8',
|
|
52
|
+
'.json': 'application/json; charset=utf-8',
|
|
53
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
54
|
+
'.svg': 'image/svg+xml',
|
|
55
|
+
'.png': 'image/png',
|
|
56
|
+
'.jpg': 'image/jpeg',
|
|
57
|
+
'.jpeg': 'image/jpeg',
|
|
58
|
+
'.webp': 'image/webp',
|
|
59
|
+
'.avif': 'image/avif',
|
|
60
|
+
'.gif': 'image/gif',
|
|
61
|
+
'.ico': 'image/x-icon',
|
|
62
|
+
'.wasm': 'application/wasm',
|
|
63
|
+
'.woff2': 'font/woff2',
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** Options for {@link startDevServer}. */
|
|
67
|
+
export interface DevServerOptions {
|
|
68
|
+
/** Project root; `respond_file` paths resolve against it (and may not escape it). */
|
|
69
|
+
readonly root: string;
|
|
70
|
+
/** Public listening port (the one the browser opens). */
|
|
71
|
+
readonly port: number;
|
|
72
|
+
/** Bind host. Default `127.0.0.1`. */
|
|
73
|
+
readonly host?: string;
|
|
74
|
+
/** Absolute path to the ToilScript server wasm (toilconfig `targets.release.outFile`). */
|
|
75
|
+
readonly wasmFile: string;
|
|
76
|
+
/** The internal Vite dev server to proxy unclaimed traffic to. */
|
|
77
|
+
readonly vite: ViteTarget;
|
|
78
|
+
/** Max request body bytes. Default 8 MB. */
|
|
79
|
+
readonly maxBodyLength?: number;
|
|
80
|
+
/**
|
|
81
|
+
* The `toil.config.ts` `server.email` section (non-secret). When set (and the
|
|
82
|
+
* API key is in `.env.secrets`), `EmailService.send` really sends in dev;
|
|
83
|
+
* otherwise it stays a log-only mock. See `./email`.
|
|
84
|
+
*/
|
|
85
|
+
readonly email?: EmailBackendConfig;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A running dev server. */
|
|
89
|
+
export interface RunningDevServer {
|
|
90
|
+
readonly port: number;
|
|
91
|
+
readonly host: string;
|
|
92
|
+
/** Gracefully shuts the front server down (the Vite server is owned by the caller). */
|
|
93
|
+
close(): Promise<void>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** True for requests that belong to Vite by construction (never offered to the wasm). */
|
|
97
|
+
function isViteInternal(url: string): boolean {
|
|
98
|
+
return VITE_PREFIXES.some((p) => url.startsWith(p));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Resolves a guest `respond_file` path inside `root`, refusing traversal outside it. */
|
|
102
|
+
function resolveSendfile(root: string, file: string): string | null {
|
|
103
|
+
const resolved = path.resolve(root, file);
|
|
104
|
+
if (resolved !== root && !resolved.startsWith(root + path.sep)) return null;
|
|
105
|
+
if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) return null;
|
|
106
|
+
return resolved;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Builds the envelope request for one incoming HTTP request. */
|
|
110
|
+
async function toEnvelopeRequest(request: Request): Promise<EnvelopeRequest> {
|
|
111
|
+
const hasBody = request.method !== 'GET' && request.method !== 'HEAD';
|
|
112
|
+
const body = hasBody ? new Uint8Array(await request.buffer()) : new Uint8Array(0);
|
|
113
|
+
// Dev parity for `client_ip`: the edge keys on the unspoofable socket peer,
|
|
114
|
+
// but the dev server has no DPDK socket, so best-effort from a proxy's
|
|
115
|
+
// `x-forwarded-for`, else localhost, so `ctx.clientIp()` returns a value.
|
|
116
|
+
const xff = request.headers['x-forwarded-for'];
|
|
117
|
+
const clientIp =
|
|
118
|
+
typeof xff === 'string' && xff.length > 0 ? xff.split(',')[0]!.trim() : '127.0.0.1';
|
|
119
|
+
return {
|
|
120
|
+
method: request.method,
|
|
121
|
+
// `url` keeps the query string; the guest's RouteContext parses it off the path.
|
|
122
|
+
path: request.url,
|
|
123
|
+
headers: Object.entries(request.headers),
|
|
124
|
+
body,
|
|
125
|
+
clientIp,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Sends a shaped wasm response, mirroring the edge's response defaults. */
|
|
130
|
+
function sendWasmResponse(
|
|
131
|
+
response: Response,
|
|
132
|
+
root: string,
|
|
133
|
+
result: {
|
|
134
|
+
status: number;
|
|
135
|
+
headers: readonly (readonly [string, string])[];
|
|
136
|
+
body: Uint8Array;
|
|
137
|
+
sendfile: string | null;
|
|
138
|
+
},
|
|
139
|
+
): void {
|
|
140
|
+
response.status(result.status);
|
|
141
|
+
let hasContentType = false;
|
|
142
|
+
for (const [name, value] of result.headers) {
|
|
143
|
+
if (name.toLowerCase() === 'content-type') hasContentType = true;
|
|
144
|
+
response.header(name, value);
|
|
145
|
+
}
|
|
146
|
+
response.header('server', 'toil-dev');
|
|
147
|
+
|
|
148
|
+
if (result.sendfile !== null) {
|
|
149
|
+
const file = resolveSendfile(root, result.sendfile);
|
|
150
|
+
if (file === null) {
|
|
151
|
+
response.status(404).send('not found\n');
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (!hasContentType) {
|
|
155
|
+
// The edge defaults file bodies to application/octet-stream; in dev we
|
|
156
|
+
// guess from the extension so a guest-served asset renders in the browser.
|
|
157
|
+
response.header(
|
|
158
|
+
'content-type',
|
|
159
|
+
MIME[path.extname(file).toLowerCase()] ?? 'application/octet-stream',
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
response.sendFile(file);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (!hasContentType) response.header('content-type', 'text/plain; charset=utf-8');
|
|
167
|
+
response.send(Buffer.from(result.body.buffer, result.body.byteOffset, result.body.length));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Starts the front server. The caller owns the Vite dev server (start it on a
|
|
172
|
+
* loopback port first) and the toilscript rebuild watcher; this watches only
|
|
173
|
+
* the wasm artifact and hot-swaps the compiled module when it changes.
|
|
174
|
+
*/
|
|
175
|
+
export async function startDevServer(options: DevServerOptions): Promise<RunningDevServer> {
|
|
176
|
+
const host = options.host ?? '127.0.0.1';
|
|
177
|
+
const root = path.resolve(options.root);
|
|
178
|
+
|
|
179
|
+
// Wire the email service from toil.config `server.email` + `.env.secrets`
|
|
180
|
+
// (TOIL_EMAIL_*). Configured -> real sends; otherwise the import stays a
|
|
181
|
+
// log-only mock. A partial-but-invalid config logs why it stayed off.
|
|
182
|
+
const emailInit = initEmailService(root, options.email);
|
|
183
|
+
if (emailInit.service !== null) {
|
|
184
|
+
process.stdout.write(pc.dim(` ✉ email enabled: ${emailInit.note}`) + '\n');
|
|
185
|
+
} else if (emailInit.note !== null) {
|
|
186
|
+
process.stdout.write(pc.yellow(' ! ') + pc.dim(`email off: ${emailInit.note}`) + '\n');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const module = new WasmServerModule(options.wasmFile);
|
|
190
|
+
|
|
191
|
+
// Persist dev DB data under the project's .toil/ so records, events, and their
|
|
192
|
+
// schema_versions survive restarts (delete .toil/devdata.json to reset). Only
|
|
193
|
+
// the running dev server persists; tests that construct WasmServerModule
|
|
194
|
+
// directly stay purely in-memory.
|
|
195
|
+
configureDbPersistence(path.join(root, '.toil', 'devdata.json'));
|
|
196
|
+
|
|
197
|
+
let warnedMissing = false;
|
|
198
|
+
let loadedOnce = false;
|
|
199
|
+
const refresh = (): void => {
|
|
200
|
+
try {
|
|
201
|
+
if (module.refresh() && loadedOnce) {
|
|
202
|
+
process.stdout.write(pc.green(' ✓ ') + pc.dim('server module reloaded') + '\n');
|
|
203
|
+
}
|
|
204
|
+
loadedOnce ||= module.available;
|
|
205
|
+
} catch (e) {
|
|
206
|
+
process.stdout.write(pc.red(` ✗ server wasm failed to load: ${String(e)}`) + '\n');
|
|
207
|
+
}
|
|
208
|
+
if (!module.available && !warnedMissing) {
|
|
209
|
+
warnedMissing = true;
|
|
210
|
+
process.stdout.write(
|
|
211
|
+
pc.yellow(' ! ') +
|
|
212
|
+
pc.dim(`server wasm not found at ${options.wasmFile}; serving client only`) +
|
|
213
|
+
'\n',
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
refresh();
|
|
218
|
+
|
|
219
|
+
const app = new Server({
|
|
220
|
+
max_body_length: options.maxBodyLength ?? DEFAULT_MAX_BODY_LENGTH,
|
|
221
|
+
max_body_buffer: 1024 * 32,
|
|
222
|
+
fast_abort: true,
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
app.set_error_handler((_request: Request, response: Response, error: Error) => {
|
|
226
|
+
if (response.completed) return;
|
|
227
|
+
response.atomic(() => {
|
|
228
|
+
response.status(500).send(`internal error: ${error.message}\n`);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
wireWebsocketProxy(app, options.vite);
|
|
233
|
+
|
|
234
|
+
app.any('/*', async (request: Request, response: Response) => {
|
|
235
|
+
response.removeHeader('uWebSockets');
|
|
236
|
+
|
|
237
|
+
const dispatchable =
|
|
238
|
+
!isViteInternal(request.url) && METHOD_CODES[request.method] !== undefined;
|
|
239
|
+
if (dispatchable) refresh();
|
|
240
|
+
|
|
241
|
+
if (dispatchable && module.available) {
|
|
242
|
+
const envelopeReq = await toEnvelopeRequest(request);
|
|
243
|
+
// Honor the tenant cache directive locally, same rules as the
|
|
244
|
+
// edge: serve an identical request from the per-process cache,
|
|
245
|
+
// else dispatch and apply/strip the directive on the response.
|
|
246
|
+
const cacheHost = request.headers.host ?? 'dev';
|
|
247
|
+
const hasAuth =
|
|
248
|
+
request.headers.cookie !== undefined || request.headers.authorization !== undefined;
|
|
249
|
+
const cached = lookupCache(cacheHost, request.method, request.url, envelopeReq.body);
|
|
250
|
+
if (cached !== null) {
|
|
251
|
+
sendWasmResponse(response, root, cached);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
try {
|
|
255
|
+
const result = module.dispatch(envelopeReq);
|
|
256
|
+
if (!result.unhandled) {
|
|
257
|
+
const finalized = applyCacheRule(
|
|
258
|
+
cacheHost,
|
|
259
|
+
request.method,
|
|
260
|
+
request.url,
|
|
261
|
+
envelopeReq.body,
|
|
262
|
+
hasAuth,
|
|
263
|
+
result,
|
|
264
|
+
);
|
|
265
|
+
sendWasmResponse(response, root, finalized);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
} catch (e) {
|
|
269
|
+
// A trap (ToilScript abort, OOB, malformed envelope) is isolated to
|
|
270
|
+
// this request, exactly like the edge poisoning one instance.
|
|
271
|
+
process.stdout.write(
|
|
272
|
+
pc.red(` ✗ ${request.method} ${request.path} server error: ${String(e)}`) +
|
|
273
|
+
'\n',
|
|
274
|
+
);
|
|
275
|
+
response.status(500).send('internal error\n');
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
await proxyToVite(request, response, options.vite);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
await app.listen(options.port, host);
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
port: options.port,
|
|
287
|
+
host,
|
|
288
|
+
close: async (): Promise<void> => {
|
|
289
|
+
await app.shutdown();
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
}
|
package/test/db.test.ts
ADDED
|
Binary file
|
|
@@ -1,7 +1,18 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
1
5
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
2
6
|
|
|
3
|
-
import {
|
|
4
|
-
|
|
7
|
+
import {
|
|
8
|
+
__resetDbForTests,
|
|
9
|
+
__setDbCatalogForTests,
|
|
10
|
+
buildDatabaseImports,
|
|
11
|
+
configureDbPersistence,
|
|
12
|
+
freshDbState,
|
|
13
|
+
persistDb,
|
|
14
|
+
} from '../src/devserver/db/index.js';
|
|
15
|
+
import type { MemoryRef } from '../src/devserver/runtime/host.js';
|
|
5
16
|
|
|
6
17
|
function setup() {
|
|
7
18
|
const memory = new WebAssembly.Memory({ initial: 1 });
|
|
@@ -37,7 +48,7 @@ describe('toildb dev emulator (record family)', () => {
|
|
|
37
48
|
const [vPtr, vLen] = put(buf, 48, 'hello');
|
|
38
49
|
|
|
39
50
|
expect(imports['data.create'](h, kPtr, kLen, vPtr, vLen, 0)).toBe(0);
|
|
40
|
-
expect(imports['data.create'](h, kPtr, kLen, vPtr, vLen, 0)).toBe(-
|
|
51
|
+
expect(imports['data.create'](h, kPtr, kLen, vPtr, vLen, 0)).toBe(-1003); // AlreadyExists
|
|
41
52
|
expect(imports['data.exists'](h, kPtr, kLen)).toBe(1);
|
|
42
53
|
|
|
43
54
|
expect(imports['data.get'](h, kPtr, kLen)).toBe(5);
|
|
@@ -64,7 +75,7 @@ describe('toildb dev emulator (record family)', () => {
|
|
|
64
75
|
const h = resolve(imports, buf, 'App/users');
|
|
65
76
|
const [kPtr, kLen] = put(buf, 32, 'ghost');
|
|
66
77
|
const [pPtr, pLen] = put(buf, 48, 'x');
|
|
67
|
-
expect(imports['data.patch'](h, kPtr, kLen, pPtr, pLen, 0)).toBe(-
|
|
78
|
+
expect(imports['data.patch'](h, kPtr, kLen, pPtr, pLen, 0)).toBe(-2); // NotFound -> ABSENT
|
|
68
79
|
});
|
|
69
80
|
|
|
70
81
|
it('consume-once get_delete deletes exactly once', () => {
|
|
@@ -124,7 +135,7 @@ describe('toildb dev emulator (record family)', () => {
|
|
|
124
135
|
const [u2Ptr, u2Len] = put(buf, 64, 'user_2');
|
|
125
136
|
imports['data.unique_claim'](h, kPtr, kLen, u1Ptr, u1Len, 0);
|
|
126
137
|
|
|
127
|
-
expect(imports['data.unique_release'](h, kPtr, kLen, u2Ptr, u2Len, 0)).toBe(-
|
|
138
|
+
expect(imports['data.unique_release'](h, kPtr, kLen, u2Ptr, u2Len, 0)).toBe(-1004); // not owner
|
|
128
139
|
expect(imports['data.unique_release'](h, kPtr, kLen, u1Ptr, u1Len, 0)).toBe(0); // owner releases
|
|
129
140
|
expect(imports['data.unique_lookup'](h, kPtr, kLen)).toBe(-2); // gone
|
|
130
141
|
});
|
|
@@ -162,6 +173,7 @@ describe('toildb dev emulator (record family)', () => {
|
|
|
162
173
|
got.push(null);
|
|
163
174
|
continue;
|
|
164
175
|
}
|
|
176
|
+
p += 4; // per-item schema_version (0 in dev)
|
|
165
177
|
const len = buf.readUInt32LE(p);
|
|
166
178
|
p += 4;
|
|
167
179
|
got.push(buf.toString('utf8', p, p + len));
|
|
@@ -194,6 +206,7 @@ describe('toildb dev emulator (record family)', () => {
|
|
|
194
206
|
expect(count).toBe(2);
|
|
195
207
|
const out: string[] = [];
|
|
196
208
|
for (let i = 0; i < count; i++) {
|
|
209
|
+
off += 4; // per-item schema_version (0 in dev)
|
|
197
210
|
const len = buf.readUInt32LE(off);
|
|
198
211
|
off += 4;
|
|
199
212
|
out.push(buf.toString('utf8', off, off + len));
|
|
@@ -274,6 +287,7 @@ describe('toildb dev emulator (record family)', () => {
|
|
|
274
287
|
expect(count).toBe(2);
|
|
275
288
|
const out: string[] = [];
|
|
276
289
|
for (let i = 0; i < count; i++) {
|
|
290
|
+
off += 4; // per-item schema_version (0 in dev)
|
|
277
291
|
const len = buf.readUInt32LE(off);
|
|
278
292
|
off += 4;
|
|
279
293
|
out.push(buf.toString('utf8', off, off + len));
|
|
@@ -301,6 +315,36 @@ describe('toildb dev emulator (record family)', () => {
|
|
|
301
315
|
// the same logical key in another collection is absent.
|
|
302
316
|
expect(imports['data.get'](posts, kPtr, kLen)).toBe(-2);
|
|
303
317
|
});
|
|
318
|
+
|
|
319
|
+
it('append_once dedups on eventId; enqueue replaces an existing record', () => {
|
|
320
|
+
const { imports, buf } = setup();
|
|
321
|
+
const feed = resolve(imports, buf, 'App/feed');
|
|
322
|
+
const [kPtr, kLen] = put(buf, 32, 'room1');
|
|
323
|
+
const [idPtr, idLen] = put(buf, 64, 'evt-1');
|
|
324
|
+
const [evPtr, evLen] = put(buf, 96, 'hello');
|
|
325
|
+
// first appendOnce appends (1); the same id is a no-op (0); a new id appends (1).
|
|
326
|
+
expect(imports['data.append_once'](feed, kPtr, kLen, idPtr, idLen, evPtr, evLen)).toBe(1);
|
|
327
|
+
expect(imports['data.append_once'](feed, kPtr, kLen, idPtr, idLen, evPtr, evLen)).toBe(0);
|
|
328
|
+
const [id2P, id2L] = put(buf, 128, 'evt-2');
|
|
329
|
+
expect(imports['data.append_once'](feed, kPtr, kLen, id2P, id2L, evPtr, evLen)).toBe(1);
|
|
330
|
+
// latest frames exactly 2 events (the duplicate did not double-append).
|
|
331
|
+
const total = imports['data.latest'](feed, kPtr, kLen, 10);
|
|
332
|
+
expect(total).toBeGreaterThan(0);
|
|
333
|
+
imports['data.take_result'](512, total);
|
|
334
|
+
expect(buf.readUInt32LE(512)).toBe(2);
|
|
335
|
+
|
|
336
|
+
// enqueue: absent -> ABSENT (-2); after create -> replaces (0); get sees the new value.
|
|
337
|
+
const docs = resolve(imports, buf, 'App/docs');
|
|
338
|
+
const [dkP, dkL] = put(buf, 160, 'doc1');
|
|
339
|
+
const [v1P, v1L] = put(buf, 192, 'AAAA');
|
|
340
|
+
expect(imports['data.enqueue'](docs, dkP, dkL, v1P, v1L, 0)).toBe(-2);
|
|
341
|
+
expect(imports['data.create'](docs, dkP, dkL, v1P, v1L, 0)).toBe(0);
|
|
342
|
+
const [v2P, v2L] = put(buf, 224, 'BBBB');
|
|
343
|
+
expect(imports['data.enqueue'](docs, dkP, dkL, v2P, v2L, 0)).toBe(0);
|
|
344
|
+
expect(imports['data.get'](docs, dkP, dkL)).toBe(4);
|
|
345
|
+
imports['data.take_result'](256, 4);
|
|
346
|
+
expect(buf.toString('utf8', 256, 260)).toBe('BBBB');
|
|
347
|
+
});
|
|
304
348
|
});
|
|
305
349
|
|
|
306
350
|
type Imports = Record<string, (...args: number[]) => number>;
|
|
@@ -343,9 +387,9 @@ describe('toildb dev emulator (capacity family)', () => {
|
|
|
343
387
|
const id2 = Number(buf.readBigUInt64LE(512));
|
|
344
388
|
expect(imports['data.capacity_confirm'](h, kPtr, kLen, id2, 0)).toBe(1);
|
|
345
389
|
expect(avail(imports, buf, h, kPtr, kLen)).toBe(6n);
|
|
346
|
-
// a confirmed
|
|
390
|
+
// a confirmed sale cannot be cancelled (0); re-confirm is idempotent (1).
|
|
347
391
|
expect(imports['data.capacity_cancel'](h, kPtr, kLen, id2, 0)).toBe(0);
|
|
348
|
-
expect(imports['data.capacity_confirm'](h, kPtr, kLen, id2, 0)).toBe(
|
|
392
|
+
expect(imports['data.capacity_confirm'](h, kPtr, kLen, id2, 0)).toBe(1);
|
|
349
393
|
});
|
|
350
394
|
|
|
351
395
|
it('never oversells (a reserve beyond available is refused)', () => {
|
|
@@ -357,8 +401,69 @@ describe('toildb dev emulator (capacity family)', () => {
|
|
|
357
401
|
// a hold for all 5 succeeds; a further hold for 1 is refused (-2 -> guest 0).
|
|
358
402
|
expect(imports['data.capacity_reserve'](h, kPtr, kLen, 5, 60000, 0)).toBe(8);
|
|
359
403
|
expect(imports['data.capacity_reserve'](h, kPtr, kLen, 1, 60000, 0)).toBe(-2);
|
|
360
|
-
// a non-positive amount is
|
|
361
|
-
expect(imports['data.capacity_reserve'](h, kPtr, kLen, 0, 60000, 0)).toBe(-
|
|
404
|
+
// a non-positive amount is a typed error (BadAmount), invalid handle rejected.
|
|
405
|
+
expect(imports['data.capacity_reserve'](h, kPtr, kLen, 0, 60000, 0)).toBe(-1006);
|
|
362
406
|
expect(imports['data.capacity_available'](999, kPtr, kLen)).toBe(-1001);
|
|
363
407
|
});
|
|
364
408
|
});
|
|
409
|
+
|
|
410
|
+
describe('toildb dev emulator (migration + persistence)', () => {
|
|
411
|
+
const rsv = (imports: Imports): bigint =>
|
|
412
|
+
(imports['data.result_schema_version'] as () => bigint)();
|
|
413
|
+
|
|
414
|
+
it('stamps writes with the catalog schema_version and surfaces it on read', () => {
|
|
415
|
+
const { imports, buf } = setup();
|
|
416
|
+
__setDbCatalogForTests({ 'App/users': 0x1234 });
|
|
417
|
+
const h = resolve(imports, buf, 'App/users');
|
|
418
|
+
const [kPtr, kLen] = put(buf, 32, 'u1');
|
|
419
|
+
const [vPtr, vLen] = put(buf, 64, 'data');
|
|
420
|
+
imports['data.create'](h, kPtr, kLen, vPtr, vLen, 0);
|
|
421
|
+
expect(imports['data.get'](h, kPtr, kLen)).toBe(4);
|
|
422
|
+
expect(rsv(imports)).toBe(0x1234n); // the woven decoder dispatches on this
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it('an evolved @data type leaves old rows stamped with the OLD version', () => {
|
|
426
|
+
const { imports, buf } = setup();
|
|
427
|
+
__setDbCatalogForTests({ 'App/users': 100 }); // version A
|
|
428
|
+
const h = resolve(imports, buf, 'App/users');
|
|
429
|
+
const [kPtr, kLen] = put(buf, 32, 'u1');
|
|
430
|
+
const [vPtr, vLen] = put(buf, 64, 'old');
|
|
431
|
+
imports['data.create'](h, kPtr, kLen, vPtr, vLen, 0);
|
|
432
|
+
// the @data type evolves + the wasm rebuilds -> the catalog version changes.
|
|
433
|
+
__setDbCatalogForTests({ 'App/users': 200 }); // version B
|
|
434
|
+
// a read of the existing row still reports the OLD version -> guest migrates it.
|
|
435
|
+
imports['data.get'](h, kPtr, kLen);
|
|
436
|
+
expect(rsv(imports)).toBe(100n);
|
|
437
|
+
// a NEW write stamps the current version.
|
|
438
|
+
const [k2, kl2] = put(buf, 96, 'u2');
|
|
439
|
+
imports['data.create'](h, k2, kl2, vPtr, vLen, 0);
|
|
440
|
+
imports['data.get'](h, k2, kl2);
|
|
441
|
+
expect(rsv(imports)).toBe(200n);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it('persists data + versions to disk and reloads them', () => {
|
|
445
|
+
const dir = mkdtempSync(join(tmpdir(), 'toildb-'));
|
|
446
|
+
const file = join(dir, 'devdata.json');
|
|
447
|
+
try {
|
|
448
|
+
const a = setup();
|
|
449
|
+
__setDbCatalogForTests({ 'App/users': 777 });
|
|
450
|
+
configureDbPersistence(file);
|
|
451
|
+
const h = resolve(a.imports, a.buf, 'App/users');
|
|
452
|
+
const [kPtr, kLen] = put(a.buf, 32, 'u1');
|
|
453
|
+
const [vPtr, vLen] = put(a.buf, 64, 'persisted');
|
|
454
|
+
a.imports['data.create'](h, kPtr, kLen, vPtr, vLen, 0);
|
|
455
|
+
persistDb();
|
|
456
|
+
|
|
457
|
+
// simulate a restart: wipe memory + catalog, then reload from disk.
|
|
458
|
+
__resetDbForTests();
|
|
459
|
+
configureDbPersistence(file);
|
|
460
|
+
const b = setup();
|
|
461
|
+
const h2 = resolve(b.imports, b.buf, 'App/users');
|
|
462
|
+
const [k2, kl2] = put(b.buf, 32, 'u1');
|
|
463
|
+
expect(b.imports['data.get'](h2, k2, kl2)).toBe(9); // "persisted" survived restart
|
|
464
|
+
expect(rsv(b.imports)).toBe(777n); // and so did its schema_version stamp
|
|
465
|
+
} finally {
|
|
466
|
+
rmSync(dir, { recursive: true, force: true });
|
|
467
|
+
}
|
|
468
|
+
});
|
|
469
|
+
});
|
|
@@ -10,7 +10,7 @@ import { describe, expect, it } from 'vitest';
|
|
|
10
10
|
|
|
11
11
|
import { ml_kem768 } from '@dacely/noble-post-quantum/ml-kem.js';
|
|
12
12
|
|
|
13
|
-
import { buildCryptoImports, freshCryptoState } from '../src/devserver/crypto.js';
|
|
13
|
+
import { buildCryptoImports, freshCryptoState } from '../src/devserver/runtime/crypto.js';
|
|
14
14
|
|
|
15
15
|
type Ref = { memory: WebAssembly.Memory | null };
|
|
16
16
|
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
buildHostImports,
|
|
5
|
+
freshDispatchState,
|
|
6
|
+
type MemoryRef,
|
|
7
|
+
} from '../src/devserver/runtime/host.js';
|
|
4
8
|
|
|
5
9
|
/**
|
|
6
10
|
* The dev host warns (once) when the guest reads a framework auth secret that is unset, since the
|
package/test/doctor.test.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
checkDevScripts,
|
|
7
7
|
checkDuplicatePatterns,
|
|
8
8
|
type CheckGroup,
|
|
9
|
+
checkMigrationsDir,
|
|
9
10
|
checkMountSlots,
|
|
10
11
|
checkNode,
|
|
11
12
|
checkPeer,
|
|
@@ -219,6 +220,18 @@ describe('checkAuthSecrets', () => {
|
|
|
219
220
|
});
|
|
220
221
|
});
|
|
221
222
|
|
|
223
|
+
describe('checkMigrationsDir', () => {
|
|
224
|
+
it('passes when the server/migrations/ folder exists', () => {
|
|
225
|
+
expect(checkMigrationsDir(true).status).toBe('pass');
|
|
226
|
+
});
|
|
227
|
+
it('warns (not fails) when missing, naming the convention and the update fix', () => {
|
|
228
|
+
const c = checkMigrationsDir(false);
|
|
229
|
+
expect(c.status).toBe('warn');
|
|
230
|
+
expect(c.detail).toContain('migration.ts');
|
|
231
|
+
expect(c.fix).toContain('toiljs update');
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
222
235
|
describe('summarize', () => {
|
|
223
236
|
it('tallies pass/warn/fail across groups', () => {
|
|
224
237
|
const groups: CheckGroup[] = [
|
|
@@ -7,13 +7,14 @@
|
|
|
7
7
|
* request". Skips until the example server wasm is built (`npm run build:server`).
|
|
8
8
|
*/
|
|
9
9
|
import fs from 'node:fs';
|
|
10
|
+
import os from 'node:os';
|
|
10
11
|
import path from 'node:path';
|
|
11
12
|
import { fileURLToPath } from 'node:url';
|
|
12
13
|
|
|
13
14
|
import { describe, expect, it, beforeEach } from 'vitest';
|
|
14
15
|
|
|
15
16
|
import { WasmServerModule } from '../src/devserver/index.js';
|
|
16
|
-
import { __resetDbForTests } from '../src/devserver/
|
|
17
|
+
import { __resetDbForTests, configureDbPersistence } from '../src/devserver/db/index.js';
|
|
17
18
|
|
|
18
19
|
const EXAMPLE_WASM = path.resolve(
|
|
19
20
|
path.dirname(fileURLToPath(import.meta.url)),
|
|
@@ -75,4 +76,45 @@ describe.skipIf(!haveWasm)('guestbook demo: ToilDB events + counter persist acro
|
|
|
75
76
|
// A read-only GET on yet another instance sees the same persisted state.
|
|
76
77
|
expect(json(list(load())).total).toBe('2');
|
|
77
78
|
});
|
|
79
|
+
|
|
80
|
+
// End-to-end proof that the `server/migrations/GuestEntry.migration.ts` demo
|
|
81
|
+
// actually RUNS: write an entry under the current shape, downgrade it on disk to
|
|
82
|
+
// the original pre-`at` `GuestEntryV1` layout (drop the trailing u64 + re-stamp
|
|
83
|
+
// with v1's schema_version), then `list()` and confirm the woven decoder ran the
|
|
84
|
+
// `@migrate` - the entry comes back with the new `at` field defaulted to 0.
|
|
85
|
+
it('migrates an on-disk pre-`at` entry on read (the GuestEntry.migration demo fires)', () => {
|
|
86
|
+
const GUEST_ENTRY_V1_VERSION = 631968986; // layoutHash({author:string, message:string})
|
|
87
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gb-mig-'));
|
|
88
|
+
const file = path.join(dir, 'devdata.json');
|
|
89
|
+
try {
|
|
90
|
+
// 1. sign under the CURRENT shape (author, message, at); persistence flushes it.
|
|
91
|
+
configureDbPersistence(file);
|
|
92
|
+
expect(sign(load(), 'Ada', 'from the old days').status).toBe(200);
|
|
93
|
+
|
|
94
|
+
// 2. downgrade that event on disk to the v1 shape: GuestEntry encodes
|
|
95
|
+
// author + message + at(u64), so dropping the trailing 8 bytes yields a
|
|
96
|
+
// valid GuestEntryV1; re-stamp it with v1's schema_version.
|
|
97
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
98
|
+
const snap: any = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
99
|
+
const evKey = Object.keys(snap.events)[0];
|
|
100
|
+
const buf = Buffer.from(snap.events[evKey][0].v, 'base64');
|
|
101
|
+
snap.events[evKey][0] = {
|
|
102
|
+
v: buf.subarray(0, buf.length - 8).toString('base64'),
|
|
103
|
+
sv: GUEST_ENTRY_V1_VERSION,
|
|
104
|
+
};
|
|
105
|
+
fs.writeFileSync(file, JSON.stringify(snap));
|
|
106
|
+
|
|
107
|
+
// 3. reload + list: the read surfaces v1's version, so the guest's woven
|
|
108
|
+
// decoder runs the @migrate, copying author/message and defaulting at=0.
|
|
109
|
+
__resetDbForTests();
|
|
110
|
+
configureDbPersistence(file);
|
|
111
|
+
const v = json(list(load()));
|
|
112
|
+
expect(v.entries.length).toBe(1);
|
|
113
|
+
expect(v.entries[0].author).toBe('Ada');
|
|
114
|
+
expect(v.entries[0].message).toBe('from the old days');
|
|
115
|
+
expect(String(v.entries[0].at)).toBe('0'); // the migrated-in field
|
|
116
|
+
} finally {
|
|
117
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
118
|
+
}
|
|
119
|
+
});
|
|
78
120
|
});
|
package/test/pqauth-e2e.test.ts
CHANGED
|
@@ -20,7 +20,7 @@ import { describe, expect, it, beforeEach, afterEach } from 'vitest';
|
|
|
20
20
|
import { ristretto255_oprf } from '@noble/curves/ed25519.js';
|
|
21
21
|
|
|
22
22
|
import { WasmServerModule } from '../src/devserver/index.js';
|
|
23
|
-
import { __resetDbForTests } from '../src/devserver/
|
|
23
|
+
import { __resetDbForTests } from '../src/devserver/db/index.js';
|
|
24
24
|
import { Auth } from '../src/client/auth.js';
|
|
25
25
|
import { DataReader, DataWriter } from '../src/io/codec.js';
|
|
26
26
|
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
import type { MemoryRef } from './host.js';
|
|
2
|
-
export interface DbDevState {
|
|
3
|
-
handles: string[];
|
|
4
|
-
lastResult: Buffer | null;
|
|
5
|
-
}
|
|
6
|
-
export declare function freshDbState(): DbDevState;
|
|
7
|
-
export declare function buildDatabaseImports(ref: MemoryRef, db: DbDevState): Record<string, (...args: number[]) => number | bigint>;
|
|
8
|
-
export declare function __resetDbForTests(): void;
|