toiljs 0.0.60 → 0.0.62
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 +17 -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 +11 -26
- package/build/client/ssr/markers.d.ts +1 -0
- package/build/client/ssr/markers.js +9 -2
- 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 +23 -3
- package/build/compiler/template-build.js +120 -30
- 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 +19 -31
- package/src/client/ssr/markers.tsx +33 -4
- 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 +271 -53
- 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-hydration.test.tsx +107 -0
- package/test/ssr-render.test.ts +96 -27
- package/test/ssr-template.test.tsx +47 -2
- package/vitest.config.ts +3 -0
|
@@ -5,16 +5,34 @@ import { join } from 'node:path';
|
|
|
5
5
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
|
+
CollectionFamily,
|
|
9
|
+
DbFunctionKind,
|
|
8
10
|
__resetDbForTests,
|
|
9
11
|
__setDbCatalogForTests,
|
|
10
12
|
buildDatabaseImports,
|
|
11
13
|
configureDbPersistence,
|
|
12
14
|
freshDbState,
|
|
13
15
|
persistDb,
|
|
16
|
+
setDbCatalog,
|
|
14
17
|
} from '../src/devserver/db/index.js';
|
|
18
|
+
import { parseRouteKinds, routeKindForRequest } from '../src/devserver/db/routeKinds.js';
|
|
15
19
|
import type { MemoryRef } from '../src/devserver/runtime/host.js';
|
|
16
20
|
|
|
17
|
-
|
|
21
|
+
const DEFAULT_CATALOG = {
|
|
22
|
+
'App/users': { family: CollectionFamily.Record },
|
|
23
|
+
'App/posts': { family: CollectionFamily.Record },
|
|
24
|
+
'App/docs': { family: CollectionFamily.Record },
|
|
25
|
+
'App/challenges': { family: CollectionFamily.Record },
|
|
26
|
+
'App/pages': { family: CollectionFamily.View },
|
|
27
|
+
'App/feed': { family: CollectionFamily.Events },
|
|
28
|
+
'App/likes': { family: CollectionFamily.Counter },
|
|
29
|
+
'App/rooms': { family: CollectionFamily.Membership },
|
|
30
|
+
'App/usernames': { family: CollectionFamily.Unique },
|
|
31
|
+
'App/seats': { family: CollectionFamily.Capacity },
|
|
32
|
+
'App/tickets': { family: CollectionFamily.Capacity },
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function setupRaw() {
|
|
18
36
|
const memory = new WebAssembly.Memory({ initial: 1 });
|
|
19
37
|
const ref: MemoryRef = { memory };
|
|
20
38
|
const db = freshDbState();
|
|
@@ -23,6 +41,11 @@ function setup() {
|
|
|
23
41
|
return { ref, db, imports, buf };
|
|
24
42
|
}
|
|
25
43
|
|
|
44
|
+
function setup() {
|
|
45
|
+
__setDbCatalogForTests(DEFAULT_CATALOG);
|
|
46
|
+
return setupRaw();
|
|
47
|
+
}
|
|
48
|
+
|
|
26
49
|
/** Write bytes at `offset`, returning the `[ptr, len]` pair the imports expect. */
|
|
27
50
|
function put(buf: Buffer, offset: number, data: string): [number, number] {
|
|
28
51
|
const b = Buffer.from(data);
|
|
@@ -30,17 +53,213 @@ function put(buf: Buffer, offset: number, data: string): [number, number] {
|
|
|
30
53
|
return [offset, b.length];
|
|
31
54
|
}
|
|
32
55
|
|
|
33
|
-
function resolve(
|
|
56
|
+
function resolve(
|
|
57
|
+
imports: Record<string, (...a: number[]) => number>,
|
|
58
|
+
buf: Buffer,
|
|
59
|
+
name: string,
|
|
60
|
+
): number {
|
|
34
61
|
const [p, l] = put(buf, 0, name);
|
|
35
62
|
expect(imports['data.resolve_collection'](p, l, 16)).toBe(0);
|
|
36
63
|
return buf.readUInt32LE(16);
|
|
37
64
|
}
|
|
38
65
|
|
|
66
|
+
function wasmWithSection(name: string, payload: Uint8Array): Buffer {
|
|
67
|
+
const nameBytes = Buffer.from(name);
|
|
68
|
+
const sectionPayload = Buffer.concat([
|
|
69
|
+
Buffer.from([nameBytes.length]),
|
|
70
|
+
nameBytes,
|
|
71
|
+
Buffer.from(payload),
|
|
72
|
+
]);
|
|
73
|
+
return Buffer.concat([
|
|
74
|
+
Buffer.from([0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x00, sectionPayload.length]),
|
|
75
|
+
sectionPayload,
|
|
76
|
+
]);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function routeKindsSection(routes: readonly (readonly [number, number, string])[]): Buffer {
|
|
80
|
+
const chunks: Buffer[] = [Buffer.alloc(4)];
|
|
81
|
+
chunks[0].writeUInt16LE(1, 0);
|
|
82
|
+
chunks[0].writeUInt16LE(routes.length, 2);
|
|
83
|
+
for (const [method, kind, pattern] of routes) {
|
|
84
|
+
const p = Buffer.from(pattern, 'utf8');
|
|
85
|
+
const header = Buffer.alloc(6);
|
|
86
|
+
header.writeUInt8(method, 0);
|
|
87
|
+
header.writeUInt8(kind, 1);
|
|
88
|
+
header.writeUInt32LE(p.length, 2);
|
|
89
|
+
chunks.push(header, p);
|
|
90
|
+
}
|
|
91
|
+
return Buffer.concat(chunks);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function catalogSectionV2(fillMaxWaitMs: number, fillAllowStale: number): Buffer {
|
|
95
|
+
const chunks: Buffer[] = [];
|
|
96
|
+
const u8 = (v: number) => chunks.push(Buffer.from([v & 0xff]));
|
|
97
|
+
const u16 = (v: number) => {
|
|
98
|
+
const b = Buffer.alloc(2);
|
|
99
|
+
b.writeUInt16LE(v);
|
|
100
|
+
chunks.push(b);
|
|
101
|
+
};
|
|
102
|
+
const u32 = (v: number) => {
|
|
103
|
+
const b = Buffer.alloc(4);
|
|
104
|
+
b.writeUInt32LE(v >>> 0);
|
|
105
|
+
chunks.push(b);
|
|
106
|
+
};
|
|
107
|
+
const str = (s: string) => {
|
|
108
|
+
const b = Buffer.from(s, 'utf8');
|
|
109
|
+
u32(b.length);
|
|
110
|
+
chunks.push(b);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
u16(2); // catalog version
|
|
114
|
+
u16(1); // databases
|
|
115
|
+
str('App');
|
|
116
|
+
u16(1); // collections
|
|
117
|
+
str('users');
|
|
118
|
+
u8(CollectionFamily.Record);
|
|
119
|
+
str('UserId');
|
|
120
|
+
str('User');
|
|
121
|
+
u32(0xaaaa);
|
|
122
|
+
u32(0x1234);
|
|
123
|
+
u32(0); // generation
|
|
124
|
+
u8(5); // replication = localOnly, accepted by the backend ABI
|
|
125
|
+
u8(0); // placement = hashKey
|
|
126
|
+
u32(fillMaxWaitMs);
|
|
127
|
+
u8(fillAllowStale);
|
|
128
|
+
u16(0); // fields
|
|
129
|
+
u16(0); // migrations
|
|
130
|
+
return Buffer.concat(chunks);
|
|
131
|
+
}
|
|
132
|
+
|
|
39
133
|
afterEach(() => {
|
|
40
134
|
__resetDbForTests();
|
|
41
135
|
});
|
|
42
136
|
|
|
43
137
|
describe('toildb dev emulator (record family)', () => {
|
|
138
|
+
it('parses route-kind metadata and only tightens matching mutating routes', () => {
|
|
139
|
+
const wasm = wasmWithSection(
|
|
140
|
+
'toildb.route_kinds',
|
|
141
|
+
routeKindsSection([
|
|
142
|
+
[1, 0, '/api/search'],
|
|
143
|
+
[1, 0, '/api/items/:id'],
|
|
144
|
+
]),
|
|
145
|
+
);
|
|
146
|
+
const routes = parseRouteKinds(wasm);
|
|
147
|
+
|
|
148
|
+
expect(routeKindForRequest(routes, 'POST', '/api/search?q=x')).toBe(DbFunctionKind.Query);
|
|
149
|
+
expect(routeKindForRequest(routes, 'POST', '/api/items/42')).toBe(DbFunctionKind.Query);
|
|
150
|
+
expect(routeKindForRequest(routes, 'GET', '/api/search')).toBeNull();
|
|
151
|
+
expect(routeKindForRequest(routes, 'POST', '/api/items')).toBeNull();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('ignores malformed route-kind metadata like the edge method clamp fallback', () => {
|
|
155
|
+
expect(
|
|
156
|
+
parseRouteKinds(wasmWithSection('toildb.route_kinds', Buffer.from([1, 0, 1, 0, 1]))),
|
|
157
|
+
).toEqual([]);
|
|
158
|
+
expect(
|
|
159
|
+
parseRouteKinds(
|
|
160
|
+
wasmWithSection('toildb.route_kinds', routeKindsSection([[1, 0, 'relative']])),
|
|
161
|
+
),
|
|
162
|
+
).toEqual([]);
|
|
163
|
+
expect(
|
|
164
|
+
parseRouteKinds(
|
|
165
|
+
wasmWithSection('toildb.route_kinds', routeKindsSection([[1, 0, '/snowman-☃']])),
|
|
166
|
+
),
|
|
167
|
+
).toEqual([]);
|
|
168
|
+
|
|
169
|
+
const invalidUtf8 = Buffer.concat([
|
|
170
|
+
Buffer.from([1, 0, 1, 0, 1, 0, 2, 0, 0, 0]),
|
|
171
|
+
Buffer.from([0xc3, 0x28]),
|
|
172
|
+
]);
|
|
173
|
+
expect(parseRouteKinds(wasmWithSection('toildb.route_kinds', invalidUtf8))).toEqual([]);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('rejects unknown catalog collections instead of minting arbitrary handles', () => {
|
|
177
|
+
const { imports, buf } = setup();
|
|
178
|
+
const [p, l] = put(buf, 0, 'App/missing');
|
|
179
|
+
buf.writeUInt32LE(0xdeadbeef, 16);
|
|
180
|
+
expect(imports['data.resolve_collection'](p, l, 16)).toBe(-1070);
|
|
181
|
+
expect(buf.readUInt32LE(16)).toBe(0xdeadbeef);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('rejects a present but malformed catalog section', () => {
|
|
185
|
+
setDbCatalog(wasmWithSection('toildb.catalog', Buffer.from([0xff, 0x00, 0xde, 0xad])));
|
|
186
|
+
const { imports, buf } = setupRaw();
|
|
187
|
+
const [p, l] = put(buf, 0, 'App/users');
|
|
188
|
+
|
|
189
|
+
expect(imports['data.resolve_collection'](p, l, 16)).toBe(-1070);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('parses catalog v2 fill policy and backend replication bytes', () => {
|
|
193
|
+
setDbCatalog(wasmWithSection('toildb.catalog', catalogSectionV2(7, 0)));
|
|
194
|
+
const { imports, buf, db } = setupRaw();
|
|
195
|
+
const h = resolve(imports, buf, 'App/users');
|
|
196
|
+
|
|
197
|
+
expect(db.handles[h]).toMatchObject({
|
|
198
|
+
name: 'App/users',
|
|
199
|
+
family: CollectionFamily.Record,
|
|
200
|
+
schemaVersion: 0x1234,
|
|
201
|
+
replication: 5,
|
|
202
|
+
placement: 0,
|
|
203
|
+
fillMaxWaitMs: 7,
|
|
204
|
+
fillAllowStale: false,
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('rejects malformed catalog v2 fill policy', () => {
|
|
209
|
+
setDbCatalog(wasmWithSection('toildb.catalog', catalogSectionV2(7, 2)));
|
|
210
|
+
const { imports, buf } = setupRaw();
|
|
211
|
+
const [p, l] = put(buf, 0, 'App/users');
|
|
212
|
+
|
|
213
|
+
expect(imports['data.resolve_collection'](p, l, 16)).toBe(-1070);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('rejects handles used with the wrong collection family', () => {
|
|
217
|
+
const { imports, buf } = setup();
|
|
218
|
+
const users = resolve(imports, buf, 'App/users');
|
|
219
|
+
const likes = resolve(imports, buf, 'App/likes');
|
|
220
|
+
const [kPtr, kLen] = put(buf, 32, 'k');
|
|
221
|
+
const [vPtr, vLen] = put(buf, 48, 'v');
|
|
222
|
+
|
|
223
|
+
expect(imports['data.counter_add'](users, kPtr, kLen, 1, 0)).toBe(-1010);
|
|
224
|
+
expect(imports['data.create'](likes, kPtr, kLen, vPtr, vLen, 0)).toBe(-1010);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('query-kind dispatch denies writes and write_allowed is false', () => {
|
|
228
|
+
const { imports, buf, db } = setup();
|
|
229
|
+
db.functionKind = DbFunctionKind.Query;
|
|
230
|
+
const h = resolve(imports, buf, 'App/users');
|
|
231
|
+
const [kPtr, kLen] = put(buf, 32, 'u1');
|
|
232
|
+
const [vPtr, vLen] = put(buf, 48, 'hello');
|
|
233
|
+
|
|
234
|
+
expect(imports['data.write_allowed']()).toBe(0);
|
|
235
|
+
expect(imports['data.get'](h, kPtr, kLen)).toBe(-2);
|
|
236
|
+
expect(imports['data.create'](h, kPtr, kLen, vPtr, vLen, 0)).toBe(-1011);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('query/action dispatch deny scan-class imports', () => {
|
|
240
|
+
const { imports, buf, db } = setup();
|
|
241
|
+
const feed = resolve(imports, buf, 'App/feed');
|
|
242
|
+
const rooms = resolve(imports, buf, 'App/rooms');
|
|
243
|
+
const [kPtr, kLen] = put(buf, 32, 'room1');
|
|
244
|
+
const [vPtr, vLen] = put(buf, 48, 'ev1');
|
|
245
|
+
const [mPtr, mLen] = put(buf, 64, 'alice');
|
|
246
|
+
|
|
247
|
+
expect(imports['data.append'](feed, kPtr, kLen, vPtr, vLen, 0)).toBe(0);
|
|
248
|
+
expect(imports['data.membership_add'](rooms, kPtr, kLen, mPtr, mLen, 0)).toBe(0);
|
|
249
|
+
|
|
250
|
+
db.functionKind = DbFunctionKind.Query;
|
|
251
|
+
expect(imports['data.latest'](feed, kPtr, kLen, 10)).toBe(-1011);
|
|
252
|
+
expect(imports['data.membership_list'](rooms, kPtr, kLen, 10)).toBe(-1011);
|
|
253
|
+
|
|
254
|
+
db.functionKind = DbFunctionKind.Action;
|
|
255
|
+
expect(imports['data.latest'](feed, kPtr, kLen, 10)).toBe(-1011);
|
|
256
|
+
expect(imports['data.membership_list'](rooms, kPtr, kLen, 10)).toBe(-1011);
|
|
257
|
+
|
|
258
|
+
db.functionKind = DbFunctionKind.Job;
|
|
259
|
+
expect(imports['data.latest'](feed, kPtr, kLen, 10)).toBeGreaterThan(0);
|
|
260
|
+
expect(imports['data.membership_list'](rooms, kPtr, kLen, 10)).toBeGreaterThan(0);
|
|
261
|
+
});
|
|
262
|
+
|
|
44
263
|
it('resolve + create + get + take_result round-trips', () => {
|
|
45
264
|
const { imports, buf } = setup();
|
|
46
265
|
const h = resolve(imports, buf, 'App/users');
|
|
@@ -70,6 +289,27 @@ describe('toildb dev emulator (record family)', () => {
|
|
|
70
289
|
expect(buf.toString('utf8', 128, 134)).toBe('world!');
|
|
71
290
|
});
|
|
72
291
|
|
|
292
|
+
it('record patch idempotency replays and rejects request mismatch', () => {
|
|
293
|
+
const { imports, buf } = setup();
|
|
294
|
+
const h = resolve(imports, buf, 'App/users');
|
|
295
|
+
const [kPtr, kLen] = put(buf, 32, 'u1');
|
|
296
|
+
const [vPtr, vLen] = put(buf, 48, 'hello');
|
|
297
|
+
const [idemPtr] = put(buf, 64, '1234567890abcdef');
|
|
298
|
+
imports['data.create'](h, kPtr, kLen, vPtr, vLen, 0);
|
|
299
|
+
|
|
300
|
+
const [pPtr, pLen] = put(buf, 96, 'world!');
|
|
301
|
+
expect(imports['data.patch'](h, kPtr, kLen, pPtr, pLen, idemPtr)).toBe(6);
|
|
302
|
+
expect(imports['data.patch'](h, kPtr, kLen, pPtr, pLen, idemPtr)).toBe(6);
|
|
303
|
+
expect(imports['data.take_result'](128, 64)).toBe(6);
|
|
304
|
+
expect(buf.toString('utf8', 128, 134)).toBe('world!');
|
|
305
|
+
|
|
306
|
+
const [otherPtr, otherLen] = put(buf, 160, 'again!');
|
|
307
|
+
expect(imports['data.patch'](h, kPtr, kLen, otherPtr, otherLen, idemPtr)).toBe(-1004);
|
|
308
|
+
expect(imports['data.get'](h, kPtr, kLen)).toBe(6);
|
|
309
|
+
expect(imports['data.take_result'](192, 64)).toBe(6);
|
|
310
|
+
expect(buf.toString('utf8', 192, 198)).toBe('world!');
|
|
311
|
+
});
|
|
312
|
+
|
|
73
313
|
it('patch on a missing key is NotFound', () => {
|
|
74
314
|
const { imports, buf } = setup();
|
|
75
315
|
const h = resolve(imports, buf, 'App/users');
|
|
@@ -92,6 +332,24 @@ describe('toildb dev emulator (record family)', () => {
|
|
|
92
332
|
expect(imports['data.get_delete'](h, kPtr, kLen, 0)).toBe(-2);
|
|
93
333
|
});
|
|
94
334
|
|
|
335
|
+
it('record get_delete idempotency replays the consumed value', () => {
|
|
336
|
+
const { imports, buf } = setup();
|
|
337
|
+
const h = resolve(imports, buf, 'App/challenges');
|
|
338
|
+
const [kPtr, kLen] = put(buf, 32, 'chal');
|
|
339
|
+
const [vPtr, vLen] = put(buf, 48, 'nonce');
|
|
340
|
+
const [idemPtr] = put(buf, 80, 'consume-idem-001');
|
|
341
|
+
imports['data.create'](h, kPtr, kLen, vPtr, vLen, 0);
|
|
342
|
+
|
|
343
|
+
expect(imports['data.get_delete'](h, kPtr, kLen, idemPtr)).toBe(5);
|
|
344
|
+
expect(imports['data.take_result'](96, 64)).toBe(5);
|
|
345
|
+
expect(buf.toString('utf8', 96, 101)).toBe('nonce');
|
|
346
|
+
|
|
347
|
+
expect(imports['data.get_delete'](h, kPtr, kLen, idemPtr)).toBe(5);
|
|
348
|
+
expect(imports['data.take_result'](128, 64)).toBe(5);
|
|
349
|
+
expect(buf.toString('utf8', 128, 133)).toBe('nonce');
|
|
350
|
+
expect(imports['data.get'](h, kPtr, kLen)).toBe(-2);
|
|
351
|
+
});
|
|
352
|
+
|
|
95
353
|
it('absent / invalid handle / buffer-too-small return the edge codes', () => {
|
|
96
354
|
const { imports, buf } = setup();
|
|
97
355
|
const h = resolve(imports, buf, 'App/users');
|
|
@@ -127,6 +385,19 @@ describe('toildb dev emulator (record family)', () => {
|
|
|
127
385
|
expect(buf.toString('utf8', 200, 206)).toBe('user_1');
|
|
128
386
|
});
|
|
129
387
|
|
|
388
|
+
it('unique claim: same owner must replay only with the same idempotency key', () => {
|
|
389
|
+
const { imports, buf } = setup();
|
|
390
|
+
const h = resolve(imports, buf, 'App/usernames');
|
|
391
|
+
const [kPtr, kLen] = put(buf, 32, 'grace');
|
|
392
|
+
const [u1Ptr, u1Len] = put(buf, 48, 'user_1');
|
|
393
|
+
const [idem1Ptr] = put(buf, 80, 'idem-claim-0001');
|
|
394
|
+
const [idem2Ptr] = put(buf, 112, 'idem-claim-0002');
|
|
395
|
+
|
|
396
|
+
expect(imports['data.unique_claim'](h, kPtr, kLen, u1Ptr, u1Len, idem1Ptr)).toBe(0);
|
|
397
|
+
expect(imports['data.unique_claim'](h, kPtr, kLen, u1Ptr, u1Len, idem1Ptr)).toBe(2);
|
|
398
|
+
expect(imports['data.unique_claim'](h, kPtr, kLen, u1Ptr, u1Len, idem2Ptr)).toBe(-1004);
|
|
399
|
+
});
|
|
400
|
+
|
|
130
401
|
it('unique release: only the owner may release', () => {
|
|
131
402
|
const { imports, buf } = setup();
|
|
132
403
|
const h = resolve(imports, buf, 'App/usernames');
|
|
@@ -263,6 +534,14 @@ describe('toildb dev emulator (record family)', () => {
|
|
|
263
534
|
imports['data.take_result'](72, 8);
|
|
264
535
|
expect(buf.readBigInt64LE(72)).toBe(3n);
|
|
265
536
|
|
|
537
|
+
const [idemPtr] = put(buf, 96, 'counter-idem-001');
|
|
538
|
+
expect(imports['data.counter_add'](h, kPtr, kLen, 7, idemPtr)).toBe(0);
|
|
539
|
+
expect(imports['data.counter_add'](h, kPtr, kLen, 7, idemPtr)).toBe(0);
|
|
540
|
+
expect(imports['data.counter_add'](h, kPtr, kLen, 8, idemPtr)).toBe(-1004);
|
|
541
|
+
expect(imports['data.counter_get'](h, kPtr, kLen)).toBe(8);
|
|
542
|
+
imports['data.take_result'](104, 8);
|
|
543
|
+
expect(buf.readBigInt64LE(104)).toBe(10n);
|
|
544
|
+
|
|
266
545
|
// invalid handle still rejected
|
|
267
546
|
expect(imports['data.counter_add'](999, kPtr, kLen, 1, 0)).toBe(-1001);
|
|
268
547
|
});
|
|
@@ -327,11 +606,14 @@ describe('toildb dev emulator (record family)', () => {
|
|
|
327
606
|
expect(imports['data.append_once'](feed, kPtr, kLen, idPtr, idLen, evPtr, evLen)).toBe(0);
|
|
328
607
|
const [id2P, id2L] = put(buf, 128, 'evt-2');
|
|
329
608
|
expect(imports['data.append_once'](feed, kPtr, kLen, id2P, id2L, evPtr, evLen)).toBe(1);
|
|
609
|
+
const [appendIdemPtr] = put(buf, 144, 'append-idem-0001');
|
|
610
|
+
expect(imports['data.append'](feed, kPtr, kLen, evPtr, evLen, appendIdemPtr)).toBe(0);
|
|
611
|
+
expect(imports['data.append'](feed, kPtr, kLen, evPtr, evLen, appendIdemPtr)).toBe(0);
|
|
330
612
|
// latest frames exactly 2 events (the duplicate did not double-append).
|
|
331
613
|
const total = imports['data.latest'](feed, kPtr, kLen, 10);
|
|
332
614
|
expect(total).toBeGreaterThan(0);
|
|
333
615
|
imports['data.take_result'](512, total);
|
|
334
|
-
expect(buf.readUInt32LE(512)).toBe(
|
|
616
|
+
expect(buf.readUInt32LE(512)).toBe(3);
|
|
335
617
|
|
|
336
618
|
// enqueue: absent -> ABSENT (-2); after create -> replaces (0); get sees the new value.
|
|
337
619
|
const docs = resolve(imports, buf, 'App/docs');
|
|
@@ -376,9 +658,19 @@ describe('toildb dev emulator (capacity family)', () => {
|
|
|
376
658
|
expect(id > 0n).toBe(true);
|
|
377
659
|
expect(avail(imports, buf, h, kPtr, kLen)).toBe(7n);
|
|
378
660
|
|
|
661
|
+
const [idemPtr] = put(buf, 64, 'reserve-idem-001');
|
|
662
|
+
expect(imports['data.capacity_reserve'](h, kPtr, kLen, 2, 60000, idemPtr)).toBe(8);
|
|
663
|
+
expect(imports['data.take_result'](520, 16)).toBe(8);
|
|
664
|
+
const idemId = buf.readBigUInt64LE(520);
|
|
665
|
+
expect(imports['data.capacity_reserve'](h, kPtr, kLen, 2, 60000, idemPtr)).toBe(8);
|
|
666
|
+
expect(imports['data.take_result'](528, 16)).toBe(8);
|
|
667
|
+
expect(buf.readBigUInt64LE(528)).toBe(idemId);
|
|
668
|
+
expect(imports['data.capacity_reserve'](h, kPtr, kLen, 1, 60000, idemPtr)).toBe(-1004);
|
|
669
|
+
expect(avail(imports, buf, h, kPtr, kLen)).toBe(5n);
|
|
670
|
+
|
|
379
671
|
// cancel returns the hold -> back to 10; a double-cancel is a no-op.
|
|
380
672
|
expect(imports['data.capacity_cancel'](h, kPtr, kLen, Number(id), 0)).toBe(1);
|
|
381
|
-
expect(avail(imports, buf, h, kPtr, kLen)).toBe(
|
|
673
|
+
expect(avail(imports, buf, h, kPtr, kLen)).toBe(8n);
|
|
382
674
|
expect(imports['data.capacity_cancel'](h, kPtr, kLen, Number(id), 0)).toBe(0);
|
|
383
675
|
|
|
384
676
|
// reserve 4 then confirm -> a permanent consume; available holds at 6.
|
|
@@ -386,7 +678,7 @@ describe('toildb dev emulator (capacity family)', () => {
|
|
|
386
678
|
imports['data.take_result'](512, 16);
|
|
387
679
|
const id2 = Number(buf.readBigUInt64LE(512));
|
|
388
680
|
expect(imports['data.capacity_confirm'](h, kPtr, kLen, id2, 0)).toBe(1);
|
|
389
|
-
expect(avail(imports, buf, h, kPtr, kLen)).toBe(
|
|
681
|
+
expect(avail(imports, buf, h, kPtr, kLen)).toBe(4n);
|
|
390
682
|
// a confirmed sale cannot be cancelled (0); re-confirm is idempotent (1).
|
|
391
683
|
expect(imports['data.capacity_cancel'](h, kPtr, kLen, id2, 0)).toBe(0);
|
|
392
684
|
expect(imports['data.capacity_confirm'](h, kPtr, kLen, id2, 0)).toBe(1);
|
|
@@ -405,6 +697,17 @@ describe('toildb dev emulator (capacity family)', () => {
|
|
|
405
697
|
expect(imports['data.capacity_reserve'](h, kPtr, kLen, 0, 60000, 0)).toBe(-1006);
|
|
406
698
|
expect(imports['data.capacity_available'](999, kPtr, kLen)).toBe(-1001);
|
|
407
699
|
});
|
|
700
|
+
|
|
701
|
+
it('does not cache an idempotent insufficient reserve forever', () => {
|
|
702
|
+
const { imports, buf } = setup();
|
|
703
|
+
const h = resolve(imports, buf, 'App/tickets');
|
|
704
|
+
const [kPtr, kLen] = put(buf, 32, 'late-restock');
|
|
705
|
+
const [idemPtr] = put(buf, 64, 'reserve-idem-001');
|
|
706
|
+
|
|
707
|
+
expect(imports['data.capacity_reserve'](h, kPtr, kLen, 1, 60000, idemPtr)).toBe(-2);
|
|
708
|
+
expect(imports['data.capacity_set_total'](h, kPtr, kLen, 1, 0)).toBe(0);
|
|
709
|
+
expect(imports['data.capacity_reserve'](h, kPtr, kLen, 1, 60000, idemPtr)).toBe(8);
|
|
710
|
+
});
|
|
408
711
|
});
|
|
409
712
|
|
|
410
713
|
describe('toildb dev emulator (migration + persistence)', () => {
|
|
@@ -466,4 +769,92 @@ describe('toildb dev emulator (migration + persistence)', () => {
|
|
|
466
769
|
rmSync(dir, { recursive: true, force: true });
|
|
467
770
|
}
|
|
468
771
|
});
|
|
772
|
+
|
|
773
|
+
it('persists counter idempotency across devserver restart', () => {
|
|
774
|
+
const dir = mkdtempSync(join(tmpdir(), 'toildb-'));
|
|
775
|
+
const file = join(dir, 'devdata.json');
|
|
776
|
+
try {
|
|
777
|
+
const a = setup();
|
|
778
|
+
configureDbPersistence(file);
|
|
779
|
+
const h = resolve(a.imports, a.buf, 'App/likes');
|
|
780
|
+
const [kPtr, kLen] = put(a.buf, 32, 'post-1');
|
|
781
|
+
const [idemPtr] = put(a.buf, 64, 'counter-idem-001');
|
|
782
|
+
expect(a.imports['data.counter_add'](h, kPtr, kLen, 7, idemPtr)).toBe(0);
|
|
783
|
+
persistDb();
|
|
784
|
+
|
|
785
|
+
__resetDbForTests();
|
|
786
|
+
configureDbPersistence(file);
|
|
787
|
+
const b = setup();
|
|
788
|
+
const h2 = resolve(b.imports, b.buf, 'App/likes');
|
|
789
|
+
const [k2, kl2] = put(b.buf, 32, 'post-1');
|
|
790
|
+
const [idem2] = put(b.buf, 64, 'counter-idem-001');
|
|
791
|
+
expect(b.imports['data.counter_add'](h2, k2, kl2, 7, idem2)).toBe(0);
|
|
792
|
+
expect(b.imports['data.counter_add'](h2, k2, kl2, 8, idem2)).toBe(-1004);
|
|
793
|
+
expect(b.imports['data.counter_get'](h2, k2, kl2)).toBe(8);
|
|
794
|
+
expect(b.imports['data.take_result'](96, 8)).toBe(8);
|
|
795
|
+
expect(b.buf.readBigInt64LE(96)).toBe(7n);
|
|
796
|
+
} finally {
|
|
797
|
+
rmSync(dir, { recursive: true, force: true });
|
|
798
|
+
}
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
it('persists unique claim idempotency across devserver restart', () => {
|
|
802
|
+
const dir = mkdtempSync(join(tmpdir(), 'toildb-'));
|
|
803
|
+
const file = join(dir, 'devdata.json');
|
|
804
|
+
try {
|
|
805
|
+
const a = setup();
|
|
806
|
+
configureDbPersistence(file);
|
|
807
|
+
const h = resolve(a.imports, a.buf, 'App/usernames');
|
|
808
|
+
const [kPtr, kLen] = put(a.buf, 32, 'hopper');
|
|
809
|
+
const [u1Ptr, u1Len] = put(a.buf, 48, 'user_1');
|
|
810
|
+
const [idemPtr] = put(a.buf, 80, 'idem-claim-0001');
|
|
811
|
+
expect(a.imports['data.unique_claim'](h, kPtr, kLen, u1Ptr, u1Len, idemPtr)).toBe(0);
|
|
812
|
+
persistDb();
|
|
813
|
+
|
|
814
|
+
__resetDbForTests();
|
|
815
|
+
configureDbPersistence(file);
|
|
816
|
+
const b = setup();
|
|
817
|
+
const h2 = resolve(b.imports, b.buf, 'App/usernames');
|
|
818
|
+
const [k2, kl2] = put(b.buf, 32, 'hopper');
|
|
819
|
+
const [u2, u2l] = put(b.buf, 48, 'user_1');
|
|
820
|
+
const [sameIdem] = put(b.buf, 80, 'idem-claim-0001');
|
|
821
|
+
const [otherIdem] = put(b.buf, 112, 'idem-claim-0002');
|
|
822
|
+
expect(b.imports['data.unique_claim'](h2, k2, kl2, u2, u2l, sameIdem)).toBe(2);
|
|
823
|
+
expect(b.imports['data.unique_claim'](h2, k2, kl2, u2, u2l, otherIdem)).toBe(-1004);
|
|
824
|
+
} finally {
|
|
825
|
+
rmSync(dir, { recursive: true, force: true });
|
|
826
|
+
}
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
it('replays idempotent capacity reservations across devserver restart', () => {
|
|
830
|
+
const dir = mkdtempSync(join(tmpdir(), 'toildb-'));
|
|
831
|
+
const file = join(dir, 'devdata.json');
|
|
832
|
+
try {
|
|
833
|
+
const a = setup();
|
|
834
|
+
configureDbPersistence(file);
|
|
835
|
+
const h = resolve(a.imports, a.buf, 'App/seats');
|
|
836
|
+
const [kPtr, kLen] = put(a.buf, 32, 'showB');
|
|
837
|
+
const [idemPtr] = put(a.buf, 64, 'reserve-idem-001');
|
|
838
|
+
expect(a.imports['data.capacity_set_total'](h, kPtr, kLen, 3, 0)).toBe(0);
|
|
839
|
+
expect(a.imports['data.capacity_reserve'](h, kPtr, kLen, 2, 60000, idemPtr)).toBe(8);
|
|
840
|
+
expect(a.imports['data.take_result'](96, 16)).toBe(8);
|
|
841
|
+
const firstId = a.buf.readBigUInt64LE(96);
|
|
842
|
+
persistDb();
|
|
843
|
+
|
|
844
|
+
__resetDbForTests();
|
|
845
|
+
configureDbPersistence(file);
|
|
846
|
+
const b = setup();
|
|
847
|
+
const h2 = resolve(b.imports, b.buf, 'App/seats');
|
|
848
|
+
const [k2, kl2] = put(b.buf, 32, 'showB');
|
|
849
|
+
const [idem2] = put(b.buf, 64, 'reserve-idem-001');
|
|
850
|
+
expect(b.imports['data.capacity_reserve'](h2, k2, kl2, 2, 60000, idem2)).toBe(8);
|
|
851
|
+
expect(b.imports['data.take_result'](96, 16)).toBe(8);
|
|
852
|
+
expect(b.buf.readBigUInt64LE(96)).toBe(firstId);
|
|
853
|
+
expect(b.imports['data.capacity_available'](h2, k2, kl2)).toBe(8);
|
|
854
|
+
expect(b.imports['data.take_result'](112, 16)).toBe(8);
|
|
855
|
+
expect(b.buf.readBigInt64LE(112)).toBe(1n);
|
|
856
|
+
} finally {
|
|
857
|
+
rmSync(dir, { recursive: true, force: true });
|
|
858
|
+
}
|
|
859
|
+
});
|
|
469
860
|
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
1
2
|
import path from 'node:path';
|
|
2
3
|
|
|
3
4
|
import { createServer } from 'vite';
|
|
@@ -14,7 +15,11 @@ import { createViteConfig } from '../src/compiler/vite';
|
|
|
14
15
|
|
|
15
16
|
const EXAMPLE = path.resolve(__dirname, '../examples/basic');
|
|
16
17
|
|
|
17
|
-
|
|
18
|
+
// Drives a Vite server over examples/basic; skip where the example's deps are
|
|
19
|
+
// not installed (e.g. CI without the example set up).
|
|
20
|
+
describe.skipIf(!existsSync(path.join(EXAMPLE, 'node_modules')))(
|
|
21
|
+
'email preview end-to-end (examples/basic)',
|
|
22
|
+
() => {
|
|
18
23
|
it('lists Welcome and inlines its emails/styles/email.css; client/* alias resolves', async () => {
|
|
19
24
|
const cfg = await loadConfig({ root: EXAMPLE });
|
|
20
25
|
const items = listEmails(cfg);
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// A minimal @daemon fixture for the dev daemon-emulation test. It declares the
|
|
2
|
+
// `daemon.*` / `mstore.*` host imports directly (so it needs no toiljs globals
|
|
3
|
+
// lib) and records its activity into the dev MemoryStore, which the test reads
|
|
4
|
+
// back through `devMemoryStore`. Compiled with `--targetMode cold` by the test.
|
|
5
|
+
//
|
|
6
|
+
// onStart() -> mstore.incr("started", 1) and stamps the lease epoch
|
|
7
|
+
// tick() @scheduled -> mstore.incr("tick:fast", 1) (1s interval)
|
|
8
|
+
// sixHourly() cron -> mstore.incr("tick:cron", 1) (0 */6 * * *)
|
|
9
|
+
|
|
10
|
+
// @ts-nocheck — this is AssemblyScript source compiled by toilscript, not TS.
|
|
11
|
+
|
|
12
|
+
@external("env", "mstore.incr")
|
|
13
|
+
declare function mstoreIncr(keyPtr: i32, keyLen: i32, delta: i64, ttlSecs: i32): i64;
|
|
14
|
+
|
|
15
|
+
@external("env", "daemon.is_leader")
|
|
16
|
+
declare function daemonIsLeader(): i32;
|
|
17
|
+
|
|
18
|
+
@external("env", "daemon.current_epoch")
|
|
19
|
+
declare function daemonCurrentEpoch(): i64;
|
|
20
|
+
|
|
21
|
+
@external("env", "daemon.task_count")
|
|
22
|
+
declare function daemonTaskCount(): i32;
|
|
23
|
+
|
|
24
|
+
// Bump the i64 counter stored at the (utf8) `key` by 1. The host reads the key
|
|
25
|
+
// bytes straight out of linear memory (handleless mstore, ttl in seconds).
|
|
26
|
+
function bump(key: string): void {
|
|
27
|
+
let bytes = String.UTF8.encode(key);
|
|
28
|
+
mstoreIncr(changetype<i32>(bytes), bytes.byteLength, 1, 0);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
@daemon
|
|
32
|
+
class Jobs {
|
|
33
|
+
onStart(): void {
|
|
34
|
+
// Prove leader=true and that the epoch import is callable; record both so
|
|
35
|
+
// the test can assert the stubs from outside.
|
|
36
|
+
bump("started");
|
|
37
|
+
if (daemonIsLeader() == 1) bump("leader");
|
|
38
|
+
let epoch = daemonCurrentEpoch();
|
|
39
|
+
if (epoch >= 0) bump("epoch:nonneg");
|
|
40
|
+
if (daemonTaskCount() == 2) bump("taskcount:2");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
@scheduled("1s")
|
|
44
|
+
tick(): void {
|
|
45
|
+
bump("tick:fast");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
@scheduled("0 */6 * * *")
|
|
49
|
+
sixHourly(): void {
|
|
50
|
+
bump("tick:cron");
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function probe(): i32 {
|
|
55
|
+
return 1;
|
|
56
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Vitest global setup: generate `src/compiler/toil-docs.generated.ts` before the
|
|
7
|
+
* suite runs. `src/compiler/docs.ts` imports that module, but it is gitignored
|
|
8
|
+
* and only (re)created by the build's `gen:docs` step, so a fresh checkout that
|
|
9
|
+
* runs the tests without a prior build would otherwise fail to resolve it
|
|
10
|
+
* ("Cannot find module './toil-docs.generated.js'"). Running the same generator
|
|
11
|
+
* the npm scripts use keeps the tests self-contained. Runs once per vitest
|
|
12
|
+
* invocation, so it also covers `npx vitest` / watch mode.
|
|
13
|
+
*/
|
|
14
|
+
export default function setup(): void {
|
|
15
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
16
|
+
execFileSync('node', ['scripts/gen-toil-docs.mjs'], { cwd: root, stdio: 'ignore' });
|
|
17
|
+
}
|