@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.
Files changed (51) hide show
  1. package/dist/TestConfect.d.ts +26 -0
  2. package/dist/TestConfect.d.ts.map +1 -1
  3. package/dist/TestConfect.js +9 -1
  4. package/dist/TestConfect.js.map +1 -1
  5. package/dist/internal/Attempt.d.ts +14 -1
  6. package/dist/internal/Attempt.d.ts.map +1 -1
  7. package/dist/internal/Attempt.js +18 -1
  8. package/dist/internal/Attempt.js.map +1 -1
  9. package/dist/internal/ConvexTest.d.ts +26 -1
  10. package/dist/internal/ConvexTest.d.ts.map +1 -1
  11. package/dist/internal/ConvexTest.js +55 -27
  12. package/dist/internal/ConvexTest.js.map +1 -1
  13. package/dist/internal/Execution.d.ts +151 -1
  14. package/dist/internal/Execution.d.ts.map +1 -1
  15. package/dist/internal/Execution.js +731 -214
  16. package/dist/internal/Execution.js.map +1 -1
  17. package/dist/internal/KernelQuery.d.ts +8 -3
  18. package/dist/internal/KernelQuery.d.ts.map +1 -1
  19. package/dist/internal/KernelQuery.js +10 -3
  20. package/dist/internal/KernelQuery.js.map +1 -1
  21. package/dist/internal/KernelRefusal.d.ts +134 -0
  22. package/dist/internal/KernelRefusal.d.ts.map +1 -1
  23. package/dist/internal/KernelRefusal.js +134 -1
  24. package/dist/internal/KernelRefusal.js.map +1 -1
  25. package/dist/internal/Metering.d.ts +186 -0
  26. package/dist/internal/Metering.d.ts.map +1 -0
  27. package/dist/internal/Metering.js +401 -0
  28. package/dist/internal/Metering.js.map +1 -0
  29. package/dist/internal/Redaction.d.ts +142 -0
  30. package/dist/internal/Redaction.d.ts.map +1 -0
  31. package/dist/internal/Redaction.js +448 -0
  32. package/dist/internal/Redaction.js.map +1 -0
  33. package/dist/internal/Snapshot.d.ts +298 -0
  34. package/dist/internal/Snapshot.d.ts.map +1 -0
  35. package/dist/internal/Snapshot.js +532 -0
  36. package/dist/internal/Snapshot.js.map +1 -0
  37. package/dist/internal/Subscriptions.d.ts +13 -0
  38. package/dist/internal/Subscriptions.d.ts.map +1 -1
  39. package/dist/internal/Subscriptions.js +27 -3
  40. package/dist/internal/Subscriptions.js.map +1 -1
  41. package/package.json +3 -3
  42. package/src/TestConfect.ts +63 -4
  43. package/src/internal/Attempt.ts +24 -0
  44. package/src/internal/ConvexTest.ts +135 -38
  45. package/src/internal/Execution.ts +1141 -324
  46. package/src/internal/KernelQuery.ts +17 -6
  47. package/src/internal/KernelRefusal.ts +160 -0
  48. package/src/internal/Metering.ts +520 -0
  49. package/src/internal/Redaction.ts +730 -0
  50. package/src/internal/Snapshot.ts +945 -0
  51. 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 { convexToJson, jsonToConvex, type Value } from "convex/values";
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.open().then(() => {
591
- const from = { caller, request: newRequest(caller) };
592
- return typeof reference === "function"
593
- ? this.runQuery(
594
- { componentPath: ROOT, udfPath: "inline" },
595
- { handler: reference, args: { type: "any" }, returns: null },
596
- args ?? {},
597
- from,
598
- )
599
- : this.queryAt(pathOfReference(reference, ROOT), args, from);
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.open().then(() => {
605
- const from = { caller, request: newRequest(caller) };
606
- return typeof reference === "function"
607
- ? this.runMutation(
608
- { componentPath: ROOT, udfPath: "inline" },
609
- { handler: reference, args: { type: "any" }, returns: null },
610
- args ?? {},
611
- from,
612
- )
613
- : this.mutationAt(pathOfReference(reference, ROOT), args, from);
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.open().then(() => {
619
- const from = { caller, request: newRequest(caller) };
620
- return typeof reference === "function"
621
- ? this.runAction(
622
- { componentPath: ROOT, udfPath: "inline" },
623
- { handler: reference, args: { type: "any" }, returns: null },
624
- args ?? {},
625
- from,
626
- )
627
- : this.actionAt(pathOfReference(reference, ROOT), args, from);
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.open().then(() => {
637
- this.component(componentPath);
638
- return this.runMutation(
639
- { componentPath, udfPath: "custom" },
640
- { handler, args: { type: "any" }, returns: null },
641
- {},
642
- { caller, request: newRequest(caller) },
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.open().then(() => {
654
- const functionPath = pathOfReference(reference, ROOT);
655
- const resolved = this.resolve(functionPath, "mutation");
656
- validateFunctionArgs(resolved.args, args ?? {}, functionPath, false);
657
- return this.prepareMutation(
658
- functionPath,
659
- resolved,
660
- args,
661
- { caller, request: newRequest(caller) },
662
- 1,
663
- Invocation.current(),
664
- ).then((held) => held.view);
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
- const held = this.holding.get(id);
670
- return held === undefined
671
- ? Promise.reject(
672
- new KernelRefusal.StaleAttempt({
673
- id,
674
- message: `The test kernel holds no attempt "${id}". It was already applied, or never prepared.`,
675
- }),
676
- )
677
- : this.applyHeld(held);
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.open().then(() => {
702
- const functionPath = pathOfReference(reference, ROOT);
703
- const resolved = this.resolve(functionPath, "query");
704
- validateFunctionArgs(resolved.args, args ?? {}, functionPath, false);
705
- const top: TopQuery = {
706
- attempt: new KernelAttempt(this.state),
707
- pagination: { calls: 0, journal: pinned.journal, end: null },
708
- startTime: pinned.time,
709
- };
710
- const run = (outcome: Outcome): QueryRun => ({
711
- outcome,
712
- snapshot: top.attempt.snapshot.version,
713
- reads: top.attempt.reads,
714
- observedIdentity: top.attempt.observedIdentity,
715
- time: top.attempt.observedTime,
716
- journal: pinned.journal ?? top.pagination.end,
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.open().then(() => {
757
- const until = this.now() + ms;
758
- return this.walk(
759
- (pending) => pending.filter((job) => job.scheduledTime <= until),
760
- budget,
761
- ).then(() => this.raiseFloor(until));
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.open().then(() =>
771
- this.walk((pending) => pending.filter((job) => !job.standing), budget),
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, componentPath: string, id: any) {
1330
+ private cancelJob(attempt: KernelAttempt, execution: Execution, id: any) {
1331
+ const { componentPath } = execution;
946
1332
  validateId(Scheduling.table, id);
947
- attempt.readDocument(componentPath, id);
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
- const parentAttempt = from.parent?.attempt;
1043
- const isNested = parentAttempt !== undefined;
1044
- const attempt =
1045
- top?.attempt ??
1046
- (parentAttempt === undefined
1047
- ? new KernelAttempt(this.state)
1048
- : snapshotOnly
1049
- ? new KernelAttempt(parentAttempt.snapshot)
1050
- : parentAttempt);
1051
- const execution = this.execution(
1052
- functionPath,
1053
- "query",
1054
- attempt,
1055
- from,
1056
- top?.pagination ??
1057
- ((isNested && from.parent?.pagination) || {
1058
- calls: 0,
1059
- journal: null,
1060
- end: null,
1061
- }),
1062
- top?.startTime,
1063
- );
1064
- const q = queryGeneric({
1065
- handler: (ctx: any, a: any) =>
1066
- resolved.handler({ ...ctx, auth: queryAuth(execution) }, a),
1067
- }) as unknown as { invokeQuery: (args: string) => Promise<string> };
1068
- return enter(execution, () =>
1069
- q.invokeQuery(JSON.stringify(convexToJson([parseArgs(args)]))),
1070
- )
1071
- .then((raw) => settled(execution, raw))
1072
- .then((raw) => {
1073
- const result = jsonToConvex(JSON.parse(raw));
1074
- if (!isNested && hasPendingValues(result)) {
1075
- throw new Error(
1076
- `Function ${functionPath.udfPath} return value invalid: queries ` +
1077
- `cannot return an unresolved placeholder value.`,
1078
- );
1079
- }
1080
- validateReturnValue(resolved.returns, result, "query", functionPath);
1081
- return result;
1082
- })
1083
- .catch((error: unknown) => {
1084
- throw deserializeConvexErrorData(error);
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
- validateReturnValue(resolved.returns, result, "mutation", {
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
- const parentAttempt = from.parent?.attempt;
1118
- if (parentAttempt !== undefined) {
1119
- const frame = parentAttempt.child();
1120
- const execution = this.execution(
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
- "mutation",
1123
- frame,
1554
+ resolved,
1555
+ args,
1124
1556
  from,
1125
- undefined,
1126
- );
1127
- // As at the top level, a nested mutation ends once its function has
1128
- // returned and every syscall it started has settled. Convex runs a
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
- const attempt = new KernelAttempt(this.state, number);
1171
- const execution = this.execution(
1172
- functionPath,
1173
- "mutation",
1174
- attempt,
1175
- from,
1176
- undefined,
1177
- );
1178
- const run = () => this.invokeMutation(execution, resolved, args);
1179
- const started =
1180
- bound === undefined
1181
- ? run()
1182
- : Invocation.bind({ ...bound, attempt: number }, run);
1183
- return started
1184
- .then(
1185
- (value) =>
1186
- attempt.drained().then((): Outcome => ({ _tag: "returned", value })),
1187
- (error: unknown): Outcome => ({
1188
- _tag: "failed",
1189
- error: deserializeConvexErrorData(error),
1190
- }),
1191
- )
1192
- .then((outcome) => {
1193
- attempt.close();
1194
- const held: Held = {
1195
- view: {
1196
- id: `attempt-${this.nextAttemptId++}`,
1197
- request: from.request.requestId,
1198
- number,
1199
- snapshot: attempt.snapshot.version,
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
- resolved,
1208
- args,
1209
- from,
1210
- next,
1211
- bound,
1212
- ),
1213
- };
1214
- this.holding.set(held.view.id, held);
1215
- return held;
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
- const execution = this.execution(
1300
- functionPath,
1301
- "action",
1302
- undefined,
1303
- from,
1304
- undefined,
1305
- );
1306
- const inner: From = { request: from.request, parent: execution };
1307
- const a = actionGeneric({
1308
- handler: (ctx: any, innerArgs: any) =>
1309
- resolved.handler(
1310
- {
1311
- ...ctx,
1312
- runQuery: (reference: any, queryArgs: any) =>
1313
- this.queryAt(
1314
- pathOfReference(reference, functionPath.componentPath),
1315
- queryArgs,
1316
- inner,
1317
- ),
1318
- runMutation: (reference: any, mutationArgs: any) =>
1319
- this.mutationAt(
1320
- pathOfReference(reference, functionPath.componentPath),
1321
- mutationArgs,
1322
- inner,
1323
- ),
1324
- runAction: (reference: any, actionArgs: any) =>
1325
- this.actionAt(
1326
- pathOfReference(reference, functionPath.componentPath),
1327
- actionArgs,
1328
- inner,
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
- auth: queryAuth(execution),
1331
- },
1332
- innerArgs,
1333
- ),
1334
- }) as unknown as {
1335
- invokeAction: (requestId: string, args: string) => Promise<string>;
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
- const run = () =>
1338
- enter(execution, () =>
1339
- a.invokeAction(
1340
- from.request.requestId,
1341
- JSON.stringify(convexToJson([parseArgs(args)])),
1342
- ),
1343
- );
1344
- // An action another function calls is an invocation of its own, with its
1345
- // world's services, a start time read from its world's clock and its
1346
- // world's random stream, as a scheduled job is.
1347
- const bound = from.parent === undefined ? undefined : Invocation.current();
1348
- const started =
1349
- bound === undefined
1350
- ? run()
1351
- : Invocation.bind(
1352
- Invocation.fresh(
1353
- bound.world,
1354
- functionPath.componentPath,
1355
- from.request,
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
- run,
1358
- );
1359
- return started
1360
- .then((raw) => {
1361
- const result = jsonToConvex(JSON.parse(raw));
1362
- validateReturnValue(resolved.returns, result, "action", functionPath);
1363
- return result;
1364
- })
1365
- .catch((error: unknown) => {
1366
- throw deserializeConvexErrorData(error);
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(execution.componentPath),
1380
- scan: (tableName) => attempt.scan(execution.componentPath, tableName),
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: execution.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 = { request: execution.request, parent: execution };
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.componentPath, args.id),
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
- throw KernelRefusal.rowPending("transaction limits", 6);
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
- const attempt = transaction(execution);
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
- attempt.write(execution.componentPath, _id, {
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 attempt = transaction(execution);
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
- transaction(execution).write(execution.componentPath, _id, {
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
- KernelQuery.validateDocument(
1752
- this.schema(execution.componentPath),
2470
+ this.keepRow(
2471
+ execution.componentPath,
1753
2472
  tableNameFromId(document._id)!,
1754
2473
  fields,
1755
2474
  );
1756
- transaction(execution).write(execution.componentPath, document._id, {
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
- transaction(execution).write(execution.componentPath, document._id, null);
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
- | { readonly parent: Execution; readonly request: Request };
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({