@gaganref/confect-test 10.3.1-fork.92 → 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 (36) 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/ConvexTest.d.ts +26 -1
  6. package/dist/internal/ConvexTest.d.ts.map +1 -1
  7. package/dist/internal/ConvexTest.js +55 -27
  8. package/dist/internal/ConvexTest.js.map +1 -1
  9. package/dist/internal/Execution.d.ts +138 -1
  10. package/dist/internal/Execution.d.ts.map +1 -1
  11. package/dist/internal/Execution.js +595 -182
  12. package/dist/internal/Execution.js.map +1 -1
  13. package/dist/internal/KernelRefusal.d.ts +134 -0
  14. package/dist/internal/KernelRefusal.d.ts.map +1 -1
  15. package/dist/internal/KernelRefusal.js +134 -1
  16. package/dist/internal/KernelRefusal.js.map +1 -1
  17. package/dist/internal/Redaction.d.ts +142 -0
  18. package/dist/internal/Redaction.d.ts.map +1 -0
  19. package/dist/internal/Redaction.js +448 -0
  20. package/dist/internal/Redaction.js.map +1 -0
  21. package/dist/internal/Snapshot.d.ts +298 -0
  22. package/dist/internal/Snapshot.d.ts.map +1 -0
  23. package/dist/internal/Snapshot.js +532 -0
  24. package/dist/internal/Snapshot.js.map +1 -0
  25. package/dist/internal/Subscriptions.d.ts +13 -0
  26. package/dist/internal/Subscriptions.d.ts.map +1 -1
  27. package/dist/internal/Subscriptions.js +27 -3
  28. package/dist/internal/Subscriptions.js.map +1 -1
  29. package/package.json +3 -3
  30. package/src/TestConfect.ts +63 -4
  31. package/src/internal/ConvexTest.ts +135 -38
  32. package/src/internal/Execution.ts +886 -287
  33. package/src/internal/KernelRefusal.ts +160 -0
  34. package/src/internal/Redaction.ts +730 -0
  35. package/src/internal/Snapshot.ts +945 -0
  36. package/src/internal/Subscriptions.ts +34 -4
@@ -16,6 +16,7 @@ import {
16
16
  jsonToConvex,
17
17
  type Value,
18
18
  } from "convex/values";
19
+ import * as Result from "effect/Result";
19
20
  import { KernelAttempt } from "./Attempt.js";
20
21
  import * as Clock from "./Clock.js";
21
22
  import * as Globals from "./Globals.js";
@@ -25,7 +26,9 @@ import * as KernelQuery from "./KernelQuery.js";
25
26
  import * as KernelRefusal from "./KernelRefusal.js";
26
27
  import * as Metering from "./Metering.js";
27
28
  import type * as ReadSet from "./ReadSet.js";
29
+ import * as Redaction from "./Redaction.js";
28
30
  import * as Scheduling from "./Scheduling.js";
31
+ import * as Snapshot from "./Snapshot.js";
29
32
  import type {
30
33
  DocumentId,
31
34
  FunctionPath,
@@ -119,6 +122,11 @@ export type Outcome =
119
122
  */
120
123
  export interface HeldAttempt {
121
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;
122
130
  readonly request: string;
123
131
  readonly number: Invocation.Attempt;
124
132
  readonly snapshot: Kernel.Version;
@@ -298,6 +306,39 @@ const pinnedTime = (): number | undefined => {
298
306
  */
299
307
  const defaultBudget = 100;
300
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
+
301
342
  const current = (): Execution => {
302
343
  const execution = executions.getStore();
303
344
  if (execution === undefined) {
@@ -480,11 +521,34 @@ export class World {
480
521
  */
481
522
  private floor = Number.NEGATIVE_INFINITY;
482
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;
483
546
 
484
547
  constructor(
485
548
  schema: SchemaDefinition<GenericSchema, boolean> | null | undefined,
486
549
  modules: Modules,
487
550
  private readonly runtime: Kernel.KernelRuntime = Kernel.wallClock(),
551
+ private readonly redaction: Redaction.Policy = Redaction.none,
488
552
  ) {
489
553
  installConvexGlobal();
490
554
  Clock.install(pinnedTime, () => executions.getStore() !== undefined);
@@ -600,44 +664,50 @@ export class World {
600
664
  // Calls from the test.
601
665
 
602
666
  query(caller: Caller, reference: any, args: any): Promise<any> {
603
- return this.open().then(() => {
604
- const from = { caller, request: newRequest(caller) };
605
- return typeof reference === "function"
606
- ? this.runQuery(
607
- { componentPath: ROOT, udfPath: "inline" },
608
- { handler: reference, args: { type: "any" }, returns: null },
609
- args ?? {},
610
- from,
611
- )
612
- : 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
+ });
613
679
  });
614
680
  }
615
681
 
616
682
  mutation(caller: Caller, reference: any, args: any): Promise<any> {
617
- return this.open().then(() => {
618
- const from = { caller, request: newRequest(caller) };
619
- return typeof reference === "function"
620
- ? this.runMutation(
621
- { componentPath: ROOT, udfPath: "inline" },
622
- { handler: reference, args: { type: "any" }, returns: null },
623
- args ?? {},
624
- from,
625
- )
626
- : 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
+ });
627
695
  });
628
696
  }
629
697
 
630
698
  action(caller: Caller, reference: any, args: any): Promise<any> {
631
- return this.open().then(() => {
632
- const from = { caller, request: newRequest(caller) };
633
- return typeof reference === "function"
634
- ? this.runAction(
635
- { componentPath: ROOT, udfPath: "inline" },
636
- { handler: reference, args: { type: "any" }, returns: null },
637
- args ?? {},
638
- from,
639
- )
640
- : 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
+ });
641
711
  });
642
712
  }
643
713
 
@@ -646,14 +716,16 @@ export class World {
646
716
  componentPath: string,
647
717
  handler: (ctx: any) => any,
648
718
  ): Promise<any> {
649
- return this.open().then(() => {
650
- this.component(componentPath);
651
- return this.runMutation(
652
- { componentPath, udfPath: "custom" },
653
- { handler, args: { type: "any" }, returns: null },
654
- {},
655
- { caller, request: newRequest(caller) },
656
- );
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
+ });
657
729
  });
658
730
  }
659
731
 
@@ -663,31 +735,36 @@ export class World {
663
735
  * Run a mutation to its commit point and hold it as attempt 1.
664
736
  */
665
737
  prepare(caller: Caller, reference: any, args: any): Promise<HeldAttempt> {
666
- return this.open().then(() => {
667
- const functionPath = pathOfReference(reference, ROOT);
668
- const resolved = this.resolve(functionPath, "mutation");
669
- validateFunctionArgs(resolved.args, args ?? {}, functionPath, false);
670
- return this.prepareMutation(
671
- functionPath,
672
- resolved,
673
- args,
674
- { caller, request: newRequest(caller) },
675
- 1,
676
- Invocation.current(),
677
- ).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
+ });
678
753
  });
679
754
  }
680
755
 
681
756
  apply(id: string): Promise<KernelApply> {
682
- const held = this.holding.get(id);
683
- return held === undefined
684
- ? Promise.reject(
685
- new KernelRefusal.StaleAttempt({
686
- id,
687
- message: `The test kernel holds no attempt "${id}". It was already applied, or never prepared.`,
688
- }),
689
- )
690
- : 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
+ });
691
768
  }
692
769
 
693
770
  /**
@@ -711,37 +788,308 @@ export class World {
711
788
  args: any,
712
789
  pinned: { readonly time?: number; readonly journal: string | null },
713
790
  ): Promise<QueryRun> {
714
- return this.open().then(() => {
715
- const functionPath = pathOfReference(reference, ROOT);
716
- const resolved = this.resolve(functionPath, "query");
717
- validateFunctionArgs(resolved.args, args ?? {}, functionPath, false);
718
- const top: TopQuery = {
719
- attempt: new KernelAttempt(this.state),
720
- pagination: { calls: 0, journal: pinned.journal, end: null },
721
- startTime: pinned.time,
722
- };
723
- const run = (outcome: Outcome): QueryRun => ({
724
- outcome,
725
- snapshot: top.attempt.snapshot.version,
726
- reads: top.attempt.reads,
727
- observedIdentity: top.attempt.observedIdentity,
728
- time: top.attempt.observedTime,
729
- 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
+ );
730
821
  });
731
- return this.runQuery(
732
- functionPath,
733
- resolved,
734
- args,
735
- { caller, request: newRequest(caller) },
736
- false,
737
- top,
738
- ).then(
739
- (value: Value) => run({ _tag: "returned", value }),
740
- (error: unknown) => run({ _tag: "failed", error }),
741
- );
742
822
  });
743
823
  }
744
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
+
745
1093
  // Scheduled functions, on the kernel's clock.
746
1094
 
747
1095
  /**
@@ -766,12 +1114,14 @@ export class World {
766
1114
  * crosses no future deadline.
767
1115
  */
768
1116
  advance(ms: number, budget = defaultBudget): Promise<void> {
769
- return this.open().then(() => {
770
- const until = this.now() + ms;
771
- return this.walk(
772
- (pending) => pending.filter((job) => job.scheduledTime <= until),
773
- budget,
774
- ).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
+ });
775
1125
  });
776
1126
  }
777
1127
 
@@ -780,9 +1130,11 @@ export class World {
780
1130
  * standing jobs are pending. Every job run counts against `budget`.
781
1131
  */
782
1132
  drain(budget = defaultBudget): Promise<void> {
783
- return this.open().then(() =>
784
- this.walk((pending) => pending.filter((job) => !job.standing), budget),
785
- );
1133
+ return this.track("walks", () => {
1134
+ return this.open().then(() =>
1135
+ this.walk((pending) => pending.filter((job) => !job.standing), budget),
1136
+ );
1137
+ });
786
1138
  }
787
1139
 
788
1140
  /**
@@ -923,6 +1275,7 @@ export class World {
923
1275
  const target = functionPathOf(args, execution.componentPath);
924
1276
  this.component(target.componentPath);
925
1277
  const jobArgs = jsonToConvex(args.args);
1278
+ this.keepArgs(target, jobArgs);
926
1279
  if (hasPendingValues(jobArgs)) {
927
1280
  throw new Error(
928
1281
  `Invalid arguments for "${target.udfPath}": a scheduled function cannot take an unresolved placeholder value.`,
@@ -1000,6 +1353,7 @@ export class World {
1000
1353
  snapshotOnly = false,
1001
1354
  ) {
1002
1355
  const resolved = this.resolve(functionPath, "query");
1356
+ this.keepArgs(functionPath, args);
1003
1357
  validateFunctionArgs(
1004
1358
  resolved.args,
1005
1359
  args ?? {},
@@ -1011,6 +1365,7 @@ export class World {
1011
1365
 
1012
1366
  private mutationAt(functionPath: FunctionPath, args: any, from: From) {
1013
1367
  const resolved = this.resolve(functionPath, "mutation");
1368
+ this.keepArgs(functionPath, args);
1014
1369
  validateFunctionArgs(
1015
1370
  resolved.args,
1016
1371
  args ?? {},
@@ -1022,6 +1377,7 @@ export class World {
1022
1377
 
1023
1378
  private actionAt(functionPath: FunctionPath, args: any, from: From) {
1024
1379
  const resolved = this.resolve(functionPath, "action");
1380
+ this.keepArgs(functionPath, args);
1025
1381
  validateFunctionArgs(resolved.args, args ?? {}, functionPath, false);
1026
1382
  return this.runAction(functionPath, resolved, args, from);
1027
1383
  }
@@ -1084,50 +1440,53 @@ export class World {
1084
1440
  snapshotOnly = false,
1085
1441
  top?: TopQuery,
1086
1442
  ): Promise<any> {
1087
- const parentAttempt = from.parent?.attempt;
1088
- const isNested = parentAttempt !== undefined;
1089
- const attempt =
1090
- top?.attempt ??
1091
- (parentAttempt === undefined
1092
- ? new KernelAttempt(this.state)
1093
- : snapshotOnly
1094
- ? parentAttempt.staleSnapshot()
1095
- : parentAttempt);
1096
- const execution = this.execution(
1097
- functionPath,
1098
- "query",
1099
- attempt,
1100
- from,
1101
- top?.pagination ??
1102
- ((isNested && from.parent?.pagination) || {
1103
- calls: 0,
1104
- journal: null,
1105
- end: null,
1106
- }),
1107
- top?.startTime,
1108
- );
1109
- const q = queryGeneric({
1110
- handler: (ctx: any, a: any) =>
1111
- resolved.handler({ ...ctx, auth: queryAuth(execution) }, a),
1112
- }) as unknown as { invokeQuery: (args: string) => Promise<string> };
1113
- return enter(execution, () =>
1114
- q.invokeQuery(JSON.stringify(convexToJson([parseArgs(args)]))),
1115
- )
1116
- .then((raw) => settled(execution, raw))
1117
- .then((raw) => {
1118
- const result = jsonToConvex(JSON.parse(raw));
1119
- if (!isNested && hasPendingValues(result)) {
1120
- throw new Error(
1121
- `Function ${functionPath.udfPath} return value invalid: queries ` +
1122
- `cannot return an unresolved placeholder value.`,
1123
- );
1124
- }
1125
- validateReturnValue(resolved.returns, result, "query", functionPath);
1126
- return result;
1127
- })
1128
- .catch((error: unknown) => {
1129
- throw deserializeConvexErrorData(error);
1130
- });
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
+ });
1131
1490
  }
1132
1491
 
1133
1492
  private invokeMutation(
@@ -1145,10 +1504,12 @@ export class World {
1145
1504
  .then((raw) => settled(execution, raw))
1146
1505
  .then((raw) => {
1147
1506
  const result = jsonToConvex(JSON.parse(raw));
1148
- validateReturnValue(resolved.returns, result, "mutation", {
1507
+ const functionPath = {
1149
1508
  componentPath: execution.componentPath,
1150
1509
  udfPath: execution.udfPath,
1151
- });
1510
+ };
1511
+ this.keepReturns(functionPath, result);
1512
+ validateReturnValue(resolved.returns, result, "mutation", functionPath);
1152
1513
  return result;
1153
1514
  });
1154
1515
  }
@@ -1159,42 +1520,44 @@ export class World {
1159
1520
  args: any,
1160
1521
  from: From,
1161
1522
  ): Promise<any> {
1162
- const parentAttempt = from.parent?.attempt;
1163
- if (parentAttempt !== undefined) {
1164
- const frame = parentAttempt.child();
1165
- 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(
1166
1553
  functionPath,
1167
- "mutation",
1168
- frame,
1554
+ resolved,
1555
+ args,
1169
1556
  from,
1170
- undefined,
1171
- );
1172
- // As at the top level, a nested mutation ends once its function has
1173
- // returned and every syscall it started has settled. Convex runs a
1174
- // nested function's syscall queue to the end before it commits its
1175
- // subtransaction, so a detached fiber's late write lands in this frame
1176
- // and merges with it. A failed one ends at once and rolls back.
1177
- return this.invokeMutation(execution, resolved, args).then(
1178
- (result) =>
1179
- frame.drained().then(() => {
1180
- frame.close();
1181
- frame.merge();
1182
- return result;
1183
- }),
1184
- (error: unknown) => {
1185
- frame.close();
1186
- throw deserializeConvexErrorData(error);
1187
- },
1188
- );
1189
- }
1190
- return this.prepareMutation(
1191
- functionPath,
1192
- resolved,
1193
- args,
1194
- from,
1195
- 1,
1196
- Invocation.current(),
1197
- ).then((held) => this.publish(held));
1557
+ 1,
1558
+ Invocation.current(),
1559
+ ).then((held) => this.publish(held));
1560
+ });
1198
1561
  }
1199
1562
 
1200
1563
  /**
@@ -1212,53 +1575,58 @@ export class World {
1212
1575
  number: Invocation.Attempt,
1213
1576
  bound: Invocation.Invocation | undefined,
1214
1577
  ): Promise<Held> {
1215
- const attempt = new KernelAttempt(this.state, number);
1216
- const execution = this.execution(
1217
- functionPath,
1218
- "mutation",
1219
- attempt,
1220
- from,
1221
- undefined,
1222
- );
1223
- const run = () => this.invokeMutation(execution, resolved, args);
1224
- const started =
1225
- bound === undefined
1226
- ? run()
1227
- : Invocation.bind({ ...bound, attempt: number }, run);
1228
- return started
1229
- .then(
1230
- (value) =>
1231
- attempt.drained().then((): Outcome => ({ _tag: "returned", value })),
1232
- (error: unknown): Outcome => ({
1233
- _tag: "failed",
1234
- error: deserializeConvexErrorData(error),
1235
- }),
1236
- )
1237
- .then((outcome) => {
1238
- attempt.close();
1239
- const held: Held = {
1240
- view: {
1241
- id: `attempt-${this.nextAttemptId++}`,
1242
- request: from.request.requestId,
1243
- number,
1244
- snapshot: attempt.snapshot.version,
1245
- reads: attempt.reads,
1246
- outcome,
1247
- },
1248
- attempt,
1249
- again: (next) =>
1250
- 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++}`,
1251
1608
  functionPath,
1252
- resolved,
1253
- args,
1254
- from,
1255
- next,
1256
- bound,
1257
- ),
1258
- };
1259
- this.holding.set(held.view.id, held);
1260
- return held;
1261
- });
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
+ });
1262
1630
  }
1263
1631
 
1264
1632
  /**
@@ -1341,75 +1709,299 @@ export class World {
1341
1709
  args: any,
1342
1710
  from: From,
1343
1711
  ): Promise<any> {
1344
- const execution = this.execution(
1345
- functionPath,
1346
- "action",
1347
- undefined,
1348
- from,
1349
- undefined,
1350
- );
1351
- const inner: From = { request: from.request, parent: execution };
1352
- const a = actionGeneric({
1353
- handler: (ctx: any, innerArgs: any) =>
1354
- resolved.handler(
1355
- {
1356
- ...ctx,
1357
- runQuery: (reference: any, queryArgs: any) =>
1358
- this.queryAt(
1359
- pathOfReference(reference, functionPath.componentPath),
1360
- queryArgs,
1361
- inner,
1362
- ),
1363
- runMutation: (reference: any, mutationArgs: any) =>
1364
- this.mutationAt(
1365
- pathOfReference(reference, functionPath.componentPath),
1366
- mutationArgs,
1367
- inner,
1368
- ),
1369
- runAction: (reference: any, actionArgs: any) =>
1370
- this.actionAt(
1371
- pathOfReference(reference, functionPath.componentPath),
1372
- actionArgs,
1373
- 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,
1374
1771
  ),
1375
- auth: queryAuth(execution),
1376
- },
1377
- innerArgs,
1378
- ),
1379
- }) as unknown as {
1380
- 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),
1381
1827
  };
1382
- const run = () =>
1383
- enter(execution, () =>
1384
- a.invokeAction(
1385
- from.request.requestId,
1386
- JSON.stringify(convexToJson([parseArgs(args)])),
1387
- ),
1388
- );
1389
- // An action another function calls is an invocation of its own, with its
1390
- // world's services, a start time read from its world's clock and its
1391
- // world's random stream, as a scheduled job is.
1392
- const bound = from.parent === undefined ? undefined : Invocation.current();
1393
- const started =
1394
- bound === undefined
1395
- ? run()
1396
- : Invocation.bind(
1397
- Invocation.fresh(
1398
- bound.world,
1399
- functionPath.componentPath,
1400
- 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),
1401
1861
  ),
1402
- run,
1403
- );
1404
- return started
1405
- .then((raw) => {
1406
- const result = jsonToConvex(JSON.parse(raw));
1407
- validateReturnValue(resolved.returns, result, "action", functionPath);
1408
- return result;
1409
- })
1410
- .catch((error: unknown) => {
1411
- throw deserializeConvexErrorData(error);
1412
- });
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
+ );
1413
2005
  }
1414
2006
 
1415
2007
  // Syscalls.
@@ -1767,6 +2359,7 @@ export class World {
1767
2359
 
1768
2360
  private insert(execution: Execution, table: string, value: any): DocumentId {
1769
2361
  const attempt = transaction(execution);
2362
+ this.keepRow(execution.componentPath, table, value);
1770
2363
  KernelQuery.validateDocument(
1771
2364
  this.schema(execution.componentPath),
1772
2365
  table,
@@ -1855,6 +2448,7 @@ export class World {
1855
2448
  const document = this.existing(execution, tableName, id, "patch", value);
1856
2449
  const { _id, _creationTime, ...fields } = document;
1857
2450
  const merged = { ...fields, ...this.fields(document, value) };
2451
+ this.keepRow(execution.componentPath, tableNameFromId(_id)!, merged);
1858
2452
  const next = { _id, _creationTime, ...merged } as StoredDocument;
1859
2453
  if (sameDocument(document, next)) return;
1860
2454
  KernelQuery.validateDocument(
@@ -1873,6 +2467,11 @@ export class World {
1873
2467
  ) {
1874
2468
  const document = this.existing(execution, tableName, id, "replace", value);
1875
2469
  const fields = this.fields(document, value);
2470
+ this.keepRow(
2471
+ execution.componentPath,
2472
+ tableNameFromId(document._id)!,
2473
+ fields,
2474
+ );
1876
2475
  const next = {
1877
2476
  ...fields,
1878
2477
  _id: document._id,