@gaganref/confect-test 10.3.1-fork.9 → 10.3.1-fork.90

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/dist/TestConfect.d.ts +37 -11
  2. package/dist/TestConfect.d.ts.map +1 -1
  3. package/dist/TestConfect.js +22 -7
  4. package/dist/TestConfect.js.map +1 -1
  5. package/dist/internal/Attempt.d.ts +77 -0
  6. package/dist/internal/Attempt.d.ts.map +1 -0
  7. package/dist/internal/Attempt.js +154 -0
  8. package/dist/internal/Attempt.js.map +1 -0
  9. package/dist/internal/Clock.d.ts +29 -0
  10. package/dist/internal/Clock.d.ts.map +1 -0
  11. package/dist/internal/Clock.js +66 -0
  12. package/dist/internal/Clock.js.map +1 -0
  13. package/dist/internal/ConvexTest.d.ts +66 -0
  14. package/dist/internal/ConvexTest.d.ts.map +1 -0
  15. package/dist/internal/ConvexTest.js +62 -0
  16. package/dist/internal/ConvexTest.js.map +1 -0
  17. package/dist/internal/Execution.d.ts +247 -0
  18. package/dist/internal/Execution.d.ts.map +1 -0
  19. package/dist/internal/Execution.js +1024 -0
  20. package/dist/internal/Execution.js.map +1 -0
  21. package/dist/internal/Globals.d.ts +11 -0
  22. package/dist/internal/Globals.d.ts.map +1 -0
  23. package/dist/internal/Globals.js +88 -0
  24. package/dist/internal/Globals.js.map +1 -0
  25. package/dist/internal/Invocation.d.ts +68 -0
  26. package/dist/internal/Invocation.d.ts.map +1 -0
  27. package/dist/internal/Invocation.js +158 -0
  28. package/dist/internal/Invocation.js.map +1 -0
  29. package/dist/internal/Kernel.d.ts +95 -0
  30. package/dist/internal/Kernel.d.ts.map +1 -0
  31. package/dist/internal/Kernel.js +91 -0
  32. package/dist/internal/Kernel.js.map +1 -0
  33. package/dist/internal/KernelQuery.d.ts +55 -0
  34. package/dist/internal/KernelQuery.d.ts.map +1 -0
  35. package/dist/internal/KernelQuery.js +205 -0
  36. package/dist/internal/KernelQuery.js.map +1 -0
  37. package/dist/internal/KernelRefusal.d.ts +102 -0
  38. package/dist/internal/KernelRefusal.d.ts.map +1 -0
  39. package/dist/internal/KernelRefusal.js +94 -0
  40. package/dist/internal/KernelRefusal.js.map +1 -0
  41. package/dist/internal/KernelValues.d.ts +138 -0
  42. package/dist/internal/KernelValues.d.ts.map +1 -0
  43. package/dist/internal/KernelValues.js +281 -0
  44. package/dist/internal/KernelValues.js.map +1 -0
  45. package/dist/internal/MountedHttp.d.ts +1 -1
  46. package/dist/internal/MountedHttp.d.ts.map +1 -1
  47. package/dist/internal/MountedHttp.js +3 -3
  48. package/dist/internal/MountedHttp.js.map +1 -1
  49. package/dist/internal/ReadSet.d.ts +55 -0
  50. package/dist/internal/ReadSet.d.ts.map +1 -0
  51. package/dist/internal/ReadSet.js +29 -0
  52. package/dist/internal/ReadSet.js.map +1 -0
  53. package/dist/internal/Scheduling.d.ts +71 -0
  54. package/dist/internal/Scheduling.d.ts.map +1 -0
  55. package/dist/internal/Scheduling.js +60 -0
  56. package/dist/internal/Scheduling.js.map +1 -0
  57. package/package.json +5 -5
  58. package/src/TestConfect.ts +84 -19
  59. package/src/internal/Attempt.ts +185 -0
  60. package/src/internal/Clock.ts +95 -0
  61. package/src/internal/ConvexTest.ts +163 -0
  62. package/src/internal/Execution.ts +1795 -0
  63. package/src/internal/Globals.ts +113 -0
  64. package/src/internal/Invocation.ts +316 -0
  65. package/src/internal/Kernel.ts +190 -0
  66. package/src/internal/KernelQuery.ts +405 -0
  67. package/src/internal/KernelRefusal.ts +112 -0
  68. package/src/internal/KernelValues.ts +765 -0
  69. package/src/internal/MountedHttp.ts +13 -6
  70. package/src/internal/ReadSet.ts +108 -0
  71. package/src/internal/Scheduling.ts +126 -0
@@ -0,0 +1,1795 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import {
3
+ actionGeneric,
4
+ getFunctionAddress,
5
+ mutationGeneric,
6
+ queryGeneric,
7
+ type FunctionReference,
8
+ type GenericSchema,
9
+ type SchemaDefinition,
10
+ type UserIdentity,
11
+ } from "convex/server";
12
+ import { convexToJson, jsonToConvex, type Value } from "convex/values";
13
+ import { KernelAttempt } from "./Attempt.js";
14
+ import * as Clock from "./Clock.js";
15
+ import * as Globals from "./Globals.js";
16
+ import * as Invocation from "./Invocation.js";
17
+ import * as Kernel from "./Kernel.js";
18
+ import * as KernelQuery from "./KernelQuery.js";
19
+ import * as KernelRefusal from "./KernelRefusal.js";
20
+ import type * as ReadSet from "./ReadSet.js";
21
+ import * as Scheduling from "./Scheduling.js";
22
+ import type {
23
+ DocumentId,
24
+ FunctionPath,
25
+ StoredDocument,
26
+ ValidatorJSON,
27
+ } from "./KernelValues.js";
28
+ import {
29
+ createFunctionHandle,
30
+ createTestJwt,
31
+ deserializeConvexErrorData,
32
+ evaluateValue,
33
+ hasPendingValues,
34
+ parseArgs,
35
+ parseFunctionHandle,
36
+ resolveCommittedValues,
37
+ simpleHash,
38
+ tableNameFromId,
39
+ validateFunctionArgs,
40
+ validateReturnValue,
41
+ } from "./KernelValues.js";
42
+
43
+ const ROOT = "";
44
+ const TYPICAL_CONVEX_ID_LENGTH = 32;
45
+
46
+ type Modules = Record<string, () => Promise<any>>;
47
+
48
+ /**
49
+ * One registered component: its schema, its function modules, each loaded once
50
+ * when the world opens, and its standing sources.
51
+ */
52
+ interface Component {
53
+ readonly path: string;
54
+ readonly schema: KernelQuery.KernelSchema | null;
55
+ readonly loaders: ReadonlyMap<string, () => Promise<any>>;
56
+ readonly loaded: Map<string, any>;
57
+ /**
58
+ * The functions, as `module:export`, that schedule their own next run. A job
59
+ * that runs one, or that one schedules, is never drain work.
60
+ */
61
+ readonly standing: ReadonlySet<string>;
62
+ }
63
+
64
+ /**
65
+ * Who is calling: the identity and the request attributes a test accessor set.
66
+ */
67
+ export interface Caller {
68
+ readonly identity: UserIdentity | null;
69
+ readonly token: string | null;
70
+ readonly ip: string | null;
71
+ readonly userAgent: string | null;
72
+ }
73
+
74
+ export const anonymous: Caller = {
75
+ identity: null,
76
+ token: null,
77
+ ip: null,
78
+ userAgent: null,
79
+ };
80
+
81
+ export const withIdentity = (
82
+ caller: Caller,
83
+ identity: Partial<UserIdentity>,
84
+ ): Caller => {
85
+ const subject = identity.subject ?? "" + simpleHash(JSON.stringify(identity));
86
+ const issuer = identity.issuer ?? "https://convex.test";
87
+ const tokenIdentifier = identity.tokenIdentifier ?? `${issuer}|${subject}`;
88
+ const full = { ...identity, subject, issuer, tokenIdentifier };
89
+ return { ...caller, identity: full, token: createTestJwt(full) };
90
+ };
91
+
92
+ interface Request {
93
+ readonly ip: string | null;
94
+ readonly userAgent: string | null;
95
+ readonly requestId: string;
96
+ readonly scheduledFunctionId: string | null;
97
+ readonly authToken: string | null;
98
+ }
99
+
100
+ type UdfType = "query" | "mutation" | "action";
101
+
102
+ /**
103
+ * How a prepared attempt's function ended.
104
+ */
105
+ export type Outcome =
106
+ | { readonly _tag: "returned"; readonly value: Value }
107
+ | { readonly _tag: "failed"; readonly error: unknown };
108
+
109
+ /**
110
+ * A mutation attempt run to its commit point and held until `apply`.
111
+ */
112
+ export interface HeldAttempt {
113
+ readonly id: string;
114
+ readonly request: string;
115
+ readonly number: Invocation.Attempt;
116
+ readonly snapshot: Kernel.Version;
117
+ readonly reads: ReadonlyArray<ReadSet.Read>;
118
+ readonly outcome: Outcome;
119
+ }
120
+
121
+ /**
122
+ * What `apply` did with a held attempt. A conflict discards the attempt and
123
+ * holds exactly one next attempt, run against the latest commit. Nothing is
124
+ * retried until that one is applied in turn.
125
+ */
126
+ export type KernelApply =
127
+ | {
128
+ readonly kind: "applied";
129
+ readonly version: Kernel.Version;
130
+ readonly commitTs: bigint;
131
+ readonly result: Value;
132
+ }
133
+ | { readonly kind: "conflict"; readonly next: HeldAttempt }
134
+ | {
135
+ readonly kind: "exhausted";
136
+ readonly error: KernelRefusal.OptimisticConcurrencyControlFailure;
137
+ }
138
+ | { readonly kind: "refused"; readonly tag: string; readonly error: unknown };
139
+
140
+ interface Held {
141
+ readonly view: HeldAttempt;
142
+ readonly attempt: KernelAttempt;
143
+ readonly again: (number: Invocation.Attempt) => Promise<Held>;
144
+ }
145
+
146
+ const lastAttempt: Invocation.Attempt = 5;
147
+
148
+ const tagOf = (error: unknown): string => {
149
+ const tagged = (value: unknown): value is { readonly _tag: string } =>
150
+ typeof value === "object" &&
151
+ value !== null &&
152
+ typeof (value as { _tag?: unknown })._tag === "string";
153
+ const data = (error as { data?: unknown } | null)?.data;
154
+ if (tagged(data)) return data._tag;
155
+ if (tagged(error)) return error._tag;
156
+ return error instanceof Error ? error.name : "Error";
157
+ };
158
+
159
+ /**
160
+ * One running function. Queries and mutations read and write through their
161
+ * attempt. Every async syscall of a query or a mutation, nested calls included,
162
+ * takes its turn on `turn` in the order the function made it.
163
+ */
164
+ interface Execution {
165
+ readonly world: World;
166
+ readonly componentPath: string;
167
+ readonly udfPath: string;
168
+ readonly udfType: UdfType;
169
+ readonly attempt: KernelAttempt | undefined;
170
+ readonly identity: UserIdentity | null;
171
+ readonly request: Request;
172
+ /**
173
+ * The kernel's time when the outermost query or mutation began. Its nested
174
+ * calls share it, and `Date` answers it inside them.
175
+ */
176
+ readonly startTime: number;
177
+ readonly pagination: { calls: number } | undefined;
178
+ readonly turn: Turn;
179
+ }
180
+
181
+ /**
182
+ * A queue that runs one task at a time, in the order they were taken.
183
+ */
184
+ class Turn {
185
+ private tail: Promise<unknown> = Promise.resolve();
186
+
187
+ take<A>(task: () => A | Promise<A>): Promise<A> {
188
+ const result = this.tail.then(task);
189
+ this.tail = result.then(
190
+ () => undefined,
191
+ () => undefined,
192
+ );
193
+ return result;
194
+ }
195
+
196
+ /**
197
+ * Resolves once every task taken so far has settled, including the tasks
198
+ * taken while waiting.
199
+ */
200
+ idle(): Promise<void> {
201
+ const tail = this.tail;
202
+ return tail.then(() => (this.tail === tail ? undefined : this.idle()));
203
+ }
204
+ }
205
+
206
+ /**
207
+ * A function's invocation finishes once its handler returned and every syscall
208
+ * it started has run. On the binary a query or mutation keeps running the
209
+ * syscalls in its queue after its promise resolves, then returns (`run_inner`
210
+ * in `crates/isolate/src/environment/udf/mod.rs`, 1.49), so an unawaited write
211
+ * still lands in its own transaction.
212
+ */
213
+ const settled = <A>(execution: Execution, result: A): Promise<A> =>
214
+ execution.turn.idle().then(() => result);
215
+
216
+ const executions = new AsyncLocalStorage<Execution>();
217
+
218
+ /**
219
+ * Run a function's code as `execution`, with its own copy of the globals a test
220
+ * invocation pins.
221
+ */
222
+ const enter = <A>(execution: Execution, run: () => A): A => {
223
+ installConvexGlobal();
224
+ return Globals.own(() => executions.run(execution, run));
225
+ };
226
+ const kernelBrand = Symbol.for("@confect/test/Kernel");
227
+
228
+ /**
229
+ * The time `Date` answers inside a kernel invocation: a query's or mutation's
230
+ * start, or the clock as it runs in an action. Outside one, none.
231
+ */
232
+ const pinnedTime = (): number | undefined => {
233
+ const execution = executions.getStore();
234
+ if (execution === undefined) return undefined;
235
+ return execution.udfType === "action"
236
+ ? execution.world.now()
237
+ : execution.startTime;
238
+ };
239
+
240
+ /**
241
+ * How many jobs one drain or advance runs before it gives up, as convex-test's
242
+ * `finishAllScheduledFunctions` defaults to.
243
+ */
244
+ const defaultBudget = 100;
245
+
246
+ const current = (): Execution => {
247
+ const execution = executions.getStore();
248
+ if (execution === undefined) {
249
+ throw new Error(
250
+ "A Convex syscall ran outside any function the test kernel started.",
251
+ );
252
+ }
253
+ return execution;
254
+ };
255
+
256
+ let nextRequestId = 0;
257
+ const newRequest = (caller: Caller): Request => ({
258
+ ip: caller.ip,
259
+ userAgent: caller.userAgent,
260
+ requestId: `kernel-request-${nextRequestId++}`,
261
+ scheduledFunctionId: null,
262
+ authToken: caller.token,
263
+ });
264
+
265
+ /**
266
+ * A scheduled call is a request of its own: no caller's attributes, and its
267
+ * job's ID.
268
+ */
269
+ const jobRequest = (jobId: DocumentId): Request => ({
270
+ ...newRequest(anonymous),
271
+ scheduledFunctionId: jobId,
272
+ });
273
+
274
+ const findModulesRoot = (paths: ReadonlyArray<string>): string => {
275
+ const generated = paths.find((path) => path.includes("_generated"));
276
+ if (generated === undefined) {
277
+ throw new Error(
278
+ 'Could not find the "_generated" directory, make sure to run ' +
279
+ "`npx convex dev` or `npx convex codegen`. Make sure your " +
280
+ '`import.meta.glob` includes the files in the "_generated" directory',
281
+ );
282
+ }
283
+ return generated.split("_generated", 2)[0]!;
284
+ };
285
+
286
+ const makeComponent = (
287
+ path: string,
288
+ schema: SchemaDefinition<GenericSchema, boolean> | null | undefined,
289
+ modules: Modules,
290
+ standing: ReadonlyArray<string> = [],
291
+ ): Component => {
292
+ const root = findModulesRoot(Object.keys(modules));
293
+ const relative = Object.entries(modules)
294
+ .filter(([key]) => key.startsWith(root))
295
+ .map(([key, load]) => [key.slice(root.length), load] as const);
296
+ const nested = relative
297
+ .map(([file]) => file)
298
+ .filter((file) => file.endsWith("/convex.config.ts"))
299
+ .map((file) => file.slice(0, -"convex.config.ts".length));
300
+ return {
301
+ path,
302
+ schema: KernelQuery.exportSchema(schema),
303
+ loaders: new Map(
304
+ relative
305
+ .filter(
306
+ ([file]) =>
307
+ isFunctionModule(file) &&
308
+ !nested.some((dir) => file.startsWith(dir)),
309
+ )
310
+ .map(([file, load]) => [file.replace(/\.[^.]+$/, ""), load]),
311
+ ),
312
+ loaded: new Map(),
313
+ standing: new Set(standing),
314
+ };
315
+ };
316
+
317
+ const entryExtensions = [
318
+ ".js",
319
+ ".mjs",
320
+ ".cjs",
321
+ ".ts",
322
+ ".tsx",
323
+ ".mts",
324
+ ".cts",
325
+ ".jsx",
326
+ ];
327
+
328
+ /**
329
+ * Whether Convex's bundler deploys this file as a function module, by the rule
330
+ * `entryPoints` applies in `convex/src/bundler/index.ts` (1.49). A test file, a
331
+ * schema, a config and `_generated` never reach the binary, so the kernel
332
+ * neither loads them nor resolves a function in them.
333
+ */
334
+ const isFunctionModule = (file: string): boolean => {
335
+ const base = file.split("/").pop()!;
336
+ return (
337
+ entryExtensions.some((extension) => file.endsWith(extension)) &&
338
+ !file.startsWith("_generated/") &&
339
+ !base.startsWith(".") &&
340
+ !base.startsWith("#") &&
341
+ base !== "schema.ts" &&
342
+ base !== "schema.js" &&
343
+ (base.match(/\./g) ?? []).length <= 1 &&
344
+ !file.includes(" ")
345
+ );
346
+ };
347
+
348
+ const unregistered = (world: World, componentPath: string) => {
349
+ const registered = [...world.components.keys()].filter((p) => p !== ROOT);
350
+ return new Error(
351
+ `Component "${componentPath}" is not registered on this test backend. ` +
352
+ (registered.length === 0
353
+ ? "No components have been registered."
354
+ : `Registered components: ${registered.map((p) => `"${p}"`).join(", ")}.`),
355
+ );
356
+ };
357
+
358
+ type FunctionAddress = {
359
+ readonly name?: string | undefined;
360
+ readonly reference?: string | undefined;
361
+ readonly functionHandle?: string | undefined;
362
+ };
363
+
364
+ /**
365
+ * Where an address points, read from the calling component.
366
+ */
367
+ const functionPathOf = (
368
+ address: FunctionAddress,
369
+ from: string,
370
+ ): FunctionPath => {
371
+ if (address.functionHandle !== undefined) {
372
+ return parseFunctionHandle(address.functionHandle);
373
+ }
374
+ if (address.name !== undefined) {
375
+ return { udfPath: address.name, componentPath: from };
376
+ }
377
+ if (address.reference !== undefined) {
378
+ // "_reference/childComponent/<child>/path/to/file/fn"
379
+ const parts = address.reference.split("/");
380
+ const child = parts[2]!;
381
+ const rest = parts.slice(3);
382
+ return {
383
+ componentPath: from.length > 0 ? `${from}/${child}` : child,
384
+ udfPath: `${rest.slice(0, -1).join("/")}:${rest[rest.length - 1]}`,
385
+ };
386
+ }
387
+ throw new Error("Function address not supported");
388
+ };
389
+
390
+ const pathOfReference = (
391
+ reference: FunctionReference<any, any, any, any> | string,
392
+ from: string,
393
+ ) =>
394
+ functionPathOf(getFunctionAddress(reference as any) as FunctionAddress, from);
395
+
396
+ interface Resolved {
397
+ readonly func: any;
398
+ readonly handler: (ctx: any, args: any) => any;
399
+ readonly args: ValidatorJSON;
400
+ readonly returns: ValidatorJSON | null;
401
+ }
402
+
403
+ const kindCheck = {
404
+ query: "isQuery",
405
+ mutation: "isMutation",
406
+ action: "isAction",
407
+ } as const;
408
+
409
+ /**
410
+ * A test backend: its registered components and the kernel state they share.
411
+ */
412
+ export class World {
413
+ readonly components = new Map<string, Component>();
414
+ state: Kernel.State = Kernel.empty;
415
+ private opened: Promise<void> | undefined;
416
+ private nextDocId = 10000;
417
+ private lastCreationTime = 0;
418
+ private nextQueryId = 1;
419
+ private readonly queries = new Map<number, Iterator<any>>();
420
+ private readonly holding = new Map<string, Held>();
421
+ private nextAttemptId = 1;
422
+ /**
423
+ * The latest deadline time has been advanced to. The clock never reads
424
+ * earlier.
425
+ */
426
+ private floor = Number.NEGATIVE_INFINITY;
427
+ private readonly targets = new Map<DocumentId, Scheduling.Target>();
428
+
429
+ constructor(
430
+ schema: SchemaDefinition<GenericSchema, boolean> | null | undefined,
431
+ modules: Modules,
432
+ private readonly runtime: Kernel.KernelRuntime = Kernel.wallClock(),
433
+ ) {
434
+ installConvexGlobal();
435
+ Clock.install(pinnedTime, () => executions.getStore() !== undefined);
436
+ this.components.set(ROOT, makeComponent(ROOT, schema, modules));
437
+ }
438
+
439
+ register(
440
+ componentPath: string,
441
+ schema: SchemaDefinition<GenericSchema, boolean>,
442
+ modules: Modules,
443
+ standing: ReadonlyArray<string> = [],
444
+ ) {
445
+ if (this.opened !== undefined) {
446
+ throw new KernelRefusal.RegisteredAfterOpen({
447
+ componentPath,
448
+ message: `Component "${componentPath}" was registered after the test backend opened. Register every component before the first call.`,
449
+ });
450
+ }
451
+ this.components.set(
452
+ componentPath,
453
+ makeComponent(componentPath, schema, modules, standing),
454
+ );
455
+ }
456
+
457
+ /**
458
+ * Load every registered module of every component, once, before any function
459
+ * runs, then check that each standing source names an exported function.
460
+ */
461
+ open(): Promise<void> {
462
+ this.opened ??= Promise.all(
463
+ Array.from(this.components.values(), (component) =>
464
+ Promise.all(
465
+ Array.from(component.loaders, ([path, load]) =>
466
+ load().then((module) => component.loaded.set(path, module)),
467
+ ),
468
+ ),
469
+ ),
470
+ ).then(() => this.checkStanding());
471
+ return this.opened;
472
+ }
473
+
474
+ /**
475
+ * Refuse a standing source no module of its component exports.
476
+ */
477
+ private checkStanding() {
478
+ for (const component of this.components.values()) {
479
+ for (const name of component.standing) {
480
+ const [modulePath, exportName = "default"] = name.split(":");
481
+ const func = component.loaded.get(modulePath!)?.[exportName];
482
+ if (func?.isMutation !== true && func?.isAction !== true) {
483
+ throw new KernelRefusal.UnknownStandingSource({
484
+ componentPath: component.path,
485
+ name,
486
+ message: `Component "${component.path}" declares "${name}" standing, but exports no mutation or action by that name. Name the self-rescheduling function as module:export.`,
487
+ });
488
+ }
489
+ }
490
+ }
491
+ }
492
+
493
+ /**
494
+ * The kernel's time: the runtime's, never earlier than the latest deadline
495
+ * time was advanced to.
496
+ */
497
+ now(): number {
498
+ return Math.max(this.runtime.now(), this.floor);
499
+ }
500
+
501
+ private component(componentPath: string): Component {
502
+ const component = this.components.get(componentPath);
503
+ if (component === undefined) throw unregistered(this, componentPath);
504
+ return component;
505
+ }
506
+
507
+ private resolve(functionPath: FunctionPath, kind: UdfType | "any"): Resolved {
508
+ const [modulePath, maybeExport] = functionPath.udfPath.split(":") as [
509
+ string,
510
+ string | undefined,
511
+ ];
512
+ const exportName = maybeExport ?? "default";
513
+ const component = this.component(functionPath.componentPath);
514
+ const module = component.loaded.get(modulePath);
515
+ if (module === undefined) {
516
+ throw new Error(`Could not find module for: "${modulePath}"`);
517
+ }
518
+ const func = module[exportName];
519
+ if (func === undefined) {
520
+ throw new Error(
521
+ `Expected a Convex function exported from module "${modulePath}" as \`${exportName}\`, but there is no such export.`,
522
+ );
523
+ }
524
+ const handler = "_handler" in func ? func._handler : func;
525
+ if (typeof handler !== "function") {
526
+ throw new Error(
527
+ `Expected a Convex function exported from module "${modulePath}" as \`${exportName}\`, but got: ${func}`,
528
+ );
529
+ }
530
+ if (kind !== "any" && !func[kindCheck[kind]]) {
531
+ throw new Error(
532
+ `Expected a ${kind} function, but the function exported from module "${modulePath}" as \`${exportName}\` is not a ${kind}.`,
533
+ );
534
+ }
535
+ const args = JSON.parse(func.exportArgs()) as ValidatorJSON;
536
+ if (args.type !== "object" && args.type !== "any") {
537
+ throw new Error(
538
+ `Invalid JSON returned from ${modulePath}.js:${exportName}.exportArgs(): ` +
539
+ `Args validator must be an object or any`,
540
+ );
541
+ }
542
+ return { func, handler, args, returns: JSON.parse(func.exportReturns()) };
543
+ }
544
+
545
+ // Calls from the test.
546
+
547
+ query(caller: Caller, reference: any, args: any): Promise<any> {
548
+ return this.open().then(() => {
549
+ const from = { caller, request: newRequest(caller) };
550
+ return typeof reference === "function"
551
+ ? this.runQuery(
552
+ { componentPath: ROOT, udfPath: "inline" },
553
+ { handler: reference, args: { type: "any" }, returns: null },
554
+ args ?? {},
555
+ from,
556
+ )
557
+ : this.queryAt(pathOfReference(reference, ROOT), args, from);
558
+ });
559
+ }
560
+
561
+ mutation(caller: Caller, reference: any, args: any): Promise<any> {
562
+ return this.open().then(() => {
563
+ const from = { caller, request: newRequest(caller) };
564
+ return typeof reference === "function"
565
+ ? this.runMutation(
566
+ { componentPath: ROOT, udfPath: "inline" },
567
+ { handler: reference, args: { type: "any" }, returns: null },
568
+ args ?? {},
569
+ from,
570
+ )
571
+ : this.mutationAt(pathOfReference(reference, ROOT), args, from);
572
+ });
573
+ }
574
+
575
+ action(caller: Caller, reference: any, args: any): Promise<any> {
576
+ return this.open().then(() => {
577
+ const from = { caller, request: newRequest(caller) };
578
+ return typeof reference === "function"
579
+ ? this.runAction(
580
+ { componentPath: ROOT, udfPath: "inline" },
581
+ { handler: reference, args: { type: "any" }, returns: null },
582
+ args ?? {},
583
+ from,
584
+ )
585
+ : this.actionAt(pathOfReference(reference, ROOT), args, from);
586
+ });
587
+ }
588
+
589
+ run(
590
+ caller: Caller,
591
+ componentPath: string,
592
+ handler: (ctx: any) => any,
593
+ ): Promise<any> {
594
+ return this.open().then(() => {
595
+ this.component(componentPath);
596
+ return this.runMutation(
597
+ { componentPath, udfPath: "custom" },
598
+ { handler, args: { type: "any" }, returns: null },
599
+ {},
600
+ { caller, request: newRequest(caller) },
601
+ );
602
+ });
603
+ }
604
+
605
+ // Held attempts.
606
+
607
+ /**
608
+ * Run a mutation to its commit point and hold it as attempt 1.
609
+ */
610
+ prepare(caller: Caller, reference: any, args: any): Promise<HeldAttempt> {
611
+ return this.open().then(() => {
612
+ const functionPath = pathOfReference(reference, ROOT);
613
+ const resolved = this.resolve(functionPath, "mutation");
614
+ validateFunctionArgs(resolved.args, args ?? {}, functionPath, false);
615
+ return this.prepareMutation(
616
+ functionPath,
617
+ resolved,
618
+ args,
619
+ { caller, request: newRequest(caller) },
620
+ 1,
621
+ Invocation.current(),
622
+ ).then((held) => held.view);
623
+ });
624
+ }
625
+
626
+ apply(id: string): Promise<KernelApply> {
627
+ const held = this.holding.get(id);
628
+ return held === undefined
629
+ ? Promise.reject(
630
+ new KernelRefusal.StaleAttempt({
631
+ id,
632
+ message: `The test kernel holds no attempt "${id}". It was already applied, or never prepared.`,
633
+ }),
634
+ )
635
+ : this.applyHeld(held);
636
+ }
637
+
638
+ /**
639
+ * The held attempts, oldest first.
640
+ */
641
+ held(): ReadonlyArray<HeldAttempt> {
642
+ return Array.from(this.holding.values(), (held) => held.view);
643
+ }
644
+
645
+ // Scheduled functions, on the kernel's clock.
646
+
647
+ /**
648
+ * The committed pending jobs, earliest deadline first.
649
+ */
650
+ jobs(): ReadonlyArray<Scheduling.Job> {
651
+ return Scheduling.pending(this.state, this.targets).map(
652
+ ({ id, componentPath, name, scheduledTime, standing }) => ({
653
+ id,
654
+ componentPath,
655
+ name,
656
+ scheduledTime,
657
+ standing,
658
+ }),
659
+ );
660
+ }
661
+
662
+ /**
663
+ * Move the clock forward `ms`, running every job whose deadline falls on the
664
+ * way, in deadline order, each starting at its deadline. Jobs those jobs
665
+ * schedule inside the window run too. `advance(0)` runs what is due now and
666
+ * crosses no future deadline.
667
+ */
668
+ advance(ms: number, budget = defaultBudget): Promise<void> {
669
+ return this.open().then(() => {
670
+ const until = this.now() + ms;
671
+ return this.walk(
672
+ (pending) => pending.filter((job) => job.scheduledTime <= until),
673
+ budget,
674
+ ).then(() => this.raiseFloor(until));
675
+ });
676
+ }
677
+
678
+ /**
679
+ * Run jobs, earliest deadline first and each at its deadline, until only
680
+ * standing jobs are pending. Every job run counts against `budget`.
681
+ */
682
+ drain(budget = defaultBudget): Promise<void> {
683
+ return this.open().then(() =>
684
+ this.walk((pending) => pending.filter((job) => !job.standing), budget),
685
+ );
686
+ }
687
+
688
+ /**
689
+ * Run the first of `select(pending)` until it is empty, moving the clock to
690
+ * each job's deadline as it starts.
691
+ */
692
+ private walk(
693
+ select: (pending: Array<Scheduling.Due>) => Array<Scheduling.Due>,
694
+ budget: number,
695
+ ): Promise<void> {
696
+ const step = (ran: number): Promise<void> => {
697
+ const left = select(Scheduling.pending(this.state, this.targets));
698
+ const next = left[0];
699
+ if (next === undefined) return Promise.resolve();
700
+ if (ran === budget) {
701
+ const pending = Scheduling.functions(left);
702
+ return Promise.reject(
703
+ new KernelRefusal.DrainBudgetExceeded({
704
+ budget,
705
+ pending,
706
+ message: `Scheduled functions ran ${budget} jobs and work remains: ${pending
707
+ .map(({ componentPath, name }) =>
708
+ componentPath === ROOT ? name : `${name} in "${componentPath}"`,
709
+ )
710
+ .join(
711
+ ", ",
712
+ )}. A function that schedules its own next run never lets the drain end, so its kit declares it with registerComponent's \`standing\` option.`,
713
+ }),
714
+ );
715
+ }
716
+ this.raiseFloor(next.scheduledTime);
717
+ return this.runJob(next).then(() => step(ran + 1));
718
+ };
719
+ return step(0);
720
+ }
721
+
722
+ private raiseFloor(to: number) {
723
+ this.floor = Math.max(this.floor, to);
724
+ }
725
+
726
+ /**
727
+ * Run one pending job as its own request, moving its row through in progress
728
+ * to success or failed. `walk` picks it from the committed pending jobs and
729
+ * starts it in the same turn, so no cancel can land in between. The job runs
730
+ * as a fresh invocation of the world that scheduled it, whatever invocation
731
+ * the drain itself runs under.
732
+ */
733
+ private runJob(job: Scheduling.Due): Promise<void> {
734
+ this.commitNow((attempt) => {
735
+ const doc = attempt.get(job.componentPath, job.id)!;
736
+ attempt.write(job.componentPath, job.id, {
737
+ ...doc,
738
+ state: { kind: "inProgress" },
739
+ });
740
+ });
741
+ const request = jobRequest(job.id);
742
+ const from: From = { caller: anonymous, request };
743
+ const run = () =>
744
+ Promise.resolve().then(() => {
745
+ const { func } = this.resolve(job.target, "any");
746
+ return func.isQuery
747
+ ? this.queryAt(job.target, job.args, from)
748
+ : func.isMutation
749
+ ? this.mutationAt(job.target, job.args, from)
750
+ : this.actionAt(job.target, job.args, from);
751
+ });
752
+ return (
753
+ job.world === undefined
754
+ ? Invocation.unbound(run)
755
+ : Invocation.bind(
756
+ Invocation.fresh(job.world, job.target.componentPath, request),
757
+ run,
758
+ )
759
+ )
760
+ .then(
761
+ (): Scheduling.JobState => ({ kind: "success" }),
762
+ (error: unknown): Scheduling.JobState => ({
763
+ kind: "failed",
764
+ error: error instanceof Error ? error.message : String(error),
765
+ }),
766
+ )
767
+ .then((state) =>
768
+ this.commitNow((attempt) => {
769
+ const doc = attempt.get(job.componentPath, job.id);
770
+ if (doc === null || Scheduling.stateOf(doc).kind !== "inProgress")
771
+ return;
772
+ attempt.write(job.componentPath, job.id, {
773
+ ...doc,
774
+ state,
775
+ completedTime: this.now(),
776
+ });
777
+ }),
778
+ );
779
+ }
780
+
781
+ /**
782
+ * Write through a fresh attempt and publish it at once, as the scheduler does
783
+ * for an action's schedule and a job's state. Nothing runs between the
784
+ * snapshot and the commit, so no other commit can conflict with it.
785
+ */
786
+ private commitNow<A>(write: (attempt: KernelAttempt) => A): A {
787
+ const attempt = new KernelAttempt(this.state);
788
+ const result = write(attempt);
789
+ this.publishWrites(attempt.writes());
790
+ return result;
791
+ }
792
+
793
+ /**
794
+ * Publish `writes` as the next commit, at the runtime's timestamp for it. No
795
+ * writes publish nothing and ask the runtime for no timestamp, which answers
796
+ * the latest commit's.
797
+ */
798
+ private publishWrites(writes: ReadonlyArray<Kernel.Write>): bigint {
799
+ if (writes.length === 0) return this.state.commitTs;
800
+ const commitTs = this.runtime.commitTs(this.state.version + 1);
801
+ if (commitTs <= this.state.commitTs) {
802
+ throw new KernelRefusal.CommitTsNotIncreasing({
803
+ version: this.state.version + 1,
804
+ previous: this.state.commitTs,
805
+ commitTs,
806
+ message: `The runtime answered commit timestamp ${commitTs} for version ${this.state.version + 1}, which is not greater than the previous commit's ${this.state.commitTs}.`,
807
+ });
808
+ }
809
+ this.state = Kernel.apply(this.state, writes, commitTs);
810
+ return commitTs;
811
+ }
812
+
813
+ /**
814
+ * Write a pending job into `attempt`, so the job exists only once that
815
+ * attempt publishes. A job that a standing function schedules, or that runs
816
+ * one, is standing.
817
+ */
818
+ private scheduleJob(
819
+ attempt: KernelAttempt,
820
+ execution: Execution,
821
+ args: any,
822
+ ): DocumentId {
823
+ const target = functionPathOf(args, execution.componentPath);
824
+ this.component(target.componentPath);
825
+ const jobArgs = jsonToConvex(args.args);
826
+ if (hasPendingValues(jobArgs)) {
827
+ throw new Error(
828
+ `Invalid arguments for "${target.udfPath}": a scheduled function cannot take an unresolved placeholder value.`,
829
+ );
830
+ }
831
+ const id = this.generateId(Scheduling.table);
832
+ attempt.write(
833
+ execution.componentPath,
834
+ id,
835
+ Scheduling.row(
836
+ id,
837
+ this.creationTime(execution.startTime),
838
+ target.udfPath,
839
+ jobArgs,
840
+ args.ts * 1000,
841
+ ),
842
+ );
843
+ this.targets.set(id, {
844
+ functionPath: target,
845
+ standing:
846
+ this.isStanding({
847
+ componentPath: execution.componentPath,
848
+ udfPath: execution.udfPath,
849
+ }) || this.isStanding(target),
850
+ world: Invocation.current()?.world,
851
+ });
852
+ return id;
853
+ }
854
+
855
+ /**
856
+ * Cancel a job that has not finished. A finished job stays as it ended.
857
+ */
858
+ private cancelJob(attempt: KernelAttempt, componentPath: string, id: any) {
859
+ validateId(Scheduling.table, id);
860
+ attempt.readDocument(componentPath, id);
861
+ const doc = attempt.get(componentPath, id);
862
+ if (doc === null) {
863
+ throw new Error(`Patch on non-existent document with ID "${id}"`);
864
+ }
865
+ const { kind } = Scheduling.stateOf(doc);
866
+ if (kind === "pending" || kind === "inProgress") {
867
+ attempt.write(componentPath, id, { ...doc, state: { kind: "canceled" } });
868
+ }
869
+ }
870
+
871
+ private isStanding({ componentPath, udfPath }: FunctionPath): boolean {
872
+ return this.components.get(componentPath)?.standing.has(udfPath) ?? false;
873
+ }
874
+
875
+ // Calls by address, from the test or from a running function.
876
+
877
+ private queryAt(
878
+ functionPath: FunctionPath,
879
+ args: any,
880
+ from: From,
881
+ snapshotOnly = false,
882
+ ) {
883
+ const resolved = this.resolve(functionPath, "query");
884
+ validateFunctionArgs(
885
+ resolved.args,
886
+ args ?? {},
887
+ functionPath,
888
+ from.parent?.attempt !== undefined,
889
+ );
890
+ return this.runQuery(functionPath, resolved, args, from, snapshotOnly);
891
+ }
892
+
893
+ private mutationAt(functionPath: FunctionPath, args: any, from: From) {
894
+ const resolved = this.resolve(functionPath, "mutation");
895
+ validateFunctionArgs(
896
+ resolved.args,
897
+ args ?? {},
898
+ functionPath,
899
+ from.parent?.attempt !== undefined,
900
+ );
901
+ return this.runMutation(functionPath, resolved, args, from);
902
+ }
903
+
904
+ private actionAt(functionPath: FunctionPath, args: any, from: From) {
905
+ const resolved = this.resolve(functionPath, "action");
906
+ validateFunctionArgs(resolved.args, args ?? {}, functionPath, false);
907
+ return this.runAction(functionPath, resolved, args, from);
908
+ }
909
+
910
+ // Runners.
911
+
912
+ private execution(
913
+ functionPath: FunctionPath,
914
+ udfType: UdfType,
915
+ attempt: KernelAttempt | undefined,
916
+ from: From,
917
+ pagination: { calls: number } | undefined,
918
+ ): Execution {
919
+ // A function in a component runs with no identity, whoever called it, as
920
+ // Convex gives a component no `ctx.auth` of the caller's. A nested call in
921
+ // the host takes its parent's identity, even when that is none.
922
+ const identity =
923
+ functionPath.componentPath !== ROOT
924
+ ? null
925
+ : from.parent !== undefined
926
+ ? from.parent.identity
927
+ : from.caller.identity;
928
+ return {
929
+ world: this,
930
+ componentPath: functionPath.componentPath,
931
+ udfPath: functionPath.udfPath,
932
+ udfType,
933
+ attempt,
934
+ identity,
935
+ request: from.request,
936
+ startTime:
937
+ from.parent !== undefined && from.parent.udfType !== "action"
938
+ ? from.parent.startTime
939
+ : this.now(),
940
+ pagination,
941
+ turn: new Turn(),
942
+ };
943
+ }
944
+
945
+ private runQuery(
946
+ functionPath: FunctionPath,
947
+ resolved: Pick<Resolved, "handler" | "args" | "returns">,
948
+ args: any,
949
+ from: From,
950
+ snapshotOnly = false,
951
+ ): Promise<any> {
952
+ const parentAttempt = from.parent?.attempt;
953
+ const isNested = parentAttempt !== undefined;
954
+ const attempt =
955
+ parentAttempt === undefined
956
+ ? new KernelAttempt(this.state)
957
+ : snapshotOnly
958
+ ? new KernelAttempt(parentAttempt.snapshot)
959
+ : parentAttempt;
960
+ const execution = this.execution(
961
+ functionPath,
962
+ "query",
963
+ attempt,
964
+ from,
965
+ (isNested && from.parent?.pagination) || { calls: 0 },
966
+ );
967
+ const q = queryGeneric({
968
+ handler: (ctx: any, a: any) =>
969
+ resolved.handler({ ...ctx, auth: queryAuth(execution) }, a),
970
+ }) as unknown as { invokeQuery: (args: string) => Promise<string> };
971
+ return enter(execution, () =>
972
+ q.invokeQuery(JSON.stringify(convexToJson([parseArgs(args)]))),
973
+ )
974
+ .then((raw) => settled(execution, raw))
975
+ .then((raw) => {
976
+ const result = jsonToConvex(JSON.parse(raw));
977
+ if (!isNested && hasPendingValues(result)) {
978
+ throw new Error(
979
+ `Function ${functionPath.udfPath} return value invalid: queries ` +
980
+ `cannot return an unresolved placeholder value.`,
981
+ );
982
+ }
983
+ validateReturnValue(resolved.returns, result, "query", functionPath);
984
+ return result;
985
+ })
986
+ .catch((error: unknown) => {
987
+ throw deserializeConvexErrorData(error);
988
+ });
989
+ }
990
+
991
+ private invokeMutation(
992
+ execution: Execution,
993
+ resolved: Pick<Resolved, "handler" | "returns">,
994
+ args: any,
995
+ ): Promise<Value> {
996
+ const m = mutationGeneric({
997
+ handler: (ctx: any, a: any) =>
998
+ resolved.handler({ ...ctx, auth: mutationAuth(execution) }, a),
999
+ }) as unknown as { invokeMutation: (args: string) => Promise<string> };
1000
+ return enter(execution, () =>
1001
+ m.invokeMutation(JSON.stringify(convexToJson([parseArgs(args)]))),
1002
+ )
1003
+ .then((raw) => settled(execution, raw))
1004
+ .then((raw) => {
1005
+ const result = jsonToConvex(JSON.parse(raw));
1006
+ validateReturnValue(resolved.returns, result, "mutation", {
1007
+ componentPath: execution.componentPath,
1008
+ udfPath: execution.udfPath,
1009
+ });
1010
+ return result;
1011
+ });
1012
+ }
1013
+
1014
+ private runMutation(
1015
+ functionPath: FunctionPath,
1016
+ resolved: Pick<Resolved, "handler" | "args" | "returns">,
1017
+ args: any,
1018
+ from: From,
1019
+ ): Promise<any> {
1020
+ const parentAttempt = from.parent?.attempt;
1021
+ if (parentAttempt !== undefined) {
1022
+ const frame = parentAttempt.child();
1023
+ const execution = this.execution(
1024
+ functionPath,
1025
+ "mutation",
1026
+ frame,
1027
+ from,
1028
+ undefined,
1029
+ );
1030
+ // As at the top level, a nested mutation ends once its function has
1031
+ // returned and every syscall it started has settled. Convex runs a
1032
+ // nested function's syscall queue to the end before it commits its
1033
+ // subtransaction, so a detached fiber's late write lands in this frame
1034
+ // and merges with it. A failed one ends at once and rolls back.
1035
+ return this.invokeMutation(execution, resolved, args).then(
1036
+ (result) =>
1037
+ frame.drained().then(() => {
1038
+ frame.close();
1039
+ frame.merge();
1040
+ return result;
1041
+ }),
1042
+ (error: unknown) => {
1043
+ frame.close();
1044
+ throw deserializeConvexErrorData(error);
1045
+ },
1046
+ );
1047
+ }
1048
+ return this.prepareMutation(
1049
+ functionPath,
1050
+ resolved,
1051
+ args,
1052
+ from,
1053
+ 1,
1054
+ Invocation.current(),
1055
+ ).then((held) => this.publish(held));
1056
+ }
1057
+
1058
+ /**
1059
+ * Run one attempt of a top-level mutation against the latest commit, then
1060
+ * hold it. The commit point is once the function has returned and every
1061
+ * syscall it started has settled. A test invocation bound when the mutation
1062
+ * was first called reaches each attempt's handler with that attempt's
1063
+ * number.
1064
+ */
1065
+ private prepareMutation(
1066
+ functionPath: FunctionPath,
1067
+ resolved: Pick<Resolved, "handler" | "returns">,
1068
+ args: any,
1069
+ from: From,
1070
+ number: Invocation.Attempt,
1071
+ bound: Invocation.Invocation | undefined,
1072
+ ): Promise<Held> {
1073
+ const attempt = new KernelAttempt(this.state, number);
1074
+ const execution = this.execution(
1075
+ functionPath,
1076
+ "mutation",
1077
+ attempt,
1078
+ from,
1079
+ undefined,
1080
+ );
1081
+ const run = () => this.invokeMutation(execution, resolved, args);
1082
+ const started =
1083
+ bound === undefined
1084
+ ? run()
1085
+ : Invocation.bind({ ...bound, attempt: number }, run);
1086
+ return started
1087
+ .then(
1088
+ (value) =>
1089
+ attempt.drained().then((): Outcome => ({ _tag: "returned", value })),
1090
+ (error: unknown): Outcome => ({
1091
+ _tag: "failed",
1092
+ error: deserializeConvexErrorData(error),
1093
+ }),
1094
+ )
1095
+ .then((outcome) => {
1096
+ attempt.close();
1097
+ const held: Held = {
1098
+ view: {
1099
+ id: `attempt-${this.nextAttemptId++}`,
1100
+ request: from.request.requestId,
1101
+ number,
1102
+ snapshot: attempt.snapshot.version,
1103
+ reads: attempt.reads,
1104
+ outcome,
1105
+ },
1106
+ attempt,
1107
+ again: (next) =>
1108
+ this.prepareMutation(
1109
+ functionPath,
1110
+ resolved,
1111
+ args,
1112
+ from,
1113
+ next,
1114
+ bound,
1115
+ ),
1116
+ };
1117
+ this.holding.set(held.view.id, held);
1118
+ return held;
1119
+ });
1120
+ }
1121
+
1122
+ /**
1123
+ * Validate a held attempt's reads against every commit since its snapshot,
1124
+ * then publish its writes or prepare its next attempt. An attempt that wrote
1125
+ * nothing publishes nothing and has nothing to validate. It answers its
1126
+ * snapshot's version and commit timestamp.
1127
+ */
1128
+ private applyHeld({ view, attempt, again }: Held): Promise<KernelApply> {
1129
+ this.holding.delete(view.id);
1130
+ const { outcome } = view;
1131
+ if (outcome._tag === "failed") {
1132
+ return Promise.resolve({
1133
+ kind: "refused",
1134
+ tag: tagOf(outcome.error),
1135
+ error: outcome.error,
1136
+ });
1137
+ }
1138
+ const writes = attempt.writes();
1139
+ if (writes.length === 0) {
1140
+ // As on the binary, a mutation that wrote nothing is serialized at its
1141
+ // snapshot and allocates no timestamp for a later commit.
1142
+ const { version, commitTs } = attempt.snapshot;
1143
+ return Promise.resolve({
1144
+ kind: "applied",
1145
+ version,
1146
+ commitTs,
1147
+ result: resolveCommittedValues(outcome.value, commitTs),
1148
+ });
1149
+ }
1150
+ if (
1151
+ Kernel.conflict(this.state, view.snapshot, attempt.reads) !== undefined
1152
+ ) {
1153
+ if (view.number === lastAttempt) {
1154
+ return Promise.resolve({
1155
+ kind: "exhausted",
1156
+ error: new KernelRefusal.OptimisticConcurrencyControlFailure({
1157
+ attempts: lastAttempt,
1158
+ message: `Documents read by the mutation changed before each of its ${lastAttempt} attempts could commit.`,
1159
+ }),
1160
+ });
1161
+ }
1162
+ return again((view.number + 1) as Invocation.Attempt).then(
1163
+ (next): KernelApply => ({ kind: "conflict", next: next.view }),
1164
+ );
1165
+ }
1166
+ // Validation, publication and the version read share one synchronous turn,
1167
+ // so no other commit lands between them.
1168
+ let commitTs: bigint;
1169
+ try {
1170
+ commitTs = this.publishWrites(writes);
1171
+ } catch (error) {
1172
+ return Promise.reject(error);
1173
+ }
1174
+ return Promise.resolve({
1175
+ kind: "applied",
1176
+ version: this.state.version,
1177
+ commitTs,
1178
+ result: resolveCommittedValues(outcome.value, commitTs),
1179
+ });
1180
+ }
1181
+
1182
+ /**
1183
+ * Apply until the mutation publishes, is refused or is exhausted, as the
1184
+ * binary retries a mutation that conflicts.
1185
+ */
1186
+ private publish(held: Held): Promise<Value> {
1187
+ return this.applyHeld(held).then((applied) => {
1188
+ if (applied.kind === "applied") return applied.result;
1189
+ if (applied.kind === "conflict") {
1190
+ return this.publish(this.holding.get(applied.next.id)!);
1191
+ }
1192
+ throw applied.error;
1193
+ });
1194
+ }
1195
+
1196
+ private runAction(
1197
+ functionPath: FunctionPath,
1198
+ resolved: Pick<Resolved, "handler" | "args" | "returns">,
1199
+ args: any,
1200
+ from: From,
1201
+ ): Promise<any> {
1202
+ const execution = this.execution(
1203
+ functionPath,
1204
+ "action",
1205
+ undefined,
1206
+ from,
1207
+ undefined,
1208
+ );
1209
+ const inner: From = { request: from.request, parent: execution };
1210
+ const a = actionGeneric({
1211
+ handler: (ctx: any, innerArgs: any) =>
1212
+ resolved.handler(
1213
+ {
1214
+ ...ctx,
1215
+ runQuery: (reference: any, queryArgs: any) =>
1216
+ this.queryAt(
1217
+ pathOfReference(reference, functionPath.componentPath),
1218
+ queryArgs,
1219
+ inner,
1220
+ ),
1221
+ runMutation: (reference: any, mutationArgs: any) =>
1222
+ this.mutationAt(
1223
+ pathOfReference(reference, functionPath.componentPath),
1224
+ mutationArgs,
1225
+ inner,
1226
+ ),
1227
+ runAction: (reference: any, actionArgs: any) =>
1228
+ this.actionAt(
1229
+ pathOfReference(reference, functionPath.componentPath),
1230
+ actionArgs,
1231
+ inner,
1232
+ ),
1233
+ auth: queryAuth(execution),
1234
+ },
1235
+ innerArgs,
1236
+ ),
1237
+ }) as unknown as {
1238
+ invokeAction: (requestId: string, args: string) => Promise<string>;
1239
+ };
1240
+ const run = () =>
1241
+ enter(execution, () =>
1242
+ a.invokeAction(
1243
+ from.request.requestId,
1244
+ JSON.stringify(convexToJson([parseArgs(args)])),
1245
+ ),
1246
+ );
1247
+ // An action another function calls is an invocation of its own, with its
1248
+ // world's services, a start time read from its world's clock and its
1249
+ // world's random stream, as a scheduled job is.
1250
+ const bound = from.parent === undefined ? undefined : Invocation.current();
1251
+ const started =
1252
+ bound === undefined
1253
+ ? run()
1254
+ : Invocation.bind(
1255
+ Invocation.fresh(
1256
+ bound.world,
1257
+ functionPath.componentPath,
1258
+ from.request,
1259
+ ),
1260
+ run,
1261
+ );
1262
+ return started
1263
+ .then((raw) => {
1264
+ const result = jsonToConvex(JSON.parse(raw));
1265
+ validateReturnValue(resolved.returns, result, "action", functionPath);
1266
+ return result;
1267
+ })
1268
+ .catch((error: unknown) => {
1269
+ throw deserializeConvexErrorData(error);
1270
+ });
1271
+ }
1272
+
1273
+ // Syscalls.
1274
+
1275
+ private schema(componentPath: string) {
1276
+ return this.component(componentPath).schema;
1277
+ }
1278
+
1279
+ private view(execution: Execution): KernelQuery.TableView {
1280
+ const attempt = transaction(execution);
1281
+ return {
1282
+ schema: this.schema(execution.componentPath),
1283
+ scan: (tableName) => attempt.scan(execution.componentPath, tableName),
1284
+ read: (interval, after) =>
1285
+ attempt.readInterval({
1286
+ ...interval,
1287
+ componentPath: execution.componentPath,
1288
+ after,
1289
+ }),
1290
+ };
1291
+ }
1292
+
1293
+ private generateId(table: string): DocumentId {
1294
+ const counter = String(this.nextDocId++).padStart(
1295
+ TYPICAL_CONVEX_ID_LENGTH - table.length,
1296
+ "0",
1297
+ );
1298
+ return (counter + table) as DocumentId;
1299
+ }
1300
+
1301
+ syscall(op: string, jsonArgs: string): string {
1302
+ const args = JSON.parse(jsonArgs);
1303
+ const execution = current();
1304
+ if (execution.attempt?.closed === true) throw closed(execution, op);
1305
+ switch (op) {
1306
+ case "1.0/getSnapshotTs":
1307
+ return JSON.stringify(
1308
+ convexToJson(transaction(execution).snapshot.commitTs),
1309
+ );
1310
+ case "1.0/queryStream": {
1311
+ // The binary reads a query at each `1.0/queryStreamNext`, which waits
1312
+ // its turn in the queue, so the stream opens at its first read and sees
1313
+ // every write made before it.
1314
+ const queryId = this.nextQueryId++;
1315
+ const view = this.view(execution);
1316
+ let results: Iterator<any> | undefined;
1317
+ this.queries.set(queryId, {
1318
+ next: () => (results ??= KernelQuery.stream(view, args.query)).next(),
1319
+ });
1320
+ return JSON.stringify({ queryId });
1321
+ }
1322
+ case "1.0/queryCleanup":
1323
+ this.queries.delete(args.queryId);
1324
+ return JSON.stringify({});
1325
+ case "1.0/db/normalizeId": {
1326
+ const tableName = tableNameFromId(args.idString);
1327
+ return JSON.stringify({
1328
+ id: tableName === args.table ? args.idString : null,
1329
+ });
1330
+ }
1331
+ default:
1332
+ throw new Error(`The test kernel does not support syscall: "${op}"`);
1333
+ }
1334
+ }
1335
+
1336
+ /**
1337
+ * The binary queues a query's or mutation's async syscalls and runs them one
1338
+ * at a time, oldest first (`start_async_syscall` and `run_inner` in
1339
+ * `crates/isolate/src/environment/udf/mod.rs`, 1.49). A write and a nested
1340
+ * call never share a batch (`AsyncSyscallBatch::can_push` in
1341
+ * `async_syscall.rs`), and a nested call holds the queue until it has
1342
+ * committed or rolled back its subtransaction (`run_udf` in
1343
+ * `async_syscall.rs`). So a caller's write made after its nested call runs
1344
+ * after that call, and a nested mutation sees every write made before it. An
1345
+ * action's calls run side by side.
1346
+ */
1347
+ asyncSyscall(op: string, jsonArgs: string): Promise<string> {
1348
+ const execution = current();
1349
+ const attempt = execution.attempt;
1350
+ if (attempt === undefined) {
1351
+ return Promise.resolve().then(() =>
1352
+ this.dispatchAsync(op, JSON.parse(jsonArgs), execution),
1353
+ );
1354
+ }
1355
+ if (attempt.closed) return Promise.reject(closed(execution, op));
1356
+ // A syscall counts toward its frame's lifetime from the moment it is
1357
+ // queued, so a frame never closes with a write still waiting its turn.
1358
+ const end = attempt.begin();
1359
+ return execution.turn
1360
+ .take(() => this.dispatchAsync(op, JSON.parse(jsonArgs), execution))
1361
+ .finally(end);
1362
+ }
1363
+
1364
+ private dispatchAsync(
1365
+ op: string,
1366
+ args: any,
1367
+ execution: Execution,
1368
+ ): string | Promise<string> {
1369
+ switch (op) {
1370
+ case "1.0/get": {
1371
+ const { table, id } = args;
1372
+ return JSON.stringify(convexToJson(this.get(execution, table, id)));
1373
+ }
1374
+ case "1.0/queryStreamNext": {
1375
+ const results = this.queries.get(args.queryId);
1376
+ if (results === undefined) throw new Error("Bad queryId");
1377
+ const { value, done } = results.next();
1378
+ return JSON.stringify(
1379
+ convexToJson(
1380
+ done ? { value: null, done: true } : { value, done: false },
1381
+ ),
1382
+ );
1383
+ }
1384
+ case "1.0/queryPage": {
1385
+ const pagination = execution.pagination;
1386
+ if (pagination) {
1387
+ pagination.calls += 1;
1388
+ if (pagination.calls > 1) {
1389
+ throw new Error(
1390
+ "Only a single paginated query (`.paginate()`) is allowed per " +
1391
+ "query, including nested query calls and component calls. " +
1392
+ "This is a Convex limit: https://docs.convex.dev/database/pagination",
1393
+ );
1394
+ }
1395
+ }
1396
+ return JSON.stringify(
1397
+ convexToJson(KernelQuery.paginate(this.view(execution), args) as any),
1398
+ );
1399
+ }
1400
+ case "1.0/insert":
1401
+ return JSON.stringify({
1402
+ _id: this.insert(execution, args.table, jsonToConvex(args.value)),
1403
+ });
1404
+ case "1.0/shallowMerge":
1405
+ this.patch(execution, args.table, args.id, args.value);
1406
+ return JSON.stringify({});
1407
+ case "1.0/replace":
1408
+ this.replace(execution, args.table, args.id, args.value);
1409
+ return JSON.stringify({});
1410
+ case "1.0/remove":
1411
+ this.remove(execution, args.table, args.id);
1412
+ return JSON.stringify({});
1413
+ case "1.0/count": {
1414
+ const attempt = transaction(execution);
1415
+ attempt.readInterval({
1416
+ kind: "interval",
1417
+ componentPath: execution.componentPath,
1418
+ table: args.table,
1419
+ fields: ["_creationTime", "_id"],
1420
+ order: "asc",
1421
+ range: [],
1422
+ after: null,
1423
+ })("end");
1424
+ return JSON.stringify(
1425
+ attempt.scan(execution.componentPath, args.table).length,
1426
+ );
1427
+ }
1428
+ case "1.0/getFunctionMetadata":
1429
+ return JSON.stringify(
1430
+ convexToJson({
1431
+ name: execution.udfPath,
1432
+ componentPath: execution.componentPath,
1433
+ }),
1434
+ );
1435
+ case "1.0/getRequestMetadata":
1436
+ if (execution.udfType === "query") {
1437
+ throw new Error(
1438
+ "getRequestMetadata() can only be called from a mutation, action " +
1439
+ "or HTTP action. It is not available in queries.",
1440
+ );
1441
+ }
1442
+ return JSON.stringify(convexToJson(execution.request as any));
1443
+ case "1.0/getDeploymentMetadata":
1444
+ return JSON.stringify(
1445
+ convexToJson({ name: "test", region: null, class: "s16" }),
1446
+ );
1447
+ case "1.0/actions/query":
1448
+ case "1.0/actions/mutation":
1449
+ case "1.0/actions/action": {
1450
+ const from: From = { request: execution.request, parent: execution };
1451
+ const functionPath = functionPathOf(args, execution.componentPath);
1452
+ const run =
1453
+ op === "1.0/actions/query"
1454
+ ? this.queryAt(functionPath, args.args, from)
1455
+ : op === "1.0/actions/mutation"
1456
+ ? this.mutationAt(functionPath, args.args, from)
1457
+ : this.actionAt(functionPath, args.args, from);
1458
+ return run.then((value) => JSON.stringify(convexToJson(value)));
1459
+ }
1460
+ case "1.0/runUdf": {
1461
+ const { udfType, args: udfArgs } = args;
1462
+ const from: From = { request: execution.request, parent: execution };
1463
+ const functionPath = functionPathOf(args, execution.componentPath);
1464
+ const value = jsonToConvex(udfArgs);
1465
+ const run =
1466
+ udfType === "query"
1467
+ ? this.queryAt(functionPath, value, from)
1468
+ : udfType === "snapshotQuery"
1469
+ ? this.queryAt(functionPath, value, from, true)
1470
+ : udfType === "mutation"
1471
+ ? this.mutationAt(functionPath, value, from)
1472
+ : Promise.reject(
1473
+ new Error(
1474
+ `The test kernel does not support udf type: "${udfType}"`,
1475
+ ),
1476
+ );
1477
+ return run.then((result) => JSON.stringify(convexToJson(result)));
1478
+ }
1479
+ case "1.0/createFunctionHandle": {
1480
+ const functionPath = functionPathOf(args, execution.componentPath);
1481
+ this.component(functionPath.componentPath);
1482
+ return JSON.stringify(createFunctionHandle(functionPath));
1483
+ }
1484
+ case "1.0/auditLog":
1485
+ return JSON.stringify(null);
1486
+ case "1.0/schedule":
1487
+ return JSON.stringify(
1488
+ convexToJson(
1489
+ this.scheduleJob(transaction(execution), execution, args),
1490
+ ),
1491
+ );
1492
+ case "1.0/actions/schedule":
1493
+ return JSON.stringify(
1494
+ convexToJson(
1495
+ this.commitNow((attempt) =>
1496
+ this.scheduleJob(attempt, execution, args),
1497
+ ),
1498
+ ),
1499
+ );
1500
+ case "1.0/cancel_job":
1501
+ this.cancelJob(
1502
+ transaction(execution),
1503
+ execution.componentPath,
1504
+ args.id,
1505
+ );
1506
+ return JSON.stringify({});
1507
+ case "1.0/actions/cancel_job":
1508
+ this.commitNow((attempt) =>
1509
+ this.cancelJob(attempt, execution.componentPath, args.id),
1510
+ );
1511
+ return JSON.stringify({});
1512
+ case "1.0/storageDelete":
1513
+ case "1.0/storageGetUrl":
1514
+ case "1.0/storageGenerateUploadUrl":
1515
+ throw KernelRefusal.rowPending("storage", 6);
1516
+ case "1.0/getTransactionMetrics":
1517
+ throw KernelRefusal.rowPending("transaction limits", 6);
1518
+ case "1.0/actions/vectorSearch":
1519
+ throw KernelRefusal.requiresBinary("vectorSearch");
1520
+ default:
1521
+ throw new Error(
1522
+ `The test kernel does not support async syscall: "${op}"`,
1523
+ );
1524
+ }
1525
+ }
1526
+
1527
+ jsSyscall(op: string): Promise<never> {
1528
+ return Promise.reject(
1529
+ op.startsWith("storage/")
1530
+ ? KernelRefusal.rowPending("storage", 6)
1531
+ : new Error(`The test kernel does not support js syscall: "${op}"`),
1532
+ );
1533
+ }
1534
+
1535
+ // Documents. The checks and messages are convex-test's.
1536
+
1537
+ private get(execution: Execution, tableName: unknown, id: unknown) {
1538
+ validateId(tableName, id);
1539
+ const attempt = transaction(execution);
1540
+ attempt.readDocument(execution.componentPath, id);
1541
+ return attempt.get(execution.componentPath, id);
1542
+ }
1543
+
1544
+ private insert(execution: Execution, table: string, value: any): DocumentId {
1545
+ const attempt = transaction(execution);
1546
+ KernelQuery.validateDocument(
1547
+ this.schema(execution.componentPath),
1548
+ table,
1549
+ value,
1550
+ );
1551
+ const _id = this.generateId(table);
1552
+ attempt.write(execution.componentPath, _id, {
1553
+ ...value,
1554
+ _id,
1555
+ _creationTime: this.creationTime(execution.startTime),
1556
+ });
1557
+ return _id;
1558
+ }
1559
+
1560
+ /**
1561
+ * A new document's creation time: `at`, or just past the latest one, so no
1562
+ * two documents share one.
1563
+ */
1564
+ private creationTime(at: number): number {
1565
+ const time =
1566
+ at <= this.lastCreationTime ? this.lastCreationTime + 0.001 : at;
1567
+ this.lastCreationTime = time;
1568
+ return time;
1569
+ }
1570
+
1571
+ private existing(
1572
+ execution: Execution,
1573
+ tableName: unknown,
1574
+ id: unknown,
1575
+ write: string,
1576
+ value: unknown,
1577
+ ): StoredDocument {
1578
+ validateId(tableName, id);
1579
+ if (value !== undefined && typeof value !== "object") {
1580
+ throw new Error(
1581
+ `Invalid argument \`value\` in \`db.${write}\`, expected object but got '${typeof value}': ${value as any}`,
1582
+ );
1583
+ }
1584
+ const attempt = transaction(execution);
1585
+ attempt.readDocument(execution.componentPath, id);
1586
+ const document = attempt.get(execution.componentPath, id);
1587
+ if (document === null) {
1588
+ throw new Error(
1589
+ write === "delete"
1590
+ ? "Delete on non-existent doc"
1591
+ : `${write === "patch" ? "Patch" : "Replace"} on non-existent document with ID "${id}"`,
1592
+ );
1593
+ }
1594
+ return document;
1595
+ }
1596
+
1597
+ private fields(document: StoredDocument, value: Record<string, any>) {
1598
+ if (value._id !== undefined && value._id !== document._id) {
1599
+ throw new Error(
1600
+ `Provided \`_id\` field value "${value._id}" ` +
1601
+ `does not match the document ID "${document._id}"`,
1602
+ );
1603
+ }
1604
+ if (
1605
+ value._creationTime !== undefined &&
1606
+ value._creationTime !== document._creationTime
1607
+ ) {
1608
+ throw new Error(
1609
+ `Provided \`_creationTime\` field value ${value._creationTime} ` +
1610
+ `does not match the document's creation time ${document._creationTime}`,
1611
+ );
1612
+ }
1613
+ const convexValue: Record<string, Value | undefined> = {};
1614
+ for (const [key, v] of Object.entries(value)) {
1615
+ if (key !== "_id" && key !== "_creationTime") {
1616
+ convexValue[key] = evaluateValue(v);
1617
+ }
1618
+ }
1619
+ return convexValue;
1620
+ }
1621
+
1622
+ private patch(
1623
+ execution: Execution,
1624
+ tableName: unknown,
1625
+ id: unknown,
1626
+ value: Record<string, any>,
1627
+ ) {
1628
+ const document = this.existing(execution, tableName, id, "patch", value);
1629
+ const { _id, _creationTime, ...fields } = document;
1630
+ const merged = { ...fields, ...this.fields(document, value) };
1631
+ KernelQuery.validateDocument(
1632
+ this.schema(execution.componentPath),
1633
+ tableNameFromId(_id)!,
1634
+ merged,
1635
+ );
1636
+ transaction(execution).write(execution.componentPath, _id, {
1637
+ _id,
1638
+ _creationTime,
1639
+ ...merged,
1640
+ } as StoredDocument);
1641
+ }
1642
+
1643
+ private replace(
1644
+ execution: Execution,
1645
+ tableName: unknown,
1646
+ id: unknown,
1647
+ value: Record<string, any>,
1648
+ ) {
1649
+ const document = this.existing(execution, tableName, id, "replace", value);
1650
+ const fields = this.fields(document, value);
1651
+ KernelQuery.validateDocument(
1652
+ this.schema(execution.componentPath),
1653
+ tableNameFromId(document._id)!,
1654
+ fields,
1655
+ );
1656
+ transaction(execution).write(execution.componentPath, document._id, {
1657
+ ...fields,
1658
+ _id: document._id,
1659
+ _creationTime: document._creationTime,
1660
+ } as StoredDocument);
1661
+ }
1662
+
1663
+ private remove(execution: Execution, tableName: unknown, id: unknown) {
1664
+ const document = this.existing(
1665
+ execution,
1666
+ tableName,
1667
+ id,
1668
+ "delete",
1669
+ undefined,
1670
+ );
1671
+ transaction(execution).write(execution.componentPath, document._id, null);
1672
+ }
1673
+ }
1674
+
1675
+ /**
1676
+ * Where a call comes from. A call from the test carries its accessor's caller.
1677
+ * A nested call carries its parent execution and takes its identity from it.
1678
+ */
1679
+ type From =
1680
+ | {
1681
+ readonly caller: Caller;
1682
+ readonly request: Request;
1683
+ readonly parent?: undefined;
1684
+ }
1685
+ | { readonly parent: Execution; readonly request: Request };
1686
+
1687
+ const closed = (execution: Execution, operation: string) =>
1688
+ new KernelRefusal.ClosedAttempt({
1689
+ operation,
1690
+ message: `"${operation}" reached mutation ${execution.udfPath} after it returned and its syscalls settled. Await every read and write inside the handler.`,
1691
+ });
1692
+
1693
+ const transaction = (execution: Execution): KernelAttempt => {
1694
+ if (execution.attempt === undefined) {
1695
+ throw new Error(
1696
+ "The database is only available in queries and mutations. An action reads and writes through `ctx.runQuery` and `ctx.runMutation`.",
1697
+ );
1698
+ }
1699
+ return execution.attempt;
1700
+ };
1701
+
1702
+ const queryAuth = (execution: Execution) => ({
1703
+ getUserIdentity: () => Promise.resolve(execution.identity),
1704
+ });
1705
+
1706
+ const mutationAuth = (execution: Execution) => ({
1707
+ ...queryAuth(execution),
1708
+ signJwt: () => Promise.reject(KernelRefusal.rowPending("JWT signing", 6)),
1709
+ });
1710
+
1711
+ function validateId(
1712
+ expectedTableName: unknown,
1713
+ id: unknown,
1714
+ ): asserts id is DocumentId {
1715
+ if (typeof id !== "string") {
1716
+ throw new Error(
1717
+ `Invalid argument \`id\`, expected string but got '${typeof id}': ${id as any}`,
1718
+ );
1719
+ }
1720
+ if (expectedTableName === undefined) return;
1721
+ if (typeof expectedTableName !== "string") {
1722
+ throw new Error(
1723
+ `Invalid argument \`tableName\`, expected string but got '${typeof expectedTableName}': ${expectedTableName as any}`,
1724
+ );
1725
+ }
1726
+ const actualTableName = tableNameFromId(id);
1727
+ if (actualTableName === null) {
1728
+ throw new Error(
1729
+ `Invalid argument \`id\`, expected ID value but got '${id}'`,
1730
+ );
1731
+ }
1732
+ if (actualTableName !== expectedTableName) {
1733
+ throw new Error(
1734
+ `Invalid argument \`id\`, expected ID in table '${expectedTableName}' but got ID in table '${actualTableName}'`,
1735
+ );
1736
+ }
1737
+ }
1738
+
1739
+ interface Syscalls {
1740
+ readonly syscall: (op: string, args: string) => string;
1741
+ readonly asyncSyscall: (op: string, args: string) => Promise<string>;
1742
+ readonly jsSyscall: (op: string, args: unknown) => unknown;
1743
+ }
1744
+
1745
+ type Branded = { readonly [kernelBrand]?: true };
1746
+
1747
+ /**
1748
+ * The backend another library installed as `globalThis.Convex`, which answers
1749
+ * every syscall made outside a kernel function.
1750
+ */
1751
+ let forwarded: Syscalls | undefined;
1752
+
1753
+ // With neither, `current` refuses the syscall.
1754
+ const backend = (): Syscalls =>
1755
+ executions.getStore()?.world ?? forwarded ?? current().world;
1756
+
1757
+ const dispatcher: Syscalls & Branded = {
1758
+ [kernelBrand]: true,
1759
+ syscall: (op, args) => backend().syscall(op, args),
1760
+ asyncSyscall: (op, args) => backend().asyncSyscall(op, args),
1761
+ jsSyscall: (op, args) => backend().jsSyscall(op, args),
1762
+ };
1763
+
1764
+ /**
1765
+ * Convex's server library reaches the backend through `globalThis.Convex`, one
1766
+ * global for the whole test runtime. convex-test 0.0.65 assigns its own the
1767
+ * first time it builds a world (its `dist/index.js:1936-1951`), whenever that
1768
+ * is. The kernel keeps the global an accessor that always reads its dispatcher,
1769
+ * and takes an assignment, or the global it found, as the backend for syscalls
1770
+ * made outside a kernel function. So a kernel world and a convex-test world
1771
+ * stay live together, whichever was built first, and no world restores the
1772
+ * global when it ends. A test that redefines the global removes the accessor,
1773
+ * so every kernel function installs it again over what the test left.
1774
+ */
1775
+ const installConvexGlobal = () => {
1776
+ const found = (globalThis as { Convex?: Syscalls & Branded }).Convex;
1777
+ if (found?.[kernelBrand] === true) return;
1778
+ forwarded = found;
1779
+ Object.defineProperty(globalThis, "Convex", {
1780
+ get: () => dispatcher,
1781
+ set: (value: (Syscalls & Branded) | undefined) => {
1782
+ if (value?.[kernelBrand] !== true) forwarded = value;
1783
+ },
1784
+ configurable: true,
1785
+ enumerable: true,
1786
+ });
1787
+ };
1788
+
1789
+ /**
1790
+ * Whether `globalThis.Convex` is the kernel's, and no other backend installed
1791
+ * one behind it.
1792
+ */
1793
+ export const ownsConvexGlobal = (): boolean =>
1794
+ (globalThis as { Convex?: Branded }).Convex?.[kernelBrand] === true &&
1795
+ forwarded === undefined;