@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.
- package/dist/TestConfect.d.ts +33 -7
- package/dist/TestConfect.d.ts.map +1 -1
- package/dist/TestConfect.js +11 -6
- package/dist/TestConfect.js.map +1 -1
- package/dist/internal/Attempt.js.map +1 -1
- package/dist/internal/Clock.d.ts +29 -0
- package/dist/internal/Clock.d.ts.map +1 -0
- package/dist/internal/Clock.js +66 -0
- package/dist/internal/Clock.js.map +1 -0
- package/dist/internal/ConvexTest.d.ts +33 -1
- package/dist/internal/ConvexTest.d.ts.map +1 -1
- package/dist/internal/ConvexTest.js +8 -5
- package/dist/internal/ConvexTest.js.map +1 -1
- package/dist/internal/Execution.d.ts +85 -5
- package/dist/internal/Execution.d.ts.map +1 -1
- package/dist/internal/Execution.js +285 -51
- package/dist/internal/Execution.js.map +1 -1
- package/dist/internal/Globals.d.ts.map +1 -1
- package/dist/internal/Globals.js +12 -0
- package/dist/internal/Globals.js.map +1 -1
- package/dist/internal/Invocation.d.ts +11 -0
- package/dist/internal/Invocation.d.ts.map +1 -1
- package/dist/internal/Invocation.js +34 -21
- package/dist/internal/Invocation.js.map +1 -1
- package/dist/internal/Kernel.d.ts +3 -1
- package/dist/internal/Kernel.d.ts.map +1 -1
- package/dist/internal/Kernel.js +2 -2
- package/dist/internal/Kernel.js.map +1 -1
- package/dist/internal/KernelRefusal.d.ts +25 -0
- package/dist/internal/KernelRefusal.d.ts.map +1 -1
- package/dist/internal/KernelRefusal.js +22 -1
- package/dist/internal/KernelRefusal.js.map +1 -1
- package/dist/internal/KernelValues.d.ts +6 -4
- package/dist/internal/KernelValues.d.ts.map +1 -1
- package/dist/internal/KernelValues.js +20 -5
- package/dist/internal/KernelValues.js.map +1 -1
- package/dist/internal/MountedHttp.d.ts.map +1 -1
- package/dist/internal/MountedHttp.js +3 -3
- package/dist/internal/MountedHttp.js.map +1 -1
- package/dist/internal/Scheduling.d.ts +71 -0
- package/dist/internal/Scheduling.d.ts.map +1 -0
- package/dist/internal/Scheduling.js +60 -0
- package/dist/internal/Scheduling.js.map +1 -0
- package/package.json +3 -3
- package/src/TestConfect.ts +62 -15
- package/src/internal/Attempt.ts +1 -1
- package/src/internal/Clock.ts +95 -0
- package/src/internal/ConvexTest.ts +51 -6
- package/src/internal/Execution.ts +415 -50
- package/src/internal/Globals.ts +20 -0
- package/src/internal/Invocation.ts +38 -13
- package/src/internal/Kernel.ts +5 -5
- package/src/internal/KernelRefusal.ts +24 -0
- package/src/internal/KernelValues.ts +44 -4
- package/src/internal/MountedHttp.ts +13 -6
- 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
|
|
48
|
-
*
|
|
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
|
-
|
|
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)
|
|
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(() =>
|
|
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
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
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
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
1410
|
-
*
|
|
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
|
|
1414
|
-
if (
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
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,
|
|
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?:
|
|
1429
|
-
|
|
1430
|
-
] === true;
|
|
1794
|
+
(globalThis as { Convex?: Branded }).Convex?.[kernelBrand] === true &&
|
|
1795
|
+
forwarded === undefined;
|
package/src/internal/Globals.ts
CHANGED
|
@@ -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,
|