@gaganref/confect-test 10.3.1-fork.89 → 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 (56) hide show
  1. package/dist/TestConfect.d.ts +33 -7
  2. package/dist/TestConfect.d.ts.map +1 -1
  3. package/dist/TestConfect.js +11 -6
  4. package/dist/TestConfect.js.map +1 -1
  5. package/dist/internal/Attempt.js.map +1 -1
  6. package/dist/internal/Clock.d.ts +29 -0
  7. package/dist/internal/Clock.d.ts.map +1 -0
  8. package/dist/internal/Clock.js +66 -0
  9. package/dist/internal/Clock.js.map +1 -0
  10. package/dist/internal/ConvexTest.d.ts +33 -1
  11. package/dist/internal/ConvexTest.d.ts.map +1 -1
  12. package/dist/internal/ConvexTest.js +8 -5
  13. package/dist/internal/ConvexTest.js.map +1 -1
  14. package/dist/internal/Execution.d.ts +85 -5
  15. package/dist/internal/Execution.d.ts.map +1 -1
  16. package/dist/internal/Execution.js +285 -51
  17. package/dist/internal/Execution.js.map +1 -1
  18. package/dist/internal/Globals.d.ts.map +1 -1
  19. package/dist/internal/Globals.js +12 -0
  20. package/dist/internal/Globals.js.map +1 -1
  21. package/dist/internal/Invocation.d.ts +11 -0
  22. package/dist/internal/Invocation.d.ts.map +1 -1
  23. package/dist/internal/Invocation.js +34 -21
  24. package/dist/internal/Invocation.js.map +1 -1
  25. package/dist/internal/Kernel.d.ts +3 -1
  26. package/dist/internal/Kernel.d.ts.map +1 -1
  27. package/dist/internal/Kernel.js +2 -2
  28. package/dist/internal/Kernel.js.map +1 -1
  29. package/dist/internal/KernelRefusal.d.ts +25 -0
  30. package/dist/internal/KernelRefusal.d.ts.map +1 -1
  31. package/dist/internal/KernelRefusal.js +22 -1
  32. package/dist/internal/KernelRefusal.js.map +1 -1
  33. package/dist/internal/KernelValues.d.ts +6 -4
  34. package/dist/internal/KernelValues.d.ts.map +1 -1
  35. package/dist/internal/KernelValues.js +20 -5
  36. package/dist/internal/KernelValues.js.map +1 -1
  37. package/dist/internal/MountedHttp.d.ts.map +1 -1
  38. package/dist/internal/MountedHttp.js +3 -3
  39. package/dist/internal/MountedHttp.js.map +1 -1
  40. package/dist/internal/Scheduling.d.ts +71 -0
  41. package/dist/internal/Scheduling.d.ts.map +1 -0
  42. package/dist/internal/Scheduling.js +60 -0
  43. package/dist/internal/Scheduling.js.map +1 -0
  44. package/package.json +3 -3
  45. package/src/TestConfect.ts +62 -15
  46. package/src/internal/Attempt.ts +1 -1
  47. package/src/internal/Clock.ts +95 -0
  48. package/src/internal/ConvexTest.ts +51 -6
  49. package/src/internal/Execution.ts +415 -50
  50. package/src/internal/Globals.ts +20 -0
  51. package/src/internal/Invocation.ts +38 -13
  52. package/src/internal/Kernel.ts +5 -5
  53. package/src/internal/KernelRefusal.ts +24 -0
  54. package/src/internal/KernelValues.ts +44 -4
  55. package/src/internal/MountedHttp.ts +13 -6
  56. package/src/internal/Scheduling.ts +126 -0
@@ -11,12 +11,14 @@ import {
11
11
  } from "convex/server";
12
12
  import { convexToJson, jsonToConvex, type Value } from "convex/values";
13
13
  import { KernelAttempt } from "./Attempt.js";
14
+ import * as Clock from "./Clock.js";
14
15
  import * as Globals from "./Globals.js";
15
16
  import * as Invocation from "./Invocation.js";
16
17
  import * as Kernel from "./Kernel.js";
17
18
  import * as KernelQuery from "./KernelQuery.js";
18
19
  import * as KernelRefusal from "./KernelRefusal.js";
19
20
  import type * as ReadSet from "./ReadSet.js";
21
+ import * as Scheduling from "./Scheduling.js";
20
22
  import type {
21
23
  DocumentId,
22
24
  FunctionPath,
@@ -44,14 +46,19 @@ const TYPICAL_CONVEX_ID_LENGTH = 32;
44
46
  type Modules = Record<string, () => Promise<any>>;
45
47
 
46
48
  /**
47
- * One registered component: its schema and its function modules, each loaded
48
- * once when the world opens.
49
+ * One registered component: its schema, its function modules, each loaded once
50
+ * when the world opens, and its standing sources.
49
51
  */
50
52
  interface Component {
51
53
  readonly path: string;
52
54
  readonly schema: KernelQuery.KernelSchema | null;
53
55
  readonly loaders: ReadonlyMap<string, () => Promise<any>>;
54
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>;
55
62
  }
56
63
 
57
64
  /**
@@ -162,6 +169,11 @@ interface Execution {
162
169
  readonly attempt: KernelAttempt | undefined;
163
170
  readonly identity: UserIdentity | null;
164
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;
165
177
  readonly pagination: { calls: number } | undefined;
166
178
  readonly turn: Turn;
167
179
  }
@@ -207,10 +219,30 @@ const executions = new AsyncLocalStorage<Execution>();
207
219
  * Run a function's code as `execution`, with its own copy of the globals a test
208
220
  * invocation pins.
209
221
  */
210
- const enter = <A>(execution: Execution, run: () => A): A =>
211
- Globals.own(() => executions.run(execution, run));
222
+ const enter = <A>(execution: Execution, run: () => A): A => {
223
+ installConvexGlobal();
224
+ return Globals.own(() => executions.run(execution, run));
225
+ };
212
226
  const kernelBrand = Symbol.for("@confect/test/Kernel");
213
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
+
214
246
  const current = (): Execution => {
215
247
  const execution = executions.getStore();
216
248
  if (execution === undefined) {
@@ -230,6 +262,15 @@ const newRequest = (caller: Caller): Request => ({
230
262
  authToken: caller.token,
231
263
  });
232
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
+
233
274
  const findModulesRoot = (paths: ReadonlyArray<string>): string => {
234
275
  const generated = paths.find((path) => path.includes("_generated"));
235
276
  if (generated === undefined) {
@@ -246,6 +287,7 @@ const makeComponent = (
246
287
  path: string,
247
288
  schema: SchemaDefinition<GenericSchema, boolean> | null | undefined,
248
289
  modules: Modules,
290
+ standing: ReadonlyArray<string> = [],
249
291
  ): Component => {
250
292
  const root = findModulesRoot(Object.keys(modules));
251
293
  const relative = Object.entries(modules)
@@ -268,6 +310,7 @@ const makeComponent = (
268
310
  .map(([file, load]) => [file.replace(/\.[^.]+$/, ""), load]),
269
311
  ),
270
312
  loaded: new Map(),
313
+ standing: new Set(standing),
271
314
  };
272
315
  };
273
316
 
@@ -326,7 +369,7 @@ const functionPathOf = (
326
369
  from: string,
327
370
  ): FunctionPath => {
328
371
  if (address.functionHandle !== undefined) {
329
- return parseFunctionHandle(address.functionHandle) as FunctionPath;
372
+ return parseFunctionHandle(address.functionHandle);
330
373
  }
331
374
  if (address.name !== undefined) {
332
375
  return { udfPath: address.name, componentPath: from };
@@ -376,6 +419,12 @@ export class World {
376
419
  private readonly queries = new Map<number, Iterator<any>>();
377
420
  private readonly holding = new Map<string, Held>();
378
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>();
379
428
 
380
429
  constructor(
381
430
  schema: SchemaDefinition<GenericSchema, boolean> | null | undefined,
@@ -383,6 +432,7 @@ export class World {
383
432
  private readonly runtime: Kernel.KernelRuntime = Kernel.wallClock(),
384
433
  ) {
385
434
  installConvexGlobal();
435
+ Clock.install(pinnedTime, () => executions.getStore() !== undefined);
386
436
  this.components.set(ROOT, makeComponent(ROOT, schema, modules));
387
437
  }
388
438
 
@@ -390,6 +440,7 @@ export class World {
390
440
  componentPath: string,
391
441
  schema: SchemaDefinition<GenericSchema, boolean>,
392
442
  modules: Modules,
443
+ standing: ReadonlyArray<string> = [],
393
444
  ) {
394
445
  if (this.opened !== undefined) {
395
446
  throw new KernelRefusal.RegisteredAfterOpen({
@@ -399,13 +450,13 @@ export class World {
399
450
  }
400
451
  this.components.set(
401
452
  componentPath,
402
- makeComponent(componentPath, schema, modules),
453
+ makeComponent(componentPath, schema, modules, standing),
403
454
  );
404
455
  }
405
456
 
406
457
  /**
407
458
  * Load every registered module of every component, once, before any function
408
- * runs.
459
+ * runs, then check that each standing source names an exported function.
409
460
  */
410
461
  open(): Promise<void> {
411
462
  this.opened ??= Promise.all(
@@ -416,10 +467,37 @@ export class World {
416
467
  ),
417
468
  ),
418
469
  ),
419
- ).then(() => undefined);
470
+ ).then(() => this.checkStanding());
420
471
  return this.opened;
421
472
  }
422
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
+
423
501
  private component(componentPath: string): Component {
424
502
  const component = this.components.get(componentPath);
425
503
  if (component === undefined) throw unregistered(this, componentPath);
@@ -564,6 +642,236 @@ export class World {
564
642
  return Array.from(this.holding.values(), (held) => held.view);
565
643
  }
566
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
+
567
875
  // Calls by address, from the test or from a running function.
568
876
 
569
877
  private queryAt(
@@ -625,6 +933,10 @@ export class World {
625
933
  attempt,
626
934
  identity,
627
935
  request: from.request,
936
+ startTime:
937
+ from.parent !== undefined && from.parent.udfType !== "action"
938
+ ? from.parent.startTime
939
+ : this.now(),
628
940
  pagination,
629
941
  turn: new Turn(),
630
942
  };
@@ -851,18 +1163,14 @@ export class World {
851
1163
  (next): KernelApply => ({ kind: "conflict", next: next.view }),
852
1164
  );
853
1165
  }
854
- const commitTs = this.runtime.commitTs(this.state.version + 1);
855
- if (commitTs <= this.state.commitTs) {
856
- return Promise.reject(
857
- new KernelRefusal.CommitTsNotIncreasing({
858
- version: this.state.version + 1,
859
- previous: this.state.commitTs,
860
- commitTs,
861
- 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}.`,
862
- }),
863
- );
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);
864
1173
  }
865
- this.state = Kernel.apply(this.state, writes, commitTs);
866
1174
  return Promise.resolve({
867
1175
  kind: "applied",
868
1176
  version: this.state.version,
@@ -940,20 +1248,15 @@ export class World {
940
1248
  // world's services, a start time read from its world's clock and its
941
1249
  // world's random stream, as a scheduled job is.
942
1250
  const bound = from.parent === undefined ? undefined : Invocation.current();
943
- const { ip, userAgent, requestId, scheduledFunctionId } = from.request;
944
1251
  const started =
945
1252
  bound === undefined
946
1253
  ? run()
947
1254
  : Invocation.bind(
948
- {
949
- world: bound.world,
950
- componentPath: functionPath.componentPath,
951
- attempt: 1,
952
- request: { ip, userAgent, requestId, scheduledFunctionId },
953
- services: bound.world.services,
954
- startedAt: bound.world.clock(),
955
- random: bound.world.random,
956
- },
1255
+ Invocation.fresh(
1256
+ bound.world,
1257
+ functionPath.componentPath,
1258
+ from.request,
1259
+ ),
957
1260
  run,
958
1261
  );
959
1262
  return started
@@ -1181,10 +1484,31 @@ export class World {
1181
1484
  case "1.0/auditLog":
1182
1485
  return JSON.stringify(null);
1183
1486
  case "1.0/schedule":
1487
+ return JSON.stringify(
1488
+ convexToJson(
1489
+ this.scheduleJob(transaction(execution), execution, args),
1490
+ ),
1491
+ );
1184
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
+ );
1185
1500
  case "1.0/cancel_job":
1501
+ this.cancelJob(
1502
+ transaction(execution),
1503
+ execution.componentPath,
1504
+ args.id,
1505
+ );
1506
+ return JSON.stringify({});
1186
1507
  case "1.0/actions/cancel_job":
1187
- throw KernelRefusal.rowPending("the scheduler", 5);
1508
+ this.commitNow((attempt) =>
1509
+ this.cancelJob(attempt, execution.componentPath, args.id),
1510
+ );
1511
+ return JSON.stringify({});
1188
1512
  case "1.0/storageDelete":
1189
1513
  case "1.0/storageGetUrl":
1190
1514
  case "1.0/storageGenerateUploadUrl":
@@ -1225,18 +1549,25 @@ export class World {
1225
1549
  value,
1226
1550
  );
1227
1551
  const _id = this.generateId(table);
1228
- const now = this.runtime.now();
1229
- const _creationTime =
1230
- now <= this.lastCreationTime ? this.lastCreationTime + 0.001 : now;
1231
- this.lastCreationTime = _creationTime;
1232
1552
  attempt.write(execution.componentPath, _id, {
1233
1553
  ...value,
1234
1554
  _id,
1235
- _creationTime,
1555
+ _creationTime: this.creationTime(execution.startTime),
1236
1556
  });
1237
1557
  return _id;
1238
1558
  }
1239
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
+
1240
1571
  private existing(
1241
1572
  execution: Execution,
1242
1573
  tableName: unknown,
@@ -1405,26 +1736,60 @@ function validateId(
1405
1736
  }
1406
1737
  }
1407
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
+
1408
1747
  /**
1409
- * Convex's server library reaches the backend through `globalThis.Convex`. The
1410
- * kernel answers each syscall for the function that made it.
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.
1411
1774
  */
1412
1775
  const installConvexGlobal = () => {
1413
- const global = globalThis as { Convex?: { [kernelBrand]?: true } };
1414
- if (global.Convex?.[kernelBrand] === true) return;
1415
- global.Convex = {
1416
- [kernelBrand]: true,
1417
- syscall: (op: string, args: string) => current().world.syscall(op, args),
1418
- asyncSyscall: (op: string, args: string) =>
1419
- current().world.asyncSyscall(op, args),
1420
- jsSyscall: (op: string) => current().world.jsSyscall(op),
1421
- } as any;
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
+ });
1422
1787
  };
1423
1788
 
1424
1789
  /**
1425
- * Whether `globalThis.Convex` is the kernel's, so no other backend answered.
1790
+ * Whether `globalThis.Convex` is the kernel's, and no other backend installed
1791
+ * one behind it.
1426
1792
  */
1427
1793
  export const ownsConvexGlobal = (): boolean =>
1428
- (globalThis as { Convex?: { [kernelBrand]?: true } }).Convex?.[
1429
- kernelBrand
1430
- ] === true;
1794
+ (globalThis as { Convex?: Branded }).Convex?.[kernelBrand] === true &&
1795
+ forwarded === undefined;
@@ -33,6 +33,25 @@ const state = (): State =>
33
33
  getters: new WeakSet(),
34
34
  });
35
35
 
36
+ /**
37
+ * Whether `accessor`, another library's, already reads through one of this
38
+ * module's, as convex-test's does once it wraps the kernel's: it answers a
39
+ * value only a fresh copy holds. convex-test reinstalls its accessor whenever
40
+ * another is on top (its `dist/index.js:1854-1866`), so wrapping it again would
41
+ * add a layer at every call while both kinds of world are live.
42
+ */
43
+ const readsThrough = (
44
+ copies: State["copies"],
45
+ accessor: PropertyDescriptor,
46
+ name: Name,
47
+ ): boolean => {
48
+ const probe = {};
49
+ return (
50
+ copies.run({ [name]: probe }, () => accessor.get?.call(globalThis)) ===
51
+ probe
52
+ );
53
+ };
54
+
36
55
  /**
37
56
  * Point each global at the calling function's copy, falling back to the
38
57
  * global's own value outside one. A test that redefines a global, as
@@ -45,6 +64,7 @@ const install = ({ copies, getters }: State) => {
45
64
  const own = Object.getOwnPropertyDescriptor(global, name);
46
65
  // oxlint-disable-next-line typescript/unbound-method -- Compared by identity, never called.
47
66
  if (own?.get !== undefined && getters.has(own.get)) continue;
67
+ if (own?.get !== undefined && readsThrough(copies, own, name)) continue;
48
68
  const base: PropertyDescriptor = own ?? {
49
69
  value: undefined,
50
70
  writable: true,