toiljs 0.0.124 → 0.0.126

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.
@@ -0,0 +1,6 @@
1
+ import { type DbDevState } from './types.js';
2
+ type Imports = Record<string, (...args: number[]) => number | bigint>;
3
+ export declare function resetDbLimits(db: DbDevState): void;
4
+ export declare function checkDbResultSize(db: DbDevState, bytes: number): void;
5
+ export declare function withDbLimits(imports: Imports, db: DbDevState): Imports;
6
+ export {};
@@ -0,0 +1,118 @@
1
+ import { DbFunctionKind } from './types.js';
2
+ const counters = new WeakMap();
3
+ const exempt = new Set([
4
+ 'data.resolve_collection',
5
+ 'data.take_result',
6
+ 'data.result_schema_version',
7
+ 'data.write_allowed',
8
+ ]);
9
+ const reads = new Set([
10
+ 'data.get',
11
+ 'data.get_current',
12
+ 'data.get_many',
13
+ 'data.exists',
14
+ 'data.unique_lookup',
15
+ 'data.view_get',
16
+ 'data.membership_contains',
17
+ 'data.membership_list',
18
+ 'data.membership_page',
19
+ 'data.counter_get',
20
+ 'data.events_get',
21
+ 'data.events_reference',
22
+ 'data.events_entries',
23
+ 'data.events_last',
24
+ 'data.latest',
25
+ 'data.events_since',
26
+ 'data.capacity_available',
27
+ ]);
28
+ const results = new Set([
29
+ 'data.get',
30
+ 'data.get_current',
31
+ 'data.get_many',
32
+ 'data.get_delete',
33
+ 'data.patch',
34
+ 'data.unique_lookup',
35
+ 'data.view_get',
36
+ 'data.membership_list',
37
+ 'data.membership_page',
38
+ 'data.counter_get',
39
+ 'data.events_get',
40
+ 'data.events_reference',
41
+ 'data.events_entries',
42
+ 'data.events_last',
43
+ 'data.latest',
44
+ 'data.events_since',
45
+ 'data.capacity_available',
46
+ 'data.capacity_reserve',
47
+ ]);
48
+ const writeLength = {
49
+ 'data.create': 4,
50
+ 'data.patch': 4,
51
+ 'data.upsert': 4,
52
+ 'data.enqueue': 4,
53
+ 'data.compare_exchange': 6,
54
+ 'data.compare_delete': 4,
55
+ 'data.unique_claim': 4,
56
+ 'data.view_publish': 4,
57
+ 'data.membership_add': 4,
58
+ 'data.append': 4,
59
+ 'data.append_once': 6,
60
+ };
61
+ export function resetDbLimits(db) {
62
+ counters.delete(db);
63
+ }
64
+ export function checkDbResultSize(db, bytes) {
65
+ if (process.env.TOIL_DEV_CHECK_DB_LIMITS !== '1')
66
+ return;
67
+ const max = db.functionKind === DbFunctionKind.Query
68
+ ? 65536
69
+ : db.functionKind === DbFunctionKind.Action
70
+ ? 131072
71
+ : 1048576;
72
+ if (bytes > max)
73
+ throw new Error(`TDL021: ${db.functionKind} DB operation uses ${String(bytes)} bytes; maximum is ${String(max)}`);
74
+ }
75
+ export function withDbLimits(imports, db) {
76
+ return Object.fromEntries(Object.entries(imports).map(([name, operation]) => [
77
+ name,
78
+ (...args) => {
79
+ if (process.env.TOIL_DEV_CHECK_DB_LIMITS !== '1' || exempt.has(name))
80
+ return operation(...args);
81
+ const kind = db.functionKind;
82
+ let count = counters.get(db);
83
+ if (count === undefined || count.kind !== kind) {
84
+ count = { kind, ops: 0, writes: 0, bytes: 0 };
85
+ counters.set(db, count);
86
+ }
87
+ const maxOps = kind === DbFunctionKind.Query || kind === DbFunctionKind.Action ? 64 : 65535;
88
+ const maxWrites = kind === DbFunctionKind.Query ? 0 : kind === DbFunctionKind.Action ? 32 : 65535;
89
+ const maxBytes = kind === DbFunctionKind.Query
90
+ ? 131072
91
+ : kind === DbFunctionKind.Action
92
+ ? 2097152
93
+ : 16777216;
94
+ const writing = !reads.has(name);
95
+ if (++count.ops > maxOps || (writing && ++count.writes > maxWrites))
96
+ throw new Error(`TDL020: ${name} exceeds ${kind} DB budget (${String(count.ops)}/${String(maxOps)} operations, ${String(count.writes)}/${String(maxWrites)} writes)`);
97
+ const writeBytes = (name === 'data.events_redact' ? 16 : writeLength[name] === undefined ? 0 : args[writeLength[name]]) +
98
+ (name === 'data.compare_exchange' ? Math.max(0, args[4]) : 0);
99
+ checkDbResultSize(db, writeBytes);
100
+ if (count.bytes + writeBytes > maxBytes)
101
+ throw new Error(`TDL021: ${kind} cumulative DB byte budget exceeds ${String(maxBytes)}`);
102
+ count.bytes += writeBytes;
103
+ const status = operation(...args);
104
+ if (typeof status === 'number' && status >= 0) {
105
+ const bytes = results.has(name)
106
+ ? (db.lastResult?.length ?? 0)
107
+ : name === 'data.unique_claim' && status === 1
108
+ ? (db.lastResult?.length ?? 0)
109
+ : 0;
110
+ checkDbResultSize(db, bytes);
111
+ if (count.bytes + bytes > maxBytes)
112
+ throw new Error(`TDL021: ${kind} cumulative DB byte budget exceeds ${String(maxBytes)}`);
113
+ count.bytes += bytes;
114
+ }
115
+ return status;
116
+ },
117
+ ]));
118
+ }
@@ -0,0 +1,4 @@
1
+ export declare function registerMaterializer(key: string, runner: (written: ReadonlySet<string>) => void): void;
2
+ export declare function unregisterMaterializer(key: string): void;
3
+ export declare function queueMaterialization(collection: string): void;
4
+ export declare function flushMaterializations(): void;
@@ -0,0 +1,25 @@
1
+ const runners = new Map();
2
+ const pending = new Set();
3
+ let scheduled = false;
4
+ export function registerMaterializer(key, runner) {
5
+ runners.set(key, runner);
6
+ }
7
+ export function unregisterMaterializer(key) {
8
+ runners.delete(key);
9
+ }
10
+ export function queueMaterialization(collection) {
11
+ pending.add(collection);
12
+ if (scheduled)
13
+ return;
14
+ scheduled = true;
15
+ queueMicrotask(flushMaterializations);
16
+ }
17
+ export function flushMaterializations() {
18
+ scheduled = false;
19
+ if (pending.size === 0)
20
+ return;
21
+ const written = new Set(pending);
22
+ pending.clear();
23
+ for (const runner of runners.values())
24
+ runner(written);
25
+ }
@@ -40,6 +40,7 @@ export type DbCatalogState = {
40
40
  collections: Map<string, DevCollectionHandle>;
41
41
  };
42
42
  export interface DbDevState {
43
+ logMessages: number;
43
44
  handles: DevCollectionHandle[];
44
45
  lastResult: Buffer | null;
45
46
  lastResultVersion: number;
@@ -83,7 +84,9 @@ export interface DbSnapshot {
83
84
  events: Record<string, {
84
85
  v: string;
85
86
  sv: number;
87
+ hlc?: string;
86
88
  }[]>;
89
+ erasedEvents?: Record<string, string[]>;
87
90
  eventDedup: Record<string, string[]>;
88
91
  eventPositions?: Record<string, [string, number][]>;
89
92
  deriveCheckpoints?: Record<string, number>;
@@ -21,6 +21,7 @@ export function isCollectionFamily(value) {
21
21
  }
22
22
  export function freshDbState() {
23
23
  return {
24
+ logMessages: 0,
24
25
  handles: [],
25
26
  lastResult: null,
26
27
  lastResultVersion: -1,
@@ -130,8 +130,24 @@ function devEmailSend(ref, reqPtr, reqLen) {
130
130
  });
131
131
  return EmailStatus.Sent;
132
132
  }
133
+ function buildLogImports(ref, db) {
134
+ return {
135
+ server_log: (level, ptr, len) => {
136
+ if (!Number.isInteger(level) || level < 0 || level > 2 || len < 0 || len > 4096)
137
+ throw new Error('server_log: level must be 0..2 and message at most 4096 UTF-8 bytes');
138
+ const message = new TextDecoder('utf-8', { fatal: true }).decode(readBytes(ref, ptr, len));
139
+ if (db.logMessages >= 32)
140
+ return 0;
141
+ db.logMessages++;
142
+ const write = level === 0 ? console.info : level === 1 ? console.warn : console.error;
143
+ write('[toil guest]', message);
144
+ return 1;
145
+ },
146
+ };
147
+ }
133
148
  export function buildEnvImports(ref, _state) {
134
149
  return {
150
+ ...buildLogImports(ref, _state.db),
135
151
  abort: (msgPtr, filePtr, line, col) => {
136
152
  throw new WasmAbortError(readGuestString(ref, msgPtr), readGuestString(ref, filePtr), line, col);
137
153
  },
@@ -146,6 +162,7 @@ export function buildEnvImports(ref, _state) {
146
162
  export function buildHostImports(ref, state) {
147
163
  return {
148
164
  env: {
165
+ ...buildLogImports(ref, state.db),
149
166
  abort: (msgPtr, filePtr, line, col) => {
150
167
  throw new WasmAbortError(readGuestString(ref, msgPtr), readGuestString(ref, filePtr), line, col);
151
168
  },
@@ -19,6 +19,8 @@ export declare class WasmServerModule {
19
19
  private rpcKinds;
20
20
  private derives;
21
21
  private derivesDirty;
22
+ private envelopeWindow;
23
+ private readonly pendingDerives;
22
24
  constructor(wasmPath: string);
23
25
  get available(): boolean;
24
26
  refresh(): boolean;
@@ -27,6 +29,8 @@ export declare class WasmServerModule {
27
29
  private runAffectedDerives;
28
30
  private rebuildDerivedViewsIfStale;
29
31
  private runDerive;
32
+ private requestEnvelope;
33
+ private writeRequestEnvelope;
30
34
  private assertImportSurface;
31
35
  private assertExportSurface;
32
36
  }
@@ -3,6 +3,8 @@ import { commitDeriveCheckpoints, DbFunctionKind, derivesForWrites, parseDerives
3
3
  import { parseRouteKinds, parseRpcKinds, routeKindForRequest, rpcKindForId, } from '../db/routeKinds.js';
4
4
  import { decodeResponseEnvelope, encodeRequestEnvelope, unpackHandleResult, } from '../http/envelope.js';
5
5
  import { buildHostImports, freshDispatchState } from './host.js';
6
+ import { envelopeWindowOf } from '../wasm/envelope-window.js';
7
+ import { registerMaterializer, unregisterMaterializer } from '../db/materialization.js';
6
8
  export { WasmAbortError } from './host.js';
7
9
  export const UNHANDLED_HEADER = 'x-toil-unhandled';
8
10
  const WASM_PAGE = 65536;
@@ -31,6 +33,7 @@ const PROVIDED_IMPORTS = new Set([
31
33
  'respond_file',
32
34
  'thread_spawn',
33
35
  'Date.now',
36
+ 'server_log',
34
37
  'client_ip',
35
38
  'ratelimit_check',
36
39
  'email_send',
@@ -68,20 +71,26 @@ const PROVIDED_IMPORTS = new Set([
68
71
  'data.unique_release',
69
72
  'data.view_get',
70
73
  'data.view_publish',
74
+ 'data.view_delete',
71
75
  'data.membership_contains',
72
76
  'data.membership_add',
73
77
  'data.membership_remove',
74
78
  'data.membership_list',
79
+ 'data.membership_page',
75
80
  'data.counter_get',
76
81
  'data.counter_add',
77
82
  'data.append',
78
83
  'data.append_once',
79
84
  'data.enqueue',
80
85
  'data.compare_exchange',
86
+ 'data.compare_delete',
81
87
  'data.get_current',
82
88
  'data.latest',
83
89
  'data.events_since',
84
90
  'data.events_get',
91
+ 'data.events_reference',
92
+ 'data.events_redact',
93
+ 'data.events_entries',
85
94
  'data.events_last',
86
95
  'data.capacity_set_total',
87
96
  'data.capacity_available',
@@ -100,6 +109,8 @@ export class WasmServerModule {
100
109
  rpcKinds = [];
101
110
  derives = [];
102
111
  derivesDirty = false;
112
+ envelopeWindow = null;
113
+ pendingDerives = new Set();
103
114
  constructor(wasmPath) {
104
115
  this.wasmPath = wasmPath;
105
116
  }
@@ -112,6 +123,7 @@ export class WasmServerModule {
112
123
  mtimeMs = fs.statSync(this.wasmPath).mtimeMs;
113
124
  }
114
125
  catch {
126
+ unregisterMaterializer(this.wasmPath);
115
127
  this.module = null;
116
128
  this.routeKinds = [];
117
129
  this.rpcKinds = [];
@@ -123,6 +135,7 @@ export class WasmServerModule {
123
135
  if (this.module !== null && mtimeMs === this.loadedMtimeMs)
124
136
  return false;
125
137
  const bytes = fs.readFileSync(this.wasmPath);
138
+ this.envelopeWindow = envelopeWindowOf(bytes);
126
139
  const module = new WebAssembly.Module(bytes);
127
140
  this.assertImportSurface(module);
128
141
  this.assertExportSurface(module);
@@ -133,13 +146,21 @@ export class WasmServerModule {
133
146
  this.module = module;
134
147
  this.loadedMtimeMs = mtimeMs;
135
148
  this.derivesDirty = this.derives.length > 0;
149
+ registerMaterializer(this.wasmPath, (written) => {
150
+ if (!fs.existsSync(this.wasmPath)) {
151
+ unregisterMaterializer(this.wasmPath);
152
+ return;
153
+ }
154
+ this.runAffectedDerives(written);
155
+ persistDb();
156
+ });
136
157
  return true;
137
158
  }
138
159
  dispatch(req) {
139
160
  if (this.module === null)
140
161
  throw new Error(`server wasm not loaded (${this.wasmPath})`);
141
162
  this.rebuildDerivedViewsIfStale();
142
- const envelope = encodeRequestEnvelope(req);
163
+ const envelope = this.requestEnvelope(req);
143
164
  const ref = { memory: null };
144
165
  const state = freshDispatchState();
145
166
  state.clientIp = req.clientIp ?? '';
@@ -163,9 +184,7 @@ export class WasmServerModule {
163
184
  const instance = new WebAssembly.Instance(this.module, buildHostImports(ref, state));
164
185
  const exports = instance.exports;
165
186
  ref.memory = exports.memory;
166
- const reqOfs = exports.memory.buffer.byteLength;
167
- exports.memory.grow(Math.ceil(envelope.length / WASM_PAGE) || 1);
168
- Buffer.from(exports.memory.buffer).set(envelope, reqOfs);
187
+ const reqOfs = this.writeRequestEnvelope(exports.memory, envelope);
169
188
  const packed = exports.handle(reqOfs, envelope.length);
170
189
  const { ptr, len } = unpackHandleResult(packed);
171
190
  const memSize = exports.memory.buffer.byteLength;
@@ -188,7 +207,8 @@ export class WasmServerModule {
188
207
  dispatchRender(req) {
189
208
  if (this.module === null)
190
209
  throw new Error(`server wasm not loaded (${this.wasmPath})`);
191
- const envelope = encodeRequestEnvelope(req);
210
+ this.rebuildDerivedViewsIfStale();
211
+ const envelope = this.requestEnvelope(req);
192
212
  const ref = { memory: null };
193
213
  const state = freshDispatchState();
194
214
  state.clientIp = req.clientIp ?? '';
@@ -198,9 +218,7 @@ export class WasmServerModule {
198
218
  if (typeof exports.render !== 'function')
199
219
  throw new Error(`guest wasm has no 'render' export (${this.wasmPath})`);
200
220
  ref.memory = exports.memory;
201
- const reqOfs = exports.memory.buffer.byteLength;
202
- exports.memory.grow(Math.ceil(envelope.length / WASM_PAGE) || 1);
203
- Buffer.from(exports.memory.buffer).set(envelope, reqOfs);
221
+ const reqOfs = this.writeRequestEnvelope(exports.memory, envelope);
204
222
  const packed = exports.render(reqOfs, envelope.length);
205
223
  const { ptr, len } = unpackHandleResult(packed);
206
224
  const memSize = exports.memory.buffer.byteLength;
@@ -211,9 +229,13 @@ export class WasmServerModule {
211
229
  runAffectedDerives(written) {
212
230
  if (this.module === null)
213
231
  return;
214
- for (const derive of derivesForWrites(this.derives, written)) {
232
+ const affected = derivesForWrites(this.derives, written);
233
+ for (const derive of affected)
234
+ this.pendingDerives.add(derive.deriveId);
235
+ for (const derive of this.derives.filter((d) => this.pendingDerives.has(d.deriveId))) {
215
236
  try {
216
237
  this.runDerive(derive.deriveId);
238
+ this.pendingDerives.delete(derive.deriveId);
217
239
  }
218
240
  catch (err) {
219
241
  console.error(`[toil] derive ${derive.dbName}#${derive.methodName} failed:`, err);
@@ -221,6 +243,7 @@ export class WasmServerModule {
221
243
  }
222
244
  }
223
245
  rebuildDerivedViewsIfStale() {
246
+ this.runAffectedDerives(new Set());
224
247
  if (!this.derivesDirty)
225
248
  return;
226
249
  this.derivesDirty = false;
@@ -248,6 +271,21 @@ export class WasmServerModule {
248
271
  exports.derive_run(deriveId);
249
272
  commitDeriveCheckpoints(state.db);
250
273
  }
274
+ requestEnvelope(req) {
275
+ const envelope = encodeRequestEnvelope(req);
276
+ if (envelope.length > 8 * 1024 * 1024)
277
+ throw new Error('request envelope exceeds the native 8 MiB request limit');
278
+ return envelope;
279
+ }
280
+ writeRequestEnvelope(memory, envelope) {
281
+ const fits = this.envelopeWindow === null || envelope.length <= this.envelopeWindow;
282
+ const offset = fits ? 0 : memory.buffer.byteLength;
283
+ const needed = offset + envelope.length;
284
+ if (needed > memory.buffer.byteLength)
285
+ memory.grow(Math.ceil((needed - memory.buffer.byteLength) / WASM_PAGE));
286
+ Buffer.from(memory.buffer).set(envelope, offset);
287
+ return offset;
288
+ }
251
289
  assertImportSurface(module) {
252
290
  const missing = WebAssembly.Module.imports(module)
253
291
  .filter((i) => i.kind === 'function' && (i.module !== 'env' || !PROVIDED_IMPORTS.has(i.name)))
@@ -0,0 +1 @@
1
+ export declare function envelopeWindowOf(wasm: Buffer): number | null;
@@ -0,0 +1,64 @@
1
+ export function envelopeWindowOf(wasm) {
2
+ if (wasm.length < 8 || !wasm.subarray(0, 8).equals(Buffer.from([0, 97, 115, 109, 1, 0, 0, 0])))
3
+ throw new Error('invalid wasm header');
4
+ let pos = 8;
5
+ let end = wasm.length;
6
+ let window = null;
7
+ const byte = () => {
8
+ if (pos >= end)
9
+ throw new Error('truncated wasm data section');
10
+ return wasm[pos++];
11
+ };
12
+ const integer = (signed = false) => {
13
+ let value = 0;
14
+ let shift = 0;
15
+ for (let i = 0; i < 5; i++) {
16
+ const b = byte();
17
+ value += (b & 127) * 2 ** shift;
18
+ shift += 7;
19
+ if ((b & 128) === 0) {
20
+ if (signed && b & 64)
21
+ value -= 2 ** shift;
22
+ if (value < (signed ? -0x80000000 : 0) ||
23
+ value > (signed ? 0x7fffffff : 0xffffffff))
24
+ throw new Error('invalid wasm data integer');
25
+ return value;
26
+ }
27
+ }
28
+ throw new Error('invalid wasm data integer');
29
+ };
30
+ while (pos < wasm.length) {
31
+ end = wasm.length;
32
+ const id = byte();
33
+ const size = integer();
34
+ end = pos + size;
35
+ if (end > wasm.length)
36
+ throw new Error('truncated wasm section');
37
+ if (id === 11) {
38
+ const count = integer();
39
+ for (let i = 0; i < count; i++) {
40
+ const flags = integer();
41
+ if (flags === 0 || flags === 2) {
42
+ if (flags === 2 && integer() !== 0)
43
+ throw new Error('unsupported data memory');
44
+ if (byte() !== 0x41)
45
+ throw new Error('data offset must be a constant i32');
46
+ const offset = integer(true);
47
+ if (offset < 0 || byte() !== 0x0b)
48
+ throw new Error('invalid data offset');
49
+ window = window === null ? offset : Math.min(window, offset);
50
+ }
51
+ else if (flags !== 1)
52
+ throw new Error('unsupported data segment flags');
53
+ const length = integer();
54
+ pos += length;
55
+ if (pos > end)
56
+ throw new Error('truncated wasm data segment');
57
+ }
58
+ if (pos !== end)
59
+ throw new Error('invalid wasm data section framing');
60
+ }
61
+ pos = end;
62
+ }
63
+ return window;
64
+ }