@gaganref/confect-test 10.3.1-fork.91 → 10.3.1-fork.93
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 +26 -0
- package/dist/TestConfect.d.ts.map +1 -1
- package/dist/TestConfect.js +9 -1
- package/dist/TestConfect.js.map +1 -1
- package/dist/internal/Attempt.d.ts +14 -1
- package/dist/internal/Attempt.d.ts.map +1 -1
- package/dist/internal/Attempt.js +18 -1
- package/dist/internal/Attempt.js.map +1 -1
- package/dist/internal/ConvexTest.d.ts +26 -1
- package/dist/internal/ConvexTest.d.ts.map +1 -1
- package/dist/internal/ConvexTest.js +55 -27
- package/dist/internal/ConvexTest.js.map +1 -1
- package/dist/internal/Execution.d.ts +151 -1
- package/dist/internal/Execution.d.ts.map +1 -1
- package/dist/internal/Execution.js +731 -214
- package/dist/internal/Execution.js.map +1 -1
- package/dist/internal/KernelQuery.d.ts +8 -3
- package/dist/internal/KernelQuery.d.ts.map +1 -1
- package/dist/internal/KernelQuery.js +10 -3
- package/dist/internal/KernelQuery.js.map +1 -1
- package/dist/internal/KernelRefusal.d.ts +134 -0
- package/dist/internal/KernelRefusal.d.ts.map +1 -1
- package/dist/internal/KernelRefusal.js +134 -1
- package/dist/internal/KernelRefusal.js.map +1 -1
- package/dist/internal/Metering.d.ts +186 -0
- package/dist/internal/Metering.d.ts.map +1 -0
- package/dist/internal/Metering.js +401 -0
- package/dist/internal/Metering.js.map +1 -0
- package/dist/internal/Redaction.d.ts +142 -0
- package/dist/internal/Redaction.d.ts.map +1 -0
- package/dist/internal/Redaction.js +448 -0
- package/dist/internal/Redaction.js.map +1 -0
- package/dist/internal/Snapshot.d.ts +298 -0
- package/dist/internal/Snapshot.d.ts.map +1 -0
- package/dist/internal/Snapshot.js +532 -0
- package/dist/internal/Snapshot.js.map +1 -0
- package/dist/internal/Subscriptions.d.ts +13 -0
- package/dist/internal/Subscriptions.d.ts.map +1 -1
- package/dist/internal/Subscriptions.js +27 -3
- package/dist/internal/Subscriptions.js.map +1 -1
- package/package.json +3 -3
- package/src/TestConfect.ts +63 -4
- package/src/internal/Attempt.ts +24 -0
- package/src/internal/ConvexTest.ts +135 -38
- package/src/internal/Execution.ts +1141 -324
- package/src/internal/KernelQuery.ts +17 -6
- package/src/internal/KernelRefusal.ts +160 -0
- package/src/internal/Metering.ts +520 -0
- package/src/internal/Redaction.ts +730 -0
- package/src/internal/Snapshot.ts +945 -0
- package/src/internal/Subscriptions.ts +34 -4
|
@@ -9,7 +9,14 @@ import {
|
|
|
9
9
|
type SchemaDefinition,
|
|
10
10
|
type UserIdentity,
|
|
11
11
|
} from "convex/server";
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
CommitTsPlaceholder,
|
|
14
|
+
compareValues,
|
|
15
|
+
convexToJson,
|
|
16
|
+
jsonToConvex,
|
|
17
|
+
type Value,
|
|
18
|
+
} from "convex/values";
|
|
19
|
+
import * as Result from "effect/Result";
|
|
13
20
|
import { KernelAttempt } from "./Attempt.js";
|
|
14
21
|
import * as Clock from "./Clock.js";
|
|
15
22
|
import * as Globals from "./Globals.js";
|
|
@@ -17,8 +24,11 @@ import * as Invocation from "./Invocation.js";
|
|
|
17
24
|
import * as Kernel from "./Kernel.js";
|
|
18
25
|
import * as KernelQuery from "./KernelQuery.js";
|
|
19
26
|
import * as KernelRefusal from "./KernelRefusal.js";
|
|
27
|
+
import * as Metering from "./Metering.js";
|
|
20
28
|
import type * as ReadSet from "./ReadSet.js";
|
|
29
|
+
import * as Redaction from "./Redaction.js";
|
|
21
30
|
import * as Scheduling from "./Scheduling.js";
|
|
31
|
+
import * as Snapshot from "./Snapshot.js";
|
|
22
32
|
import type {
|
|
23
33
|
DocumentId,
|
|
24
34
|
FunctionPath,
|
|
@@ -31,6 +41,7 @@ import {
|
|
|
31
41
|
deserializeConvexErrorData,
|
|
32
42
|
evaluateValue,
|
|
33
43
|
hasPendingValues,
|
|
44
|
+
isSimpleObject,
|
|
34
45
|
parseArgs,
|
|
35
46
|
parseFunctionHandle,
|
|
36
47
|
resolveCommittedValues,
|
|
@@ -111,6 +122,11 @@ export type Outcome =
|
|
|
111
122
|
*/
|
|
112
123
|
export interface HeldAttempt {
|
|
113
124
|
readonly id: string;
|
|
125
|
+
/**
|
|
126
|
+
* The mutation the attempt runs, whose return shape marks its outcome in an
|
|
127
|
+
* artifact.
|
|
128
|
+
*/
|
|
129
|
+
readonly functionPath: FunctionPath;
|
|
114
130
|
readonly request: string;
|
|
115
131
|
readonly number: Invocation.Attempt;
|
|
116
132
|
readonly snapshot: Kernel.Version;
|
|
@@ -217,6 +233,11 @@ interface Execution {
|
|
|
217
233
|
readonly startTime: number;
|
|
218
234
|
readonly pagination: Pagination | undefined;
|
|
219
235
|
readonly turn: Turn;
|
|
236
|
+
/**
|
|
237
|
+
* The transaction limits this function runs under: the defaults at the top
|
|
238
|
+
* level, its caller's inside a nested call, tightened by the call's budget.
|
|
239
|
+
*/
|
|
240
|
+
readonly limits: Metering.Limits;
|
|
220
241
|
}
|
|
221
242
|
|
|
222
243
|
/**
|
|
@@ -285,6 +306,39 @@ const pinnedTime = (): number | undefined => {
|
|
|
285
306
|
*/
|
|
286
307
|
const defaultBudget = 100;
|
|
287
308
|
|
|
309
|
+
const unresolvedWorld = (
|
|
310
|
+
name: string,
|
|
311
|
+
reason: "missing" | "ambiguous",
|
|
312
|
+
): KernelRefusal.SnapshotWorldUnresolved =>
|
|
313
|
+
new KernelRefusal.SnapshotWorldUnresolved({
|
|
314
|
+
name,
|
|
315
|
+
reason,
|
|
316
|
+
message:
|
|
317
|
+
reason === "missing"
|
|
318
|
+
? `A snapshot job runs under the test world "${name}", which no world passed to restore is named. Pass that world.`
|
|
319
|
+
: `Two different test worlds are named "${name}", so a snapshot cannot tell their jobs apart. Name each world uniquely.`,
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* `shape` placed at `at`, or no placement when there is no shape.
|
|
324
|
+
*/
|
|
325
|
+
const placed = (
|
|
326
|
+
shape: Redaction.Shape | undefined,
|
|
327
|
+
at: ReadonlyArray<string> = [],
|
|
328
|
+
): ReadonlyArray<Redaction.Placement> =>
|
|
329
|
+
shape === undefined ? [] : [{ at, shape }];
|
|
330
|
+
|
|
331
|
+
const running = (
|
|
332
|
+
registry: KernelRefusal.SnapshotWorldRunning["registry"],
|
|
333
|
+
count: number,
|
|
334
|
+
what: string,
|
|
335
|
+
) =>
|
|
336
|
+
new KernelRefusal.SnapshotWorldRunning({
|
|
337
|
+
registry,
|
|
338
|
+
count,
|
|
339
|
+
message: `The test backend is running ${count} ${what}, whose effects a snapshot would split. Await them first.`,
|
|
340
|
+
});
|
|
341
|
+
|
|
288
342
|
const current = (): Execution => {
|
|
289
343
|
const execution = executions.getStore();
|
|
290
344
|
if (execution === undefined) {
|
|
@@ -467,11 +521,34 @@ export class World {
|
|
|
467
521
|
*/
|
|
468
522
|
private floor = Number.NEGATIVE_INFINITY;
|
|
469
523
|
private readonly targets = new Map<DocumentId, Scheduling.Target>();
|
|
524
|
+
/**
|
|
525
|
+
* The raw secrets this world has seen under a secret schema node. Replay
|
|
526
|
+
* state, kept in memory and never written to an artifact.
|
|
527
|
+
*/
|
|
528
|
+
private secrets = new Redaction.Secrets();
|
|
529
|
+
/**
|
|
530
|
+
* The live registries `Snapshot.contract.live` names, each a count: calls
|
|
531
|
+
* from the test, scheduler walks, function executions (nested and detached
|
|
532
|
+
* ones included) and registered subscribers. Held attempts are `holding`.
|
|
533
|
+
*/
|
|
534
|
+
private readonly live = {
|
|
535
|
+
running: 0,
|
|
536
|
+
walks: 0,
|
|
537
|
+
functions: 0,
|
|
538
|
+
subscribers: 0,
|
|
539
|
+
};
|
|
540
|
+
/**
|
|
541
|
+
* The latest identity version a subscriber's `authenticate` reached. Each
|
|
542
|
+
* subscriber starts at the epoch, and each identity change advances it, so a
|
|
543
|
+
* restored world never hands out a version its source already used.
|
|
544
|
+
*/
|
|
545
|
+
private identityEpoch = 0;
|
|
470
546
|
|
|
471
547
|
constructor(
|
|
472
548
|
schema: SchemaDefinition<GenericSchema, boolean> | null | undefined,
|
|
473
549
|
modules: Modules,
|
|
474
550
|
private readonly runtime: Kernel.KernelRuntime = Kernel.wallClock(),
|
|
551
|
+
private readonly redaction: Redaction.Policy = Redaction.none,
|
|
475
552
|
) {
|
|
476
553
|
installConvexGlobal();
|
|
477
554
|
Clock.install(pinnedTime, () => executions.getStore() !== undefined);
|
|
@@ -587,44 +664,50 @@ export class World {
|
|
|
587
664
|
// Calls from the test.
|
|
588
665
|
|
|
589
666
|
query(caller: Caller, reference: any, args: any): Promise<any> {
|
|
590
|
-
return this.
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
667
|
+
return this.during(() => {
|
|
668
|
+
return this.open().then(() => {
|
|
669
|
+
const from = { caller, request: newRequest(caller) };
|
|
670
|
+
return typeof reference === "function"
|
|
671
|
+
? this.runQuery(
|
|
672
|
+
{ componentPath: ROOT, udfPath: "inline" },
|
|
673
|
+
{ handler: reference, args: { type: "any" }, returns: null },
|
|
674
|
+
args ?? {},
|
|
675
|
+
from,
|
|
676
|
+
)
|
|
677
|
+
: this.queryAt(pathOfReference(reference, ROOT), args, from);
|
|
678
|
+
});
|
|
600
679
|
});
|
|
601
680
|
}
|
|
602
681
|
|
|
603
682
|
mutation(caller: Caller, reference: any, args: any): Promise<any> {
|
|
604
|
-
return this.
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
683
|
+
return this.during(() => {
|
|
684
|
+
return this.open().then(() => {
|
|
685
|
+
const from = { caller, request: newRequest(caller) };
|
|
686
|
+
return typeof reference === "function"
|
|
687
|
+
? this.runMutation(
|
|
688
|
+
{ componentPath: ROOT, udfPath: "inline" },
|
|
689
|
+
{ handler: reference, args: { type: "any" }, returns: null },
|
|
690
|
+
args ?? {},
|
|
691
|
+
from,
|
|
692
|
+
)
|
|
693
|
+
: this.mutationAt(pathOfReference(reference, ROOT), args, from);
|
|
694
|
+
});
|
|
614
695
|
});
|
|
615
696
|
}
|
|
616
697
|
|
|
617
698
|
action(caller: Caller, reference: any, args: any): Promise<any> {
|
|
618
|
-
return this.
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
699
|
+
return this.during(() => {
|
|
700
|
+
return this.open().then(() => {
|
|
701
|
+
const from = { caller, request: newRequest(caller) };
|
|
702
|
+
return typeof reference === "function"
|
|
703
|
+
? this.runAction(
|
|
704
|
+
{ componentPath: ROOT, udfPath: "inline" },
|
|
705
|
+
{ handler: reference, args: { type: "any" }, returns: null },
|
|
706
|
+
args ?? {},
|
|
707
|
+
from,
|
|
708
|
+
)
|
|
709
|
+
: this.actionAt(pathOfReference(reference, ROOT), args, from);
|
|
710
|
+
});
|
|
628
711
|
});
|
|
629
712
|
}
|
|
630
713
|
|
|
@@ -633,14 +716,16 @@ export class World {
|
|
|
633
716
|
componentPath: string,
|
|
634
717
|
handler: (ctx: any) => any,
|
|
635
718
|
): Promise<any> {
|
|
636
|
-
return this.
|
|
637
|
-
this.
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
719
|
+
return this.during(() => {
|
|
720
|
+
return this.open().then(() => {
|
|
721
|
+
this.component(componentPath);
|
|
722
|
+
return this.runMutation(
|
|
723
|
+
{ componentPath, udfPath: "custom" },
|
|
724
|
+
{ handler, args: { type: "any" }, returns: null },
|
|
725
|
+
{},
|
|
726
|
+
{ caller, request: newRequest(caller) },
|
|
727
|
+
);
|
|
728
|
+
});
|
|
644
729
|
});
|
|
645
730
|
}
|
|
646
731
|
|
|
@@ -650,31 +735,36 @@ export class World {
|
|
|
650
735
|
* Run a mutation to its commit point and hold it as attempt 1.
|
|
651
736
|
*/
|
|
652
737
|
prepare(caller: Caller, reference: any, args: any): Promise<HeldAttempt> {
|
|
653
|
-
return this.
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
functionPath,
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
738
|
+
return this.during(() => {
|
|
739
|
+
return this.open().then(() => {
|
|
740
|
+
const functionPath = pathOfReference(reference, ROOT);
|
|
741
|
+
const resolved = this.resolve(functionPath, "mutation");
|
|
742
|
+
this.keepArgs(functionPath, args);
|
|
743
|
+
validateFunctionArgs(resolved.args, args ?? {}, functionPath, false);
|
|
744
|
+
return this.prepareMutation(
|
|
745
|
+
functionPath,
|
|
746
|
+
resolved,
|
|
747
|
+
args,
|
|
748
|
+
{ caller, request: newRequest(caller) },
|
|
749
|
+
1,
|
|
750
|
+
Invocation.current(),
|
|
751
|
+
).then((held) => held.view);
|
|
752
|
+
});
|
|
665
753
|
});
|
|
666
754
|
}
|
|
667
755
|
|
|
668
756
|
apply(id: string): Promise<KernelApply> {
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
757
|
+
return this.during(() => {
|
|
758
|
+
const held = this.holding.get(id);
|
|
759
|
+
return held === undefined
|
|
760
|
+
? Promise.reject(
|
|
761
|
+
new KernelRefusal.StaleAttempt({
|
|
762
|
+
id,
|
|
763
|
+
message: `The test kernel holds no attempt "${id}". It was already applied, or never prepared.`,
|
|
764
|
+
}),
|
|
765
|
+
)
|
|
766
|
+
: this.applyHeld(held);
|
|
767
|
+
});
|
|
678
768
|
}
|
|
679
769
|
|
|
680
770
|
/**
|
|
@@ -698,37 +788,308 @@ export class World {
|
|
|
698
788
|
args: any,
|
|
699
789
|
pinned: { readonly time?: number; readonly journal: string | null },
|
|
700
790
|
): Promise<QueryRun> {
|
|
701
|
-
return this.
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
791
|
+
return this.during(() => {
|
|
792
|
+
return this.open().then(() => {
|
|
793
|
+
const functionPath = pathOfReference(reference, ROOT);
|
|
794
|
+
const resolved = this.resolve(functionPath, "query");
|
|
795
|
+
this.keepArgs(functionPath, args);
|
|
796
|
+
validateFunctionArgs(resolved.args, args ?? {}, functionPath, false);
|
|
797
|
+
const top: TopQuery = {
|
|
798
|
+
attempt: new KernelAttempt(this.state),
|
|
799
|
+
pagination: { calls: 0, journal: pinned.journal, end: null },
|
|
800
|
+
startTime: pinned.time,
|
|
801
|
+
};
|
|
802
|
+
const run = (outcome: Outcome): QueryRun => ({
|
|
803
|
+
outcome,
|
|
804
|
+
snapshot: top.attempt.snapshot.version,
|
|
805
|
+
reads: top.attempt.reads,
|
|
806
|
+
observedIdentity: top.attempt.observedIdentity,
|
|
807
|
+
time: top.attempt.observedTime,
|
|
808
|
+
journal: pinned.journal ?? top.pagination.end,
|
|
809
|
+
});
|
|
810
|
+
return this.runQuery(
|
|
811
|
+
functionPath,
|
|
812
|
+
resolved,
|
|
813
|
+
args,
|
|
814
|
+
{ caller, request: newRequest(caller) },
|
|
815
|
+
false,
|
|
816
|
+
top,
|
|
817
|
+
).then(
|
|
818
|
+
(value: Value) => run({ _tag: "returned", value }),
|
|
819
|
+
(error: unknown) => run({ _tag: "failed", error }),
|
|
820
|
+
);
|
|
717
821
|
});
|
|
718
|
-
return this.runQuery(
|
|
719
|
-
functionPath,
|
|
720
|
-
resolved,
|
|
721
|
-
args,
|
|
722
|
-
{ caller, request: newRequest(caller) },
|
|
723
|
-
false,
|
|
724
|
-
top,
|
|
725
|
-
).then(
|
|
726
|
-
(value: Value) => run({ _tag: "returned", value }),
|
|
727
|
-
(error: unknown) => run({ _tag: "failed", error }),
|
|
728
|
-
);
|
|
729
822
|
});
|
|
730
823
|
}
|
|
731
824
|
|
|
825
|
+
/**
|
|
826
|
+
* Run `call`, a call from the test, counted as running until it settles.
|
|
827
|
+
*/
|
|
828
|
+
private during<A>(call: () => Promise<A>): Promise<A> {
|
|
829
|
+
return this.track("running", call);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* Run `work` counted in the live registry `registry` until it settles.
|
|
834
|
+
*/
|
|
835
|
+
private track<A>(
|
|
836
|
+
registry: "running" | "walks" | "functions",
|
|
837
|
+
work: () => Promise<A>,
|
|
838
|
+
): Promise<A> {
|
|
839
|
+
this.live[registry]++;
|
|
840
|
+
const settle = () => {
|
|
841
|
+
this.live[registry]--;
|
|
842
|
+
};
|
|
843
|
+
try {
|
|
844
|
+
return work().finally(settle);
|
|
845
|
+
} catch (error) {
|
|
846
|
+
settle();
|
|
847
|
+
throw error;
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* How many entries each live registry holds now.
|
|
853
|
+
*/
|
|
854
|
+
liveCounts(): {
|
|
855
|
+
readonly [K in keyof typeof Snapshot.contract.live]: number;
|
|
856
|
+
} {
|
|
857
|
+
return {
|
|
858
|
+
running: this.live.running,
|
|
859
|
+
walks: this.live.walks,
|
|
860
|
+
functions: this.live.functions,
|
|
861
|
+
held: this.holding.size,
|
|
862
|
+
subscribers: this.live.subscribers,
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* Count a subscriber the shim's registry created. Subscribers live as long as
|
|
868
|
+
* their test, so the count never falls.
|
|
869
|
+
*/
|
|
870
|
+
registerSubscriber() {
|
|
871
|
+
this.live.subscribers++;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// Identity versions.
|
|
875
|
+
|
|
876
|
+
/**
|
|
877
|
+
* The identity version a new subscriber starts at.
|
|
878
|
+
*/
|
|
879
|
+
currentIdentityEpoch(): number {
|
|
880
|
+
return this.identityEpoch;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* The next identity version, for a subscriber whose identity changed.
|
|
885
|
+
*/
|
|
886
|
+
nextIdentityEpoch(): number {
|
|
887
|
+
return ++this.identityEpoch;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// Snapshots.
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* The committed state as snapshot bytes: every component's rows and the
|
|
894
|
+
* version that wrote each, the write log, the jobs and their targets, the
|
|
895
|
+
* clock's floor and the identity counters. Each job's world is recorded by
|
|
896
|
+
* name. Two exports of the same state are the same bytes. A world running a
|
|
897
|
+
* call or holding attempts refuses, since a snapshot carries neither.
|
|
898
|
+
*/
|
|
899
|
+
exportSnapshot(): Uint8Array {
|
|
900
|
+
this.refuseBusy(0, "export");
|
|
901
|
+
const names = new Map<string, Invocation.World>();
|
|
902
|
+
const jobs = new Map<DocumentId, Snapshot.JobTarget>();
|
|
903
|
+
for (const byTable of this.state.tables.values()) {
|
|
904
|
+
for (const id of byTable.get(Scheduling.table)?.keys() ?? []) {
|
|
905
|
+
const target = this.targets.get(id);
|
|
906
|
+
if (target === undefined) continue;
|
|
907
|
+
const world = target.world;
|
|
908
|
+
if (world !== undefined) {
|
|
909
|
+
if ((names.get(world.name) ?? world) !== world) {
|
|
910
|
+
throw unresolvedWorld(world.name, "ambiguous");
|
|
911
|
+
}
|
|
912
|
+
names.set(world.name, world);
|
|
913
|
+
}
|
|
914
|
+
jobs.set(id, {
|
|
915
|
+
functionPath: target.functionPath,
|
|
916
|
+
standing: target.standing,
|
|
917
|
+
world: world?.name ?? null,
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
return Snapshot.encode(
|
|
922
|
+
{
|
|
923
|
+
components: [...this.components.keys()],
|
|
924
|
+
state: this.state,
|
|
925
|
+
floor: this.floor,
|
|
926
|
+
identity: {
|
|
927
|
+
nextDocumentId: this.nextDocId,
|
|
928
|
+
lastCreationTime: this.lastCreationTime,
|
|
929
|
+
nextAttemptId: this.nextAttemptId,
|
|
930
|
+
identityEpoch: this.identityEpoch,
|
|
931
|
+
},
|
|
932
|
+
jobs,
|
|
933
|
+
},
|
|
934
|
+
(componentPath, table, json) =>
|
|
935
|
+
Redaction.mark(
|
|
936
|
+
json,
|
|
937
|
+
this.documentPlacements(
|
|
938
|
+
componentPath,
|
|
939
|
+
table,
|
|
940
|
+
json as { readonly _id: unknown; readonly name?: unknown },
|
|
941
|
+
),
|
|
942
|
+
this.secrets,
|
|
943
|
+
),
|
|
944
|
+
);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* Replace this world's state with a snapshot's. The world must register the
|
|
949
|
+
* snapshot's components, run nothing, and have committed nothing and hold
|
|
950
|
+
* nothing. Each job's world resolves by name among `worlds`. Bytes whose
|
|
951
|
+
* digest does not match, and state the kernel could not have reached, refuse
|
|
952
|
+
* before anything changes.
|
|
953
|
+
*/
|
|
954
|
+
restoreSnapshot(
|
|
955
|
+
bytes: Uint8Array,
|
|
956
|
+
options: {
|
|
957
|
+
readonly worlds?: ReadonlyArray<Invocation.World>;
|
|
958
|
+
readonly secrets?: Redaction.Secrets;
|
|
959
|
+
} = {},
|
|
960
|
+
): Promise<void> {
|
|
961
|
+
return this.open().then(() =>
|
|
962
|
+
this.restore(
|
|
963
|
+
bytes,
|
|
964
|
+
options.worlds ?? [],
|
|
965
|
+
options.secrets ?? new Redaction.Secrets(),
|
|
966
|
+
),
|
|
967
|
+
);
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
private restore(
|
|
971
|
+
bytes: Uint8Array,
|
|
972
|
+
worlds: ReadonlyArray<Invocation.World>,
|
|
973
|
+
secrets: Redaction.Secrets,
|
|
974
|
+
) {
|
|
975
|
+
this.refuseBusy(this.state.version, "restore");
|
|
976
|
+
const decoded = Snapshot.decode(bytes, (json) =>
|
|
977
|
+
Redaction.unmark(json, secrets),
|
|
978
|
+
);
|
|
979
|
+
if (Result.isFailure(decoded)) throw decoded.failure;
|
|
980
|
+
const image = decoded.success;
|
|
981
|
+
const checked = Snapshot.check(image, Scheduling.table);
|
|
982
|
+
if (Result.isFailure(checked)) throw checked.failure;
|
|
983
|
+
const registered = [...this.components.keys()].sort();
|
|
984
|
+
const snapshot = [...image.components].sort();
|
|
985
|
+
if (registered.join("\u0000") !== snapshot.join("\u0000")) {
|
|
986
|
+
throw new KernelRefusal.SnapshotComponentMismatch({
|
|
987
|
+
snapshot,
|
|
988
|
+
registered,
|
|
989
|
+
message: `The snapshot holds components ${snapshot.map((p) => `"${p}"`).join(", ")}, but this test backend registers ${registered.map((p) => `"${p}"`).join(", ")}. Register the same components before restoring.`,
|
|
990
|
+
});
|
|
991
|
+
}
|
|
992
|
+
for (const [id, { functionPath }] of image.jobs) {
|
|
993
|
+
if (!this.exports(functionPath)) {
|
|
994
|
+
throw new KernelRefusal.SnapshotJobInconsistent({
|
|
995
|
+
id,
|
|
996
|
+
reason: "function",
|
|
997
|
+
message: `The snapshot's job ${id} targets ${functionPath.udfPath} in "${functionPath.componentPath}", which that component does not export.`,
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
const byName = new Map<string, Invocation.World>();
|
|
1002
|
+
for (const world of worlds) {
|
|
1003
|
+
if ((byName.get(world.name) ?? world) !== world) {
|
|
1004
|
+
throw unresolvedWorld(world.name, "ambiguous");
|
|
1005
|
+
}
|
|
1006
|
+
byName.set(world.name, world);
|
|
1007
|
+
}
|
|
1008
|
+
const targets = Array.from(
|
|
1009
|
+
image.jobs,
|
|
1010
|
+
([id, { functionPath, standing, world }]): [
|
|
1011
|
+
DocumentId,
|
|
1012
|
+
Scheduling.Target,
|
|
1013
|
+
] => {
|
|
1014
|
+
const resolved = world === null ? undefined : byName.get(world);
|
|
1015
|
+
if (world !== null && resolved === undefined) {
|
|
1016
|
+
throw unresolvedWorld(world, "missing");
|
|
1017
|
+
}
|
|
1018
|
+
return [id, { functionPath, standing, world: resolved }];
|
|
1019
|
+
},
|
|
1020
|
+
);
|
|
1021
|
+
this.state = image.state;
|
|
1022
|
+
this.floor = image.floor;
|
|
1023
|
+
this.nextDocId = image.identity.nextDocumentId;
|
|
1024
|
+
this.lastCreationTime = image.identity.lastCreationTime;
|
|
1025
|
+
this.nextAttemptId = image.identity.nextAttemptId;
|
|
1026
|
+
this.identityEpoch = image.identity.identityEpoch;
|
|
1027
|
+
this.targets.clear();
|
|
1028
|
+
for (const [id, target] of targets) this.targets.set(id, target);
|
|
1029
|
+
this.secrets = secrets.copy();
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
/**
|
|
1033
|
+
* Whether `functionPath`'s component exports a Convex function there.
|
|
1034
|
+
*/
|
|
1035
|
+
private exports({ componentPath, udfPath }: FunctionPath): boolean {
|
|
1036
|
+
const [modulePath, exportName = "default"] = udfPath.split(":");
|
|
1037
|
+
const func = this.components.get(componentPath)?.loaded.get(modulePath!)?.[
|
|
1038
|
+
exportName
|
|
1039
|
+
];
|
|
1040
|
+
return (
|
|
1041
|
+
func?.isQuery === true ||
|
|
1042
|
+
func?.isMutation === true ||
|
|
1043
|
+
func?.isAction === true
|
|
1044
|
+
);
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
/**
|
|
1048
|
+
* Refuse a snapshot while any live registry `operation` waits on is
|
|
1049
|
+
* non-empty, or, restoring, once `version` shows the world committed
|
|
1050
|
+
* something of its own.
|
|
1051
|
+
*/
|
|
1052
|
+
private refuseBusy(version: Kernel.Version, operation: "export" | "restore") {
|
|
1053
|
+
const counts = this.liveCounts();
|
|
1054
|
+
const refusals: {
|
|
1055
|
+
readonly [K in keyof typeof counts]: (count: number) => Error;
|
|
1056
|
+
} = {
|
|
1057
|
+
running: (count) => running("running", count, "calls from the test"),
|
|
1058
|
+
walks: (count) => running("walks", count, "scheduler walks"),
|
|
1059
|
+
functions: (count) =>
|
|
1060
|
+
running(
|
|
1061
|
+
"functions",
|
|
1062
|
+
count,
|
|
1063
|
+
"functions, nested and detached ones included",
|
|
1064
|
+
),
|
|
1065
|
+
held: () => {
|
|
1066
|
+
const held = [...this.holding.keys()];
|
|
1067
|
+
return new KernelRefusal.SnapshotWorldBusy({
|
|
1068
|
+
version,
|
|
1069
|
+
held,
|
|
1070
|
+
message: `The test backend holds attempts ${held.join(", ")}, which a snapshot does not carry. Apply them first.`,
|
|
1071
|
+
});
|
|
1072
|
+
},
|
|
1073
|
+
subscribers: (count) =>
|
|
1074
|
+
new KernelRefusal.SnapshotSubscribed({
|
|
1075
|
+
subscribers: count,
|
|
1076
|
+
message: `The test backend has ${count} subscribers, whose identity versions a restore would rewind. Restore into a test backend no client has subscribed to.`,
|
|
1077
|
+
}),
|
|
1078
|
+
};
|
|
1079
|
+
for (const key of Object.keys(refusals) as Array<keyof typeof counts>) {
|
|
1080
|
+
const { blocks } = Snapshot.contract.live[key];
|
|
1081
|
+
if (operation === "export" && blocks === "restore") continue;
|
|
1082
|
+
if (counts[key] > 0) throw refusals[key](counts[key]);
|
|
1083
|
+
}
|
|
1084
|
+
if (operation === "restore" && version !== 0) {
|
|
1085
|
+
throw new KernelRefusal.SnapshotWorldBusy({
|
|
1086
|
+
version,
|
|
1087
|
+
held: [],
|
|
1088
|
+
message: `The test backend has committed ${version} versions of its own. Restore a snapshot into a fresh test backend.`,
|
|
1089
|
+
});
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
|
|
732
1093
|
// Scheduled functions, on the kernel's clock.
|
|
733
1094
|
|
|
734
1095
|
/**
|
|
@@ -753,12 +1114,14 @@ export class World {
|
|
|
753
1114
|
* crosses no future deadline.
|
|
754
1115
|
*/
|
|
755
1116
|
advance(ms: number, budget = defaultBudget): Promise<void> {
|
|
756
|
-
return this.
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
1117
|
+
return this.track("walks", () => {
|
|
1118
|
+
return this.open().then(() => {
|
|
1119
|
+
const until = this.now() + ms;
|
|
1120
|
+
return this.walk(
|
|
1121
|
+
(pending) => pending.filter((job) => job.scheduledTime <= until),
|
|
1122
|
+
budget,
|
|
1123
|
+
).then(() => this.raiseFloor(until));
|
|
1124
|
+
});
|
|
762
1125
|
});
|
|
763
1126
|
}
|
|
764
1127
|
|
|
@@ -767,9 +1130,11 @@ export class World {
|
|
|
767
1130
|
* standing jobs are pending. Every job run counts against `budget`.
|
|
768
1131
|
*/
|
|
769
1132
|
drain(budget = defaultBudget): Promise<void> {
|
|
770
|
-
return this.
|
|
771
|
-
this.
|
|
772
|
-
|
|
1133
|
+
return this.track("walks", () => {
|
|
1134
|
+
return this.open().then(() =>
|
|
1135
|
+
this.walk((pending) => pending.filter((job) => !job.standing), budget),
|
|
1136
|
+
);
|
|
1137
|
+
});
|
|
773
1138
|
}
|
|
774
1139
|
|
|
775
1140
|
/**
|
|
@@ -910,12 +1275,32 @@ export class World {
|
|
|
910
1275
|
const target = functionPathOf(args, execution.componentPath);
|
|
911
1276
|
this.component(target.componentPath);
|
|
912
1277
|
const jobArgs = jsonToConvex(args.args);
|
|
1278
|
+
this.keepArgs(target, jobArgs);
|
|
913
1279
|
if (hasPendingValues(jobArgs)) {
|
|
914
1280
|
throw new Error(
|
|
915
1281
|
`Invalid arguments for "${target.udfPath}": a scheduled function cannot take an unresolved placeholder value.`,
|
|
916
1282
|
);
|
|
917
1283
|
}
|
|
1284
|
+
refuse(attempt.meter.schedule(jobArgs, execution.limits));
|
|
1285
|
+
const jobsIndex = indexKey(
|
|
1286
|
+
execution.componentPath,
|
|
1287
|
+
Scheduling.table,
|
|
1288
|
+
"by_id",
|
|
1289
|
+
);
|
|
1290
|
+
// A scheduled function that schedules reads its own job first
|
|
1291
|
+
// (`SchedulerModel::schedule`, `crates/model/src/scheduled_jobs/mod.rs:238-300`).
|
|
1292
|
+
const parentJob = execution.request.scheduledFunctionId;
|
|
1293
|
+
if (parentJob !== null) {
|
|
1294
|
+
refuse(
|
|
1295
|
+
attempt.meter.readInterval(
|
|
1296
|
+
jobsIndex,
|
|
1297
|
+
Metering.point([parentJob]),
|
|
1298
|
+
execution.limits,
|
|
1299
|
+
),
|
|
1300
|
+
);
|
|
1301
|
+
}
|
|
918
1302
|
const id = this.generateId(Scheduling.table);
|
|
1303
|
+
attempt.meter.deriveInterval(jobsIndex, Metering.point([id]));
|
|
919
1304
|
attempt.write(
|
|
920
1305
|
execution.componentPath,
|
|
921
1306
|
id,
|
|
@@ -942,10 +1327,10 @@ export class World {
|
|
|
942
1327
|
/**
|
|
943
1328
|
* Cancel a job that has not finished. A finished job stays as it ended.
|
|
944
1329
|
*/
|
|
945
|
-
private cancelJob(attempt: KernelAttempt,
|
|
1330
|
+
private cancelJob(attempt: KernelAttempt, execution: Execution, id: any) {
|
|
1331
|
+
const { componentPath } = execution;
|
|
946
1332
|
validateId(Scheduling.table, id);
|
|
947
|
-
|
|
948
|
-
const doc = attempt.get(componentPath, id);
|
|
1333
|
+
const doc = this.readById(attempt, execution, id);
|
|
949
1334
|
if (doc === null) {
|
|
950
1335
|
throw new Error(`Patch on non-existent document with ID "${id}"`);
|
|
951
1336
|
}
|
|
@@ -968,6 +1353,7 @@ export class World {
|
|
|
968
1353
|
snapshotOnly = false,
|
|
969
1354
|
) {
|
|
970
1355
|
const resolved = this.resolve(functionPath, "query");
|
|
1356
|
+
this.keepArgs(functionPath, args);
|
|
971
1357
|
validateFunctionArgs(
|
|
972
1358
|
resolved.args,
|
|
973
1359
|
args ?? {},
|
|
@@ -979,6 +1365,7 @@ export class World {
|
|
|
979
1365
|
|
|
980
1366
|
private mutationAt(functionPath: FunctionPath, args: any, from: From) {
|
|
981
1367
|
const resolved = this.resolve(functionPath, "mutation");
|
|
1368
|
+
this.keepArgs(functionPath, args);
|
|
982
1369
|
validateFunctionArgs(
|
|
983
1370
|
resolved.args,
|
|
984
1371
|
args ?? {},
|
|
@@ -990,6 +1377,7 @@ export class World {
|
|
|
990
1377
|
|
|
991
1378
|
private actionAt(functionPath: FunctionPath, args: any, from: From) {
|
|
992
1379
|
const resolved = this.resolve(functionPath, "action");
|
|
1380
|
+
this.keepArgs(functionPath, args);
|
|
993
1381
|
validateFunctionArgs(resolved.args, args ?? {}, functionPath, false);
|
|
994
1382
|
return this.runAction(functionPath, resolved, args, from);
|
|
995
1383
|
}
|
|
@@ -1013,6 +1401,18 @@ export class World {
|
|
|
1013
1401
|
: from.parent !== undefined
|
|
1014
1402
|
? from.parent.identity
|
|
1015
1403
|
: from.caller.identity;
|
|
1404
|
+
// A nested call runs under its caller's limits, which its budget can only
|
|
1405
|
+
// tighten, and gives them back when it ends (`run_udf`,
|
|
1406
|
+
// `crates/isolate/src/environment/udf/async_syscall.rs:700-706`, and
|
|
1407
|
+
// `commit_subtransaction`, `crates/database/src/transaction.rs:431-441`).
|
|
1408
|
+
const inherited =
|
|
1409
|
+
attempt === undefined || from.parent?.attempt === undefined
|
|
1410
|
+
? Metering.defaults
|
|
1411
|
+
: from.parent.limits;
|
|
1412
|
+
const limits =
|
|
1413
|
+
attempt === undefined || from.budget === undefined
|
|
1414
|
+
? inherited
|
|
1415
|
+
: Metering.fromBudget(from.budget, attempt.meter.used(), inherited);
|
|
1016
1416
|
return {
|
|
1017
1417
|
world: this,
|
|
1018
1418
|
componentPath: functionPath.componentPath,
|
|
@@ -1028,6 +1428,7 @@ export class World {
|
|
|
1028
1428
|
: this.now()),
|
|
1029
1429
|
pagination,
|
|
1030
1430
|
turn: new Turn(),
|
|
1431
|
+
limits,
|
|
1031
1432
|
};
|
|
1032
1433
|
}
|
|
1033
1434
|
|
|
@@ -1039,50 +1440,53 @@ export class World {
|
|
|
1039
1440
|
snapshotOnly = false,
|
|
1040
1441
|
top?: TopQuery,
|
|
1041
1442
|
): Promise<any> {
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
`
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1443
|
+
return this.track("functions", () => {
|
|
1444
|
+
const parentAttempt = from.parent?.attempt;
|
|
1445
|
+
const isNested = parentAttempt !== undefined;
|
|
1446
|
+
const attempt =
|
|
1447
|
+
top?.attempt ??
|
|
1448
|
+
(parentAttempt === undefined
|
|
1449
|
+
? new KernelAttempt(this.state)
|
|
1450
|
+
: snapshotOnly
|
|
1451
|
+
? parentAttempt.staleSnapshot()
|
|
1452
|
+
: parentAttempt);
|
|
1453
|
+
const execution = this.execution(
|
|
1454
|
+
functionPath,
|
|
1455
|
+
"query",
|
|
1456
|
+
attempt,
|
|
1457
|
+
from,
|
|
1458
|
+
top?.pagination ??
|
|
1459
|
+
((isNested && from.parent?.pagination) || {
|
|
1460
|
+
calls: 0,
|
|
1461
|
+
journal: null,
|
|
1462
|
+
end: null,
|
|
1463
|
+
}),
|
|
1464
|
+
top?.startTime,
|
|
1465
|
+
);
|
|
1466
|
+
const q = queryGeneric({
|
|
1467
|
+
handler: (ctx: any, a: any) =>
|
|
1468
|
+
resolved.handler({ ...ctx, auth: queryAuth(execution) }, a),
|
|
1469
|
+
}) as unknown as { invokeQuery: (args: string) => Promise<string> };
|
|
1470
|
+
return enter(execution, () =>
|
|
1471
|
+
q.invokeQuery(JSON.stringify(convexToJson([parseArgs(args)]))),
|
|
1472
|
+
)
|
|
1473
|
+
.then((raw) => settled(execution, raw))
|
|
1474
|
+
.then((raw) => {
|
|
1475
|
+
const result = jsonToConvex(JSON.parse(raw));
|
|
1476
|
+
if (!isNested && hasPendingValues(result)) {
|
|
1477
|
+
throw new Error(
|
|
1478
|
+
`Function ${functionPath.udfPath} return value invalid: queries ` +
|
|
1479
|
+
`cannot return an unresolved placeholder value.`,
|
|
1480
|
+
);
|
|
1481
|
+
}
|
|
1482
|
+
this.keepReturns(functionPath, result);
|
|
1483
|
+
validateReturnValue(resolved.returns, result, "query", functionPath);
|
|
1484
|
+
return result;
|
|
1485
|
+
})
|
|
1486
|
+
.catch((error: unknown) => {
|
|
1487
|
+
throw deserializeConvexErrorData(error);
|
|
1488
|
+
});
|
|
1489
|
+
});
|
|
1086
1490
|
}
|
|
1087
1491
|
|
|
1088
1492
|
private invokeMutation(
|
|
@@ -1100,10 +1504,12 @@ export class World {
|
|
|
1100
1504
|
.then((raw) => settled(execution, raw))
|
|
1101
1505
|
.then((raw) => {
|
|
1102
1506
|
const result = jsonToConvex(JSON.parse(raw));
|
|
1103
|
-
|
|
1507
|
+
const functionPath = {
|
|
1104
1508
|
componentPath: execution.componentPath,
|
|
1105
1509
|
udfPath: execution.udfPath,
|
|
1106
|
-
}
|
|
1510
|
+
};
|
|
1511
|
+
this.keepReturns(functionPath, result);
|
|
1512
|
+
validateReturnValue(resolved.returns, result, "mutation", functionPath);
|
|
1107
1513
|
return result;
|
|
1108
1514
|
});
|
|
1109
1515
|
}
|
|
@@ -1114,42 +1520,44 @@ export class World {
|
|
|
1114
1520
|
args: any,
|
|
1115
1521
|
from: From,
|
|
1116
1522
|
): Promise<any> {
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1523
|
+
return this.track("functions", () => {
|
|
1524
|
+
const parentAttempt = from.parent?.attempt;
|
|
1525
|
+
if (parentAttempt !== undefined) {
|
|
1526
|
+
const frame = parentAttempt.child();
|
|
1527
|
+
const execution = this.execution(
|
|
1528
|
+
functionPath,
|
|
1529
|
+
"mutation",
|
|
1530
|
+
frame,
|
|
1531
|
+
from,
|
|
1532
|
+
undefined,
|
|
1533
|
+
);
|
|
1534
|
+
// As at the top level, a nested mutation ends once its function has
|
|
1535
|
+
// returned and every syscall it started has settled. Convex runs a
|
|
1536
|
+
// nested function's syscall queue to the end before it commits its
|
|
1537
|
+
// subtransaction, so a detached fiber's late write lands in this frame
|
|
1538
|
+
// and merges with it. A failed one ends at once and rolls back.
|
|
1539
|
+
return this.invokeMutation(execution, resolved, args).then(
|
|
1540
|
+
(result) =>
|
|
1541
|
+
frame.drained().then(() => {
|
|
1542
|
+
frame.close();
|
|
1543
|
+
frame.merge();
|
|
1544
|
+
return result;
|
|
1545
|
+
}),
|
|
1546
|
+
(error: unknown) => {
|
|
1547
|
+
frame.close();
|
|
1548
|
+
throw deserializeConvexErrorData(error);
|
|
1549
|
+
},
|
|
1550
|
+
);
|
|
1551
|
+
}
|
|
1552
|
+
return this.prepareMutation(
|
|
1121
1553
|
functionPath,
|
|
1122
|
-
|
|
1123
|
-
|
|
1554
|
+
resolved,
|
|
1555
|
+
args,
|
|
1124
1556
|
from,
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
// nested function's syscall queue to the end before it commits its
|
|
1130
|
-
// subtransaction, so a detached fiber's late write lands in this frame
|
|
1131
|
-
// and merges with it. A failed one ends at once and rolls back.
|
|
1132
|
-
return this.invokeMutation(execution, resolved, args).then(
|
|
1133
|
-
(result) =>
|
|
1134
|
-
frame.drained().then(() => {
|
|
1135
|
-
frame.close();
|
|
1136
|
-
frame.merge();
|
|
1137
|
-
return result;
|
|
1138
|
-
}),
|
|
1139
|
-
(error: unknown) => {
|
|
1140
|
-
frame.close();
|
|
1141
|
-
throw deserializeConvexErrorData(error);
|
|
1142
|
-
},
|
|
1143
|
-
);
|
|
1144
|
-
}
|
|
1145
|
-
return this.prepareMutation(
|
|
1146
|
-
functionPath,
|
|
1147
|
-
resolved,
|
|
1148
|
-
args,
|
|
1149
|
-
from,
|
|
1150
|
-
1,
|
|
1151
|
-
Invocation.current(),
|
|
1152
|
-
).then((held) => this.publish(held));
|
|
1557
|
+
1,
|
|
1558
|
+
Invocation.current(),
|
|
1559
|
+
).then((held) => this.publish(held));
|
|
1560
|
+
});
|
|
1153
1561
|
}
|
|
1154
1562
|
|
|
1155
1563
|
/**
|
|
@@ -1167,53 +1575,58 @@ export class World {
|
|
|
1167
1575
|
number: Invocation.Attempt,
|
|
1168
1576
|
bound: Invocation.Invocation | undefined,
|
|
1169
1577
|
): Promise<Held> {
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
(
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
reads: attempt.reads,
|
|
1201
|
-
outcome,
|
|
1202
|
-
},
|
|
1203
|
-
attempt,
|
|
1204
|
-
again: (next) =>
|
|
1205
|
-
this.prepareMutation(
|
|
1578
|
+
return this.track("functions", () => {
|
|
1579
|
+
const attempt = new KernelAttempt(this.state, number);
|
|
1580
|
+
const execution = this.execution(
|
|
1581
|
+
functionPath,
|
|
1582
|
+
"mutation",
|
|
1583
|
+
attempt,
|
|
1584
|
+
from,
|
|
1585
|
+
undefined,
|
|
1586
|
+
);
|
|
1587
|
+
const run = () => this.invokeMutation(execution, resolved, args);
|
|
1588
|
+
const started =
|
|
1589
|
+
bound === undefined
|
|
1590
|
+
? run()
|
|
1591
|
+
: Invocation.bind({ ...bound, attempt: number }, run);
|
|
1592
|
+
return started
|
|
1593
|
+
.then(
|
|
1594
|
+
(value) =>
|
|
1595
|
+
attempt
|
|
1596
|
+
.drained()
|
|
1597
|
+
.then((): Outcome => ({ _tag: "returned", value })),
|
|
1598
|
+
(error: unknown): Outcome => ({
|
|
1599
|
+
_tag: "failed",
|
|
1600
|
+
error: deserializeConvexErrorData(error),
|
|
1601
|
+
}),
|
|
1602
|
+
)
|
|
1603
|
+
.then((outcome) => {
|
|
1604
|
+
attempt.close();
|
|
1605
|
+
const held: Held = {
|
|
1606
|
+
view: {
|
|
1607
|
+
id: `attempt-${this.nextAttemptId++}`,
|
|
1206
1608
|
functionPath,
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1609
|
+
request: from.request.requestId,
|
|
1610
|
+
number,
|
|
1611
|
+
snapshot: attempt.snapshot.version,
|
|
1612
|
+
reads: attempt.reads,
|
|
1613
|
+
outcome,
|
|
1614
|
+
},
|
|
1615
|
+
attempt,
|
|
1616
|
+
again: (next) =>
|
|
1617
|
+
this.prepareMutation(
|
|
1618
|
+
functionPath,
|
|
1619
|
+
resolved,
|
|
1620
|
+
args,
|
|
1621
|
+
from,
|
|
1622
|
+
next,
|
|
1623
|
+
bound,
|
|
1624
|
+
),
|
|
1625
|
+
};
|
|
1626
|
+
this.holding.set(held.view.id, held);
|
|
1627
|
+
return held;
|
|
1628
|
+
});
|
|
1629
|
+
});
|
|
1217
1630
|
}
|
|
1218
1631
|
|
|
1219
1632
|
/**
|
|
@@ -1296,75 +1709,299 @@ export class World {
|
|
|
1296
1709
|
args: any,
|
|
1297
1710
|
from: From,
|
|
1298
1711
|
): Promise<any> {
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1712
|
+
return this.track("functions", () => {
|
|
1713
|
+
const execution = this.execution(
|
|
1714
|
+
functionPath,
|
|
1715
|
+
"action",
|
|
1716
|
+
undefined,
|
|
1717
|
+
from,
|
|
1718
|
+
undefined,
|
|
1719
|
+
);
|
|
1720
|
+
const inner: From = { request: from.request, parent: execution };
|
|
1721
|
+
const a = actionGeneric({
|
|
1722
|
+
handler: (ctx: any, innerArgs: any) =>
|
|
1723
|
+
resolved.handler(
|
|
1724
|
+
{
|
|
1725
|
+
...ctx,
|
|
1726
|
+
runQuery: (reference: any, queryArgs: any) =>
|
|
1727
|
+
this.queryAt(
|
|
1728
|
+
pathOfReference(reference, functionPath.componentPath),
|
|
1729
|
+
queryArgs,
|
|
1730
|
+
inner,
|
|
1731
|
+
),
|
|
1732
|
+
runMutation: (reference: any, mutationArgs: any) =>
|
|
1733
|
+
this.mutationAt(
|
|
1734
|
+
pathOfReference(reference, functionPath.componentPath),
|
|
1735
|
+
mutationArgs,
|
|
1736
|
+
inner,
|
|
1737
|
+
),
|
|
1738
|
+
runAction: (reference: any, actionArgs: any) =>
|
|
1739
|
+
this.actionAt(
|
|
1740
|
+
pathOfReference(reference, functionPath.componentPath),
|
|
1741
|
+
actionArgs,
|
|
1742
|
+
inner,
|
|
1743
|
+
),
|
|
1744
|
+
auth: queryAuth(execution),
|
|
1745
|
+
},
|
|
1746
|
+
innerArgs,
|
|
1747
|
+
),
|
|
1748
|
+
}) as unknown as {
|
|
1749
|
+
invokeAction: (requestId: string, args: string) => Promise<string>;
|
|
1750
|
+
};
|
|
1751
|
+
const run = () =>
|
|
1752
|
+
enter(execution, () =>
|
|
1753
|
+
a.invokeAction(
|
|
1754
|
+
from.request.requestId,
|
|
1755
|
+
JSON.stringify(convexToJson([parseArgs(args)])),
|
|
1756
|
+
),
|
|
1757
|
+
);
|
|
1758
|
+
// An action another function calls is an invocation of its own, with its
|
|
1759
|
+
// world's services, a start time read from its world's clock and its
|
|
1760
|
+
// world's random stream, as a scheduled job is.
|
|
1761
|
+
const bound =
|
|
1762
|
+
from.parent === undefined ? undefined : Invocation.current();
|
|
1763
|
+
const started =
|
|
1764
|
+
bound === undefined
|
|
1765
|
+
? run()
|
|
1766
|
+
: Invocation.bind(
|
|
1767
|
+
Invocation.fresh(
|
|
1768
|
+
bound.world,
|
|
1769
|
+
functionPath.componentPath,
|
|
1770
|
+
from.request,
|
|
1329
1771
|
),
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
)
|
|
1334
|
-
|
|
1335
|
-
|
|
1772
|
+
run,
|
|
1773
|
+
);
|
|
1774
|
+
return started
|
|
1775
|
+
.then((raw) => {
|
|
1776
|
+
const result = jsonToConvex(JSON.parse(raw));
|
|
1777
|
+
this.keepReturns(functionPath, result);
|
|
1778
|
+
validateReturnValue(resolved.returns, result, "action", functionPath);
|
|
1779
|
+
return result;
|
|
1780
|
+
})
|
|
1781
|
+
.catch((error: unknown) => {
|
|
1782
|
+
throw deserializeConvexErrorData(error);
|
|
1783
|
+
});
|
|
1784
|
+
});
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
// Secrets.
|
|
1788
|
+
|
|
1789
|
+
private keepArgs(functionPath: FunctionPath, args: unknown) {
|
|
1790
|
+
Redaction.keep(
|
|
1791
|
+
args ?? {},
|
|
1792
|
+
placed(this.redaction.args(functionPath)),
|
|
1793
|
+
this.secrets,
|
|
1794
|
+
);
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
private keepReturns(functionPath: FunctionPath, result: Value) {
|
|
1798
|
+
Redaction.keep(
|
|
1799
|
+
result,
|
|
1800
|
+
placed(this.redaction.returns(functionPath)),
|
|
1801
|
+
this.secrets,
|
|
1802
|
+
);
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
private keepRow(componentPath: string, table: string, value: unknown) {
|
|
1806
|
+
Redaction.keep(
|
|
1807
|
+
value,
|
|
1808
|
+
placed(this.redaction.table(componentPath, table)),
|
|
1809
|
+
this.secrets,
|
|
1810
|
+
);
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
/**
|
|
1814
|
+
* Where a stored document holds secrets: its table's shape at its root, and
|
|
1815
|
+
* for a job's row, its target's argument shape at `args.0`.
|
|
1816
|
+
*/
|
|
1817
|
+
private documentPlacements(
|
|
1818
|
+
componentPath: string,
|
|
1819
|
+
table: string,
|
|
1820
|
+
doc: { readonly _id: unknown; readonly name?: unknown },
|
|
1821
|
+
): ReadonlyArray<Redaction.Placement> {
|
|
1822
|
+
const own = placed(this.redaction.table(componentPath, table));
|
|
1823
|
+
if (table !== Scheduling.table) return own;
|
|
1824
|
+
const target = this.targets.get(doc._id as DocumentId)?.functionPath ?? {
|
|
1825
|
+
componentPath,
|
|
1826
|
+
udfPath: String(doc.name),
|
|
1336
1827
|
};
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1828
|
+
return own.concat(placed(this.redaction.args(target), ["args", "0"]));
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
/**
|
|
1832
|
+
* A copy of the raw secrets this world has kept, for a restore of its
|
|
1833
|
+
* snapshots. It lives in memory only.
|
|
1834
|
+
*/
|
|
1835
|
+
keptSecrets(): Redaction.Secrets {
|
|
1836
|
+
return this.secrets.copy();
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
/**
|
|
1840
|
+
* The failure report for `thrown`, whatever was thrown, with every kept
|
|
1841
|
+
* secret marked. `thrown` itself is never changed.
|
|
1842
|
+
*/
|
|
1843
|
+
redactFailure(thrown: unknown): unknown {
|
|
1844
|
+
return Redaction.failure(thrown, this.secrets);
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
/**
|
|
1848
|
+
* `outcome` as an artifact writes it: a value marked by the shape of what
|
|
1849
|
+
* `functionPath` returns, a failure as its report.
|
|
1850
|
+
*/
|
|
1851
|
+
redactOutcome(outcome: Outcome, functionPath?: FunctionPath): Outcome {
|
|
1852
|
+
return outcome._tag === "returned"
|
|
1853
|
+
? {
|
|
1854
|
+
_tag: "returned",
|
|
1855
|
+
value: Redaction.markValue(
|
|
1856
|
+
outcome.value,
|
|
1857
|
+
placed(
|
|
1858
|
+
functionPath === undefined
|
|
1859
|
+
? undefined
|
|
1860
|
+
: this.redaction.returns(functionPath),
|
|
1356
1861
|
),
|
|
1357
|
-
|
|
1358
|
-
)
|
|
1359
|
-
|
|
1360
|
-
.
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1862
|
+
this.secrets,
|
|
1863
|
+
),
|
|
1864
|
+
}
|
|
1865
|
+
: { _tag: "failed", error: this.redactFailure(outcome.error) };
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
/**
|
|
1869
|
+
* A changed document as an artifact writes it, marked by its table's shape.
|
|
1870
|
+
*/
|
|
1871
|
+
redactChange(change: Kernel.Change): Kernel.Change {
|
|
1872
|
+
const document = (doc: StoredDocument | null) =>
|
|
1873
|
+
doc === null
|
|
1874
|
+
? null
|
|
1875
|
+
: (Redaction.markValue(
|
|
1876
|
+
doc as Value,
|
|
1877
|
+
this.documentPlacements(
|
|
1878
|
+
change.componentPath,
|
|
1879
|
+
tableNameFromId(doc._id) ?? "",
|
|
1880
|
+
doc,
|
|
1881
|
+
),
|
|
1882
|
+
this.secrets,
|
|
1883
|
+
) as StoredDocument);
|
|
1884
|
+
return {
|
|
1885
|
+
...change,
|
|
1886
|
+
before: document(change.before),
|
|
1887
|
+
after: document(change.after),
|
|
1888
|
+
};
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
/**
|
|
1892
|
+
* The function a subscription's or call's reference names, if it names one.
|
|
1893
|
+
*/
|
|
1894
|
+
functionPathOf(reference: unknown): FunctionPath | undefined {
|
|
1895
|
+
try {
|
|
1896
|
+
return typeof reference === "function"
|
|
1897
|
+
? undefined
|
|
1898
|
+
: pathOfReference(reference as FunctionReference<any>, ROOT);
|
|
1899
|
+
} catch {
|
|
1900
|
+
return undefined;
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
/**
|
|
1905
|
+
* Read records as an artifact writes them. A key or range bound on a field
|
|
1906
|
+
* its table's shape holds secret is the marker, and so is any string holding
|
|
1907
|
+
* a kept secret.
|
|
1908
|
+
*/
|
|
1909
|
+
projectReads(
|
|
1910
|
+
reads: ReadonlyArray<ReadSet.Read>,
|
|
1911
|
+
): ReadonlyArray<ReadSet.Read> {
|
|
1912
|
+
return reads.map((read) => {
|
|
1913
|
+
if (read.kind === "document") return read;
|
|
1914
|
+
const shape = this.redaction.table(read.componentPath, read.table);
|
|
1915
|
+
const sensitive = (fieldPath: string) =>
|
|
1916
|
+
shape !== undefined &&
|
|
1917
|
+
Redaction.sensitiveAt(shape, fieldPath.split("."));
|
|
1918
|
+
const value = (fieldPath: string, v: Value): Value =>
|
|
1919
|
+
sensitive(fieldPath)
|
|
1920
|
+
? Redaction.marker
|
|
1921
|
+
: Redaction.markValue(v, [], this.secrets);
|
|
1922
|
+
const key = (k: ReadSet.Key | null) =>
|
|
1923
|
+
k === null
|
|
1924
|
+
? null
|
|
1925
|
+
: k.map((v, i) =>
|
|
1926
|
+
v === undefined ? undefined : value(read.fields[i] ?? "", v),
|
|
1927
|
+
);
|
|
1928
|
+
return {
|
|
1929
|
+
...read,
|
|
1930
|
+
range: read.range.map((bound) => ({
|
|
1931
|
+
...bound,
|
|
1932
|
+
value: sensitive(bound.fieldPath)
|
|
1933
|
+
? Redaction.marker
|
|
1934
|
+
: convexToJson(
|
|
1935
|
+
Redaction.markValue(
|
|
1936
|
+
jsonToConvex(bound.value),
|
|
1937
|
+
[],
|
|
1938
|
+
this.secrets,
|
|
1939
|
+
),
|
|
1940
|
+
),
|
|
1941
|
+
})),
|
|
1942
|
+
after: key(read.after),
|
|
1943
|
+
through:
|
|
1944
|
+
read.through === "end" || read.through === null
|
|
1945
|
+
? read.through
|
|
1946
|
+
: key(read.through),
|
|
1947
|
+
};
|
|
1948
|
+
});
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
/**
|
|
1952
|
+
* A held attempt as an artifact writes it: its read records projected and its
|
|
1953
|
+
* outcome marked.
|
|
1954
|
+
*/
|
|
1955
|
+
projectHeld(view: HeldAttempt): HeldAttempt {
|
|
1956
|
+
return {
|
|
1957
|
+
...view,
|
|
1958
|
+
reads: this.projectReads(view.reads),
|
|
1959
|
+
outcome: this.redactOutcome(view.outcome, view.functionPath),
|
|
1960
|
+
};
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
/**
|
|
1964
|
+
* What `apply` answered as an artifact writes it: an applied result marked by
|
|
1965
|
+
* the shape of what `functionPath` returns, a conflict's next attempt
|
|
1966
|
+
* projected, a refusal as its report.
|
|
1967
|
+
*/
|
|
1968
|
+
projectApplied(
|
|
1969
|
+
applied: KernelApply,
|
|
1970
|
+
functionPath: FunctionPath | undefined,
|
|
1971
|
+
): KernelApply {
|
|
1972
|
+
switch (applied.kind) {
|
|
1973
|
+
case "applied": {
|
|
1974
|
+
const outcome = this.redactOutcome(
|
|
1975
|
+
{ _tag: "returned", value: applied.result },
|
|
1976
|
+
functionPath,
|
|
1977
|
+
);
|
|
1978
|
+
return outcome._tag === "returned"
|
|
1979
|
+
? { ...applied, result: outcome.value }
|
|
1980
|
+
: applied;
|
|
1981
|
+
}
|
|
1982
|
+
case "conflict":
|
|
1983
|
+
return { ...applied, next: this.projectHeld(applied.next) };
|
|
1984
|
+
case "refused":
|
|
1985
|
+
return { ...applied, error: this.redactFailure(applied.error) };
|
|
1986
|
+
case "exhausted":
|
|
1987
|
+
return {
|
|
1988
|
+
...applied,
|
|
1989
|
+
error: this.redactFailure(applied.error) as typeof applied.error,
|
|
1990
|
+
};
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
/**
|
|
1995
|
+
* A function's arguments as an artifact writes them, marked by the shape of
|
|
1996
|
+
* its arguments. A command log of provider calls (#137's `commands()`) writes
|
|
1997
|
+
* each command's input through this.
|
|
1998
|
+
*/
|
|
1999
|
+
projectArguments(functionPath: FunctionPath, args: Value): Value {
|
|
2000
|
+
return Redaction.markValue(
|
|
2001
|
+
args,
|
|
2002
|
+
placed(this.redaction.args(functionPath)),
|
|
2003
|
+
this.secrets,
|
|
2004
|
+
);
|
|
1368
2005
|
}
|
|
1369
2006
|
|
|
1370
2007
|
// Syscalls.
|
|
@@ -1375,18 +2012,83 @@ export class World {
|
|
|
1375
2012
|
|
|
1376
2013
|
private view(execution: Execution): KernelQuery.TableView {
|
|
1377
2014
|
const attempt = transaction(execution);
|
|
2015
|
+
const { componentPath, limits } = execution;
|
|
1378
2016
|
return {
|
|
1379
|
-
schema: this.schema(
|
|
1380
|
-
scan: (tableName) => attempt.scan(
|
|
1381
|
-
read: (interval, after) =>
|
|
1382
|
-
attempt.readInterval({
|
|
2017
|
+
schema: this.schema(componentPath),
|
|
2018
|
+
scan: (tableName) => attempt.scan(componentPath, tableName),
|
|
2019
|
+
read: ({ index, ...interval }, after) => {
|
|
2020
|
+
const advance = attempt.readInterval({
|
|
1383
2021
|
...interval,
|
|
1384
|
-
componentPath
|
|
2022
|
+
componentPath,
|
|
1385
2023
|
after,
|
|
1386
|
-
})
|
|
2024
|
+
});
|
|
2025
|
+
return (through) => {
|
|
2026
|
+
advance(through);
|
|
2027
|
+
const examined = Metering.examined(
|
|
2028
|
+
interval.range,
|
|
2029
|
+
interval.order,
|
|
2030
|
+
after,
|
|
2031
|
+
through,
|
|
2032
|
+
);
|
|
2033
|
+
if (examined !== null) {
|
|
2034
|
+
refuse(
|
|
2035
|
+
attempt.meter.readInterval(
|
|
2036
|
+
indexKey(componentPath, interval.table, index),
|
|
2037
|
+
examined,
|
|
2038
|
+
limits,
|
|
2039
|
+
),
|
|
2040
|
+
);
|
|
2041
|
+
}
|
|
2042
|
+
};
|
|
2043
|
+
},
|
|
2044
|
+
examine: (table, doc) =>
|
|
2045
|
+
refuse(attempt.meter.readDocument(doc, isSystemTable(table), limits)),
|
|
1387
2046
|
};
|
|
1388
2047
|
}
|
|
1389
2048
|
|
|
2049
|
+
/**
|
|
2050
|
+
* Read one document by ID, as the binary's `get_inner` does for a get and for
|
|
2051
|
+
* the document a patch, replace, delete or cancel changes: it reads the ID's
|
|
2052
|
+
* interval of the table's `by_id` index, then charges the document if there
|
|
2053
|
+
* is one (`crates/database/src/transaction.rs:1188-1250`).
|
|
2054
|
+
*/
|
|
2055
|
+
private readById(
|
|
2056
|
+
attempt: KernelAttempt,
|
|
2057
|
+
execution: Execution,
|
|
2058
|
+
id: DocumentId,
|
|
2059
|
+
): StoredDocument | null {
|
|
2060
|
+
const { componentPath, limits } = execution;
|
|
2061
|
+
const table = tableNameFromId(id)!;
|
|
2062
|
+
attempt.readDocument(componentPath, id);
|
|
2063
|
+
refuse(
|
|
2064
|
+
attempt.meter.readInterval(
|
|
2065
|
+
indexKey(componentPath, table, "by_id"),
|
|
2066
|
+
Metering.point([id]),
|
|
2067
|
+
limits,
|
|
2068
|
+
),
|
|
2069
|
+
);
|
|
2070
|
+
const doc = attempt.get(componentPath, id);
|
|
2071
|
+
if (doc !== null) {
|
|
2072
|
+
refuse(attempt.meter.readDocument(doc, isSystemTable(table), limits));
|
|
2073
|
+
}
|
|
2074
|
+
return doc;
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
/**
|
|
2078
|
+
* Stage a write to a user table, counted against the execution's limits
|
|
2079
|
+
* first. A write over a limit is not staged (`Writes::update`,
|
|
2080
|
+
* `crates/database/src/writes.rs:241-310`).
|
|
2081
|
+
*/
|
|
2082
|
+
private stage(
|
|
2083
|
+
execution: Execution,
|
|
2084
|
+
id: DocumentId,
|
|
2085
|
+
doc: StoredDocument | null,
|
|
2086
|
+
) {
|
|
2087
|
+
const attempt = transaction(execution);
|
|
2088
|
+
refuse(attempt.meter.write(doc, execution.limits));
|
|
2089
|
+
attempt.write(execution.componentPath, id, doc);
|
|
2090
|
+
}
|
|
2091
|
+
|
|
1390
2092
|
private generateId(table: string): DocumentId {
|
|
1391
2093
|
const counter = String(this.nextDocId++).padStart(
|
|
1392
2094
|
TYPICAL_CONVEX_ID_LENGTH - table.length,
|
|
@@ -1512,6 +2214,15 @@ export class World {
|
|
|
1512
2214
|
return JSON.stringify({});
|
|
1513
2215
|
case "1.0/count": {
|
|
1514
2216
|
const attempt = transaction(execution);
|
|
2217
|
+
// The binary counts a table by reading its whole `by_id` index, and no
|
|
2218
|
+
// document (`count_tablet`, `crates/database/src/bootstrap_model/table.rs:133-160`).
|
|
2219
|
+
refuse(
|
|
2220
|
+
attempt.meter.readInterval(
|
|
2221
|
+
indexKey(execution.componentPath, args.table, "by_id"),
|
|
2222
|
+
Metering.everything,
|
|
2223
|
+
execution.limits,
|
|
2224
|
+
),
|
|
2225
|
+
);
|
|
1515
2226
|
attempt.readInterval({
|
|
1516
2227
|
kind: "interval",
|
|
1517
2228
|
componentPath: execution.componentPath,
|
|
@@ -1558,8 +2269,12 @@ export class World {
|
|
|
1558
2269
|
return run.then((value) => JSON.stringify(convexToJson(value)));
|
|
1559
2270
|
}
|
|
1560
2271
|
case "1.0/runUdf": {
|
|
1561
|
-
const { udfType, args: udfArgs } = args;
|
|
1562
|
-
const from: From = {
|
|
2272
|
+
const { udfType, args: udfArgs, transactionLimits } = args;
|
|
2273
|
+
const from: From = {
|
|
2274
|
+
request: execution.request,
|
|
2275
|
+
parent: execution,
|
|
2276
|
+
budget: budgetOf(transactionLimits),
|
|
2277
|
+
};
|
|
1563
2278
|
const functionPath = functionPathOf(args, execution.componentPath);
|
|
1564
2279
|
const value = jsonToConvex(udfArgs);
|
|
1565
2280
|
const run =
|
|
@@ -1598,15 +2313,11 @@ export class World {
|
|
|
1598
2313
|
),
|
|
1599
2314
|
);
|
|
1600
2315
|
case "1.0/cancel_job":
|
|
1601
|
-
this.cancelJob(
|
|
1602
|
-
transaction(execution),
|
|
1603
|
-
execution.componentPath,
|
|
1604
|
-
args.id,
|
|
1605
|
-
);
|
|
2316
|
+
this.cancelJob(transaction(execution), execution, args.id);
|
|
1606
2317
|
return JSON.stringify({});
|
|
1607
2318
|
case "1.0/actions/cancel_job":
|
|
1608
2319
|
this.commitNow((attempt) =>
|
|
1609
|
-
this.cancelJob(attempt, execution
|
|
2320
|
+
this.cancelJob(attempt, execution, args.id),
|
|
1610
2321
|
);
|
|
1611
2322
|
return JSON.stringify({});
|
|
1612
2323
|
case "1.0/storageDelete":
|
|
@@ -1614,7 +2325,14 @@ export class World {
|
|
|
1614
2325
|
case "1.0/storageGenerateUploadUrl":
|
|
1615
2326
|
throw KernelRefusal.rowPending("storage", 6);
|
|
1616
2327
|
case "1.0/getTransactionMetrics":
|
|
1617
|
-
|
|
2328
|
+
// An action has no transaction, and the binary's action syscalls do
|
|
2329
|
+
// not know this one (`crates/isolate/src/environment/action/async_syscall.rs:105`).
|
|
2330
|
+
if (execution.attempt === undefined) {
|
|
2331
|
+
throw new Error(`Unknown async operation ${op}`);
|
|
2332
|
+
}
|
|
2333
|
+
return JSON.stringify(
|
|
2334
|
+
execution.attempt.meter.metrics(execution.limits),
|
|
2335
|
+
);
|
|
1618
2336
|
case "1.0/actions/vectorSearch":
|
|
1619
2337
|
throw KernelRefusal.requiresBinary("vectorSearch");
|
|
1620
2338
|
default:
|
|
@@ -1636,20 +2354,24 @@ export class World {
|
|
|
1636
2354
|
|
|
1637
2355
|
private get(execution: Execution, tableName: unknown, id: unknown) {
|
|
1638
2356
|
validateId(tableName, id);
|
|
1639
|
-
|
|
1640
|
-
attempt.readDocument(execution.componentPath, id);
|
|
1641
|
-
return attempt.get(execution.componentPath, id);
|
|
2357
|
+
return this.readById(transaction(execution), execution, id);
|
|
1642
2358
|
}
|
|
1643
2359
|
|
|
1644
2360
|
private insert(execution: Execution, table: string, value: any): DocumentId {
|
|
1645
2361
|
const attempt = transaction(execution);
|
|
2362
|
+
this.keepRow(execution.componentPath, table, value);
|
|
1646
2363
|
KernelQuery.validateDocument(
|
|
1647
2364
|
this.schema(execution.componentPath),
|
|
1648
2365
|
table,
|
|
1649
2366
|
value,
|
|
1650
2367
|
);
|
|
1651
2368
|
const _id = this.generateId(table);
|
|
1652
|
-
|
|
2369
|
+
// A new ID joins the table's `by_id` reads without counting as one.
|
|
2370
|
+
attempt.meter.deriveInterval(
|
|
2371
|
+
indexKey(execution.componentPath, table, "by_id"),
|
|
2372
|
+
Metering.point([_id]),
|
|
2373
|
+
);
|
|
2374
|
+
this.stage(execution, _id, {
|
|
1653
2375
|
...value,
|
|
1654
2376
|
_id,
|
|
1655
2377
|
_creationTime: this.creationTime(execution.startTime),
|
|
@@ -1681,9 +2403,7 @@ export class World {
|
|
|
1681
2403
|
`Invalid argument \`value\` in \`db.${write}\`, expected object but got '${typeof value}': ${value as any}`,
|
|
1682
2404
|
);
|
|
1683
2405
|
}
|
|
1684
|
-
const
|
|
1685
|
-
attempt.readDocument(execution.componentPath, id);
|
|
1686
|
-
const document = attempt.get(execution.componentPath, id);
|
|
2406
|
+
const document = this.readById(transaction(execution), execution, id);
|
|
1687
2407
|
if (document === null) {
|
|
1688
2408
|
throw new Error(
|
|
1689
2409
|
write === "delete"
|
|
@@ -1728,16 +2448,15 @@ export class World {
|
|
|
1728
2448
|
const document = this.existing(execution, tableName, id, "patch", value);
|
|
1729
2449
|
const { _id, _creationTime, ...fields } = document;
|
|
1730
2450
|
const merged = { ...fields, ...this.fields(document, value) };
|
|
2451
|
+
this.keepRow(execution.componentPath, tableNameFromId(_id)!, merged);
|
|
2452
|
+
const next = { _id, _creationTime, ...merged } as StoredDocument;
|
|
2453
|
+
if (sameDocument(document, next)) return;
|
|
1731
2454
|
KernelQuery.validateDocument(
|
|
1732
2455
|
this.schema(execution.componentPath),
|
|
1733
2456
|
tableNameFromId(_id)!,
|
|
1734
2457
|
merged,
|
|
1735
2458
|
);
|
|
1736
|
-
|
|
1737
|
-
_id,
|
|
1738
|
-
_creationTime,
|
|
1739
|
-
...merged,
|
|
1740
|
-
} as StoredDocument);
|
|
2459
|
+
this.stage(execution, _id, next);
|
|
1741
2460
|
}
|
|
1742
2461
|
|
|
1743
2462
|
private replace(
|
|
@@ -1748,16 +2467,23 @@ export class World {
|
|
|
1748
2467
|
) {
|
|
1749
2468
|
const document = this.existing(execution, tableName, id, "replace", value);
|
|
1750
2469
|
const fields = this.fields(document, value);
|
|
1751
|
-
|
|
1752
|
-
|
|
2470
|
+
this.keepRow(
|
|
2471
|
+
execution.componentPath,
|
|
1753
2472
|
tableNameFromId(document._id)!,
|
|
1754
2473
|
fields,
|
|
1755
2474
|
);
|
|
1756
|
-
|
|
2475
|
+
const next = {
|
|
1757
2476
|
...fields,
|
|
1758
2477
|
_id: document._id,
|
|
1759
2478
|
_creationTime: document._creationTime,
|
|
1760
|
-
} as StoredDocument
|
|
2479
|
+
} as StoredDocument;
|
|
2480
|
+
if (sameDocument(document, next)) return;
|
|
2481
|
+
KernelQuery.validateDocument(
|
|
2482
|
+
this.schema(execution.componentPath),
|
|
2483
|
+
tableNameFromId(document._id)!,
|
|
2484
|
+
fields,
|
|
2485
|
+
);
|
|
2486
|
+
this.stage(execution, document._id, next);
|
|
1761
2487
|
}
|
|
1762
2488
|
|
|
1763
2489
|
private remove(execution: Execution, tableName: unknown, id: unknown) {
|
|
@@ -1768,7 +2494,7 @@ export class World {
|
|
|
1768
2494
|
"delete",
|
|
1769
2495
|
undefined,
|
|
1770
2496
|
);
|
|
1771
|
-
|
|
2497
|
+
this.stage(execution, document._id, null);
|
|
1772
2498
|
}
|
|
1773
2499
|
}
|
|
1774
2500
|
|
|
@@ -1781,8 +2507,99 @@ type From =
|
|
|
1781
2507
|
readonly caller: Caller;
|
|
1782
2508
|
readonly request: Request;
|
|
1783
2509
|
readonly parent?: undefined;
|
|
2510
|
+
readonly budget?: undefined;
|
|
1784
2511
|
}
|
|
1785
|
-
| {
|
|
2512
|
+
| {
|
|
2513
|
+
readonly parent: Execution;
|
|
2514
|
+
readonly request: Request;
|
|
2515
|
+
readonly budget?: Metering.Budget | undefined;
|
|
2516
|
+
};
|
|
2517
|
+
|
|
2518
|
+
const refuse = (error: Metering.TransactionLimitExceeded | undefined) => {
|
|
2519
|
+
if (error !== undefined) throw error;
|
|
2520
|
+
};
|
|
2521
|
+
|
|
2522
|
+
/**
|
|
2523
|
+
* One index of one table in one component, as the binary keys a read set.
|
|
2524
|
+
*/
|
|
2525
|
+
const indexKey = (componentPath: string, table: string, index: string) =>
|
|
2526
|
+
`${componentPath}\u0000${table}\u0000${index}`;
|
|
2527
|
+
|
|
2528
|
+
const isSystemTable = (table: string) => table.startsWith("_");
|
|
2529
|
+
|
|
2530
|
+
const isFieldMap = (value: unknown): value is object =>
|
|
2531
|
+
typeof value === "object" && value !== null && isSimpleObject(value);
|
|
2532
|
+
|
|
2533
|
+
/**
|
|
2534
|
+
* Whether two stored values are the same value, an unresolved commit timestamp
|
|
2535
|
+
* included. The binary compares pending values structurally, so a pending
|
|
2536
|
+
* timestamp equals only another pending timestamp, at any depth (`PendingValue`
|
|
2537
|
+
* derives `PartialEq`, `crates/value/src/pending.rs:56-75`, as
|
|
2538
|
+
* `PendingDocument` does, `crates/common/src/document.rs:624-632`). Concrete
|
|
2539
|
+
* values compare by `ConvexValue`'s total order
|
|
2540
|
+
* (`crates/value/src/sorting.rs:468-472`), which `compareValues` follows.
|
|
2541
|
+
* `compareValues` alone ranks a pending timestamp as the largest int64, so it
|
|
2542
|
+
* cannot tell the two apart. An object field set to `undefined` is absent.
|
|
2543
|
+
*/
|
|
2544
|
+
const sameValue = (a: unknown, b: unknown): boolean => {
|
|
2545
|
+
const aPending = a instanceof CommitTsPlaceholder;
|
|
2546
|
+
const bPending = b instanceof CommitTsPlaceholder;
|
|
2547
|
+
if (aPending || bPending) return aPending && bPending;
|
|
2548
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
2549
|
+
return (
|
|
2550
|
+
Array.isArray(a) &&
|
|
2551
|
+
Array.isArray(b) &&
|
|
2552
|
+
a.length === b.length &&
|
|
2553
|
+
a.every((value, i) => sameValue(value, b[i]))
|
|
2554
|
+
);
|
|
2555
|
+
}
|
|
2556
|
+
if (isFieldMap(a) || isFieldMap(b)) {
|
|
2557
|
+
if (!isFieldMap(a) || !isFieldMap(b)) return false;
|
|
2558
|
+
const fields = (value: object) =>
|
|
2559
|
+
Object.entries(value).filter(([, field]) => field !== undefined);
|
|
2560
|
+
const aFields = fields(a);
|
|
2561
|
+
const bFields = new Map(fields(b));
|
|
2562
|
+
return (
|
|
2563
|
+
aFields.length === bFields.size &&
|
|
2564
|
+
aFields.every(
|
|
2565
|
+
([key, field]) =>
|
|
2566
|
+
bFields.has(key) && sameValue(field, bFields.get(key)),
|
|
2567
|
+
)
|
|
2568
|
+
);
|
|
2569
|
+
}
|
|
2570
|
+
return compareValues(a as Value, b as Value) === 0;
|
|
2571
|
+
};
|
|
2572
|
+
|
|
2573
|
+
/**
|
|
2574
|
+
* Whether a patch or replace leaves a document as it was. The binary then
|
|
2575
|
+
* writes nothing (`patch_inner` and `replace_inner`,
|
|
2576
|
+
* `crates/database/src/transaction.rs:713-790`).
|
|
2577
|
+
*/
|
|
2578
|
+
const sameDocument = (before: StoredDocument, after: StoredDocument) =>
|
|
2579
|
+
sameValue(before, after);
|
|
2580
|
+
|
|
2581
|
+
/**
|
|
2582
|
+
* A nested call's `transactionLimits`. The binary reads each field as an
|
|
2583
|
+
* unsigned integer and ignores fields it does not know (`TransactionLimits`,
|
|
2584
|
+
* `crates/database/src/execution_size.rs:62-95`).
|
|
2585
|
+
*/
|
|
2586
|
+
const budgetOf = (limits: unknown): Metering.Budget | undefined => {
|
|
2587
|
+
if (limits === undefined || limits === null) return undefined;
|
|
2588
|
+
const budget: Partial<Record<keyof Metering.Limits, number>> = {};
|
|
2589
|
+
for (const metric of Object.keys(Metering.defaults) as Array<
|
|
2590
|
+
keyof Metering.Limits
|
|
2591
|
+
>) {
|
|
2592
|
+
const value = (limits as Record<string, unknown>)[metric];
|
|
2593
|
+
if (value === undefined) continue;
|
|
2594
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
|
|
2595
|
+
throw new Error(
|
|
2596
|
+
`Invalid arguments for \`runUdf\`: transactionLimits.${metric} must be a non-negative integer, got ${JSON.stringify(value)}`,
|
|
2597
|
+
);
|
|
2598
|
+
}
|
|
2599
|
+
budget[metric] = value;
|
|
2600
|
+
}
|
|
2601
|
+
return budget;
|
|
2602
|
+
};
|
|
1786
2603
|
|
|
1787
2604
|
const closed = (execution: Execution, operation: string) =>
|
|
1788
2605
|
new KernelRefusal.ClosedAttempt({
|