@gaganref/confect-test 10.3.1-fork.89 → 10.3.1-fork.90

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/dist/TestConfect.d.ts +33 -7
  2. package/dist/TestConfect.d.ts.map +1 -1
  3. package/dist/TestConfect.js +11 -6
  4. package/dist/TestConfect.js.map +1 -1
  5. package/dist/internal/Attempt.js.map +1 -1
  6. package/dist/internal/Clock.d.ts +29 -0
  7. package/dist/internal/Clock.d.ts.map +1 -0
  8. package/dist/internal/Clock.js +66 -0
  9. package/dist/internal/Clock.js.map +1 -0
  10. package/dist/internal/ConvexTest.d.ts +33 -1
  11. package/dist/internal/ConvexTest.d.ts.map +1 -1
  12. package/dist/internal/ConvexTest.js +8 -5
  13. package/dist/internal/ConvexTest.js.map +1 -1
  14. package/dist/internal/Execution.d.ts +85 -5
  15. package/dist/internal/Execution.d.ts.map +1 -1
  16. package/dist/internal/Execution.js +285 -51
  17. package/dist/internal/Execution.js.map +1 -1
  18. package/dist/internal/Globals.d.ts.map +1 -1
  19. package/dist/internal/Globals.js +12 -0
  20. package/dist/internal/Globals.js.map +1 -1
  21. package/dist/internal/Invocation.d.ts +11 -0
  22. package/dist/internal/Invocation.d.ts.map +1 -1
  23. package/dist/internal/Invocation.js +34 -21
  24. package/dist/internal/Invocation.js.map +1 -1
  25. package/dist/internal/Kernel.d.ts +3 -1
  26. package/dist/internal/Kernel.d.ts.map +1 -1
  27. package/dist/internal/Kernel.js +2 -2
  28. package/dist/internal/Kernel.js.map +1 -1
  29. package/dist/internal/KernelRefusal.d.ts +25 -0
  30. package/dist/internal/KernelRefusal.d.ts.map +1 -1
  31. package/dist/internal/KernelRefusal.js +22 -1
  32. package/dist/internal/KernelRefusal.js.map +1 -1
  33. package/dist/internal/KernelValues.d.ts +6 -4
  34. package/dist/internal/KernelValues.d.ts.map +1 -1
  35. package/dist/internal/KernelValues.js +20 -5
  36. package/dist/internal/KernelValues.js.map +1 -1
  37. package/dist/internal/MountedHttp.d.ts.map +1 -1
  38. package/dist/internal/MountedHttp.js +3 -3
  39. package/dist/internal/MountedHttp.js.map +1 -1
  40. package/dist/internal/Scheduling.d.ts +71 -0
  41. package/dist/internal/Scheduling.d.ts.map +1 -0
  42. package/dist/internal/Scheduling.js +60 -0
  43. package/dist/internal/Scheduling.js.map +1 -0
  44. package/package.json +3 -3
  45. package/src/TestConfect.ts +62 -15
  46. package/src/internal/Attempt.ts +1 -1
  47. package/src/internal/Clock.ts +95 -0
  48. package/src/internal/ConvexTest.ts +51 -6
  49. package/src/internal/Execution.ts +415 -50
  50. package/src/internal/Globals.ts +20 -0
  51. package/src/internal/Invocation.ts +38 -13
  52. package/src/internal/Kernel.ts +5 -5
  53. package/src/internal/KernelRefusal.ts +24 -0
  54. package/src/internal/KernelValues.ts +44 -4
  55. package/src/internal/MountedHttp.ts +13 -6
  56. package/src/internal/Scheduling.ts +126 -0
@@ -1,6 +1,7 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
2
  import type { FunctionMetadata, RequestMetadata } from "convex/server";
3
3
  import type * as Context from "effect/Context";
4
+ import * as Clock from "./Clock.js";
4
5
 
5
6
  /**
6
7
  * Four re-executions after the first try. A conflict on attempt 5 exhausts.
@@ -78,6 +79,25 @@ interface Bridge {
78
79
 
79
80
  const key = Symbol.for("@confect/test/Invocation");
80
81
 
82
+ /**
83
+ * A fresh invocation of `world` for a request of its own, as an action or a
84
+ * scheduled job starts: attempt 1, the world's services and random stream, and
85
+ * a start time read from the world's clock.
86
+ */
87
+ export const fresh = (
88
+ world: World,
89
+ componentPath: string,
90
+ { ip, userAgent, requestId, scheduledFunctionId }: Invocation["request"],
91
+ ): Invocation => ({
92
+ world,
93
+ componentPath,
94
+ attempt: 1,
95
+ request: { ip, userAgent, requestId, scheduledFunctionId },
96
+ services: world.services,
97
+ startedAt: world.clock(),
98
+ random: world.random,
99
+ });
100
+
81
101
  /**
82
102
  * The invocation a function of `functionType` called from `running` joins. A
83
103
  * query or mutation an action calls is a transaction of its own, so it starts
@@ -105,16 +125,7 @@ const resolve = (
105
125
  request !== undefined &&
106
126
  request.scheduledFunctionId !== bound.request.scheduledFunctionId
107
127
  ) {
108
- const { ip, userAgent, requestId, scheduledFunctionId } = request;
109
- return {
110
- world: bound.world,
111
- componentPath,
112
- attempt: 1,
113
- request: { ip, userAgent, requestId, scheduledFunctionId },
114
- services: bound.world.services,
115
- startedAt: bound.world.clock(),
116
- random: bound.world.random,
117
- };
128
+ return fresh(bound.world, componentPath, request);
118
129
  }
119
130
  return componentPath === bound.componentPath
120
131
  ? bound
@@ -202,13 +213,18 @@ const pin = (
202
213
  ): void => {
203
214
  const host = globalThis;
204
215
  const previous = { Date: host.Date, Math: host.Math, crypto: host.crypto };
216
+ // On the kernel, `Date` already answers the running function's time on the
217
+ // kernel's clock, so `runAfter` schedules on it. The bridge leaves it there.
218
+ const kernelDate = Clock.answersInvocation(previous.Date);
205
219
  const pinned = {
206
- Date: pinnedDate(previous.Date, clockOf(invocation, functionType)),
220
+ Date: kernelDate
221
+ ? previous.Date
222
+ : pinnedDate(previous.Date, clockOf(invocation, functionType)),
207
223
  Math: drawingMath(previous.Math, invocation.random),
208
224
  crypto: drawingCrypto(previous.crypto, invocation.random),
209
225
  };
210
226
  try {
211
- host.Date = pinned.Date;
227
+ if (!kernelDate) host.Date = pinned.Date;
212
228
  host.Math = pinned.Math;
213
229
  host.crypto = pinned.crypto;
214
230
  } catch {
@@ -216,7 +232,7 @@ const pin = (
216
232
  // refuses the handler.
217
233
  }
218
234
  const reached = outside(() => ({
219
- Date: host.Date === pinned.Date,
235
+ Date: !kernelDate && host.Date === pinned.Date,
220
236
  Math: host.Math === pinned.Math,
221
237
  crypto: host.crypto === pinned.crypto,
222
238
  }));
@@ -284,6 +300,15 @@ export const bind = <A>(
284
300
  start,
285
301
  );
286
302
 
303
+ /**
304
+ * Run `start` with no invocation bound, so every registered handler it reaches
305
+ * keeps its production runtime, whatever the caller has bound.
306
+ */
307
+ export const unbound = <A>(start: () => Promise<A>): Promise<A> => {
308
+ const bridge = installed[key];
309
+ return bridge === undefined ? start() : bridge.storage.exit(start);
310
+ };
311
+
287
312
  /**
288
313
  * The invocation the calling handler runs under, if one is bound.
289
314
  */
@@ -1,4 +1,4 @@
1
- import * as Globals from "./Globals.js";
1
+ import * as Clock from "./Clock.js";
2
2
  import type { DocumentId, StoredDocument } from "./KernelValues.js";
3
3
  import { resolveCommittedValues, tableNameFromId } from "./KernelValues.js";
4
4
  import * as ReadSet from "./ReadSet.js";
@@ -158,7 +158,9 @@ export const conflict = (
158
158
  */
159
159
  export interface KernelRuntime {
160
160
  /**
161
- * The time in milliseconds, for each inserted document's creation time.
161
+ * The time in milliseconds. The kernel's clock never reads earlier than the
162
+ * latest deadline `advance` reached, so a runtime that stands still still
163
+ * lets jobs run on time.
162
164
  */
163
165
  readonly now: () => number;
164
166
  /**
@@ -176,9 +178,7 @@ export interface KernelRuntime {
176
178
  */
177
179
  export const wallClock = (): KernelRuntime => {
178
180
  let latest = 0n;
179
- // The host's clock, never the copy a bound handler pinned.
180
- // oxlint-disable-next-line effecttsgo/global-date -- The default runtime is the wall clock. A driver that replays supplies its own.
181
- const now = () => Globals.host(() => Date.now());
181
+ const now = Clock.hostNow;
182
182
  return {
183
183
  now,
184
184
  commitTs: () => {
@@ -74,6 +74,30 @@ export class CommitTsNotIncreasing extends Schema.TaggedError<CommitTsNotIncreas
74
74
  },
75
75
  ) {}
76
76
 
77
+ /**
78
+ * Draining scheduled functions ran its budget of jobs and work still remained.
79
+ * A self-rescheduling function keeps the drain from ending unless its kit
80
+ * declares it standing.
81
+ */
82
+ export class DrainBudgetExceeded extends Schema.TaggedError<DrainBudgetExceeded>()(
83
+ "DrainBudgetExceeded",
84
+ {
85
+ budget: Schema.Int,
86
+ pending: Schema.Array(
87
+ Schema.Struct({ componentPath: Schema.String, name: Schema.String }),
88
+ ),
89
+ message: Schema.String,
90
+ },
91
+ ) {}
92
+
93
+ /**
94
+ * A component declared a standing source its modules do not export.
95
+ */
96
+ export class UnknownStandingSource extends Schema.TaggedError<UnknownStandingSource>()(
97
+ "UnknownStandingSource",
98
+ { componentPath: Schema.String, name: Schema.String, message: Schema.String },
99
+ ) {}
100
+
77
101
  export const requiresBinary = (operation: RequiresBinary["operation"]) =>
78
102
  new RequiresBinary({
79
103
  operation,
@@ -419,6 +419,12 @@ export type ValidatorJSON =
419
419
  | { type: "array"; value: ValidatorJSON }
420
420
  | { type: "object"; value: Record<string, ObjectFieldType> }
421
421
  | { type: "record"; keys: ValidatorJSON; values: ObjectFieldType }
422
+ | {
423
+ type: "function";
424
+ fnType?: string;
425
+ args?: ValidatorJSON;
426
+ returns?: ValidatorJSON;
427
+ }
422
428
  | { type: "union"; value: ValidatorJSON[] };
423
429
 
424
430
  export function validateValidator(validator: ValidatorJSON, value: any) {
@@ -568,6 +574,24 @@ export function validateValidator(validator: ValidatorJSON, value: any) {
568
574
  }
569
575
  return;
570
576
  }
577
+ case "function": {
578
+ // The pinned binary, `precompiled-2026-10-28-cccbade`, accepts a string
579
+ // that parses as a function handle
580
+ // (`common/src/schemas/validator.rs:156-164`), and its parser requires a
581
+ // document id after the prefix
582
+ // (`common/src/bootstrap_model/components/handles.rs:100-112`). The
583
+ // kernel mints convex-test's handle format instead, so it accepts a
584
+ // string that parses as that format.
585
+ if (
586
+ typeof value !== "string" ||
587
+ readFunctionHandle(value) === undefined
588
+ ) {
589
+ throw new Error(
590
+ `Validator error: Expected a function handle, got \`${value}\``,
591
+ );
592
+ }
593
+ return;
594
+ }
571
595
  case "record": {
572
596
  if (
573
597
  value === null ||
@@ -706,13 +730,29 @@ export function validateReturnValue(
706
730
  }
707
731
  }
708
732
 
733
+ const FUNCTION_HANDLE_PREFIX = "function://";
734
+
709
735
  export function createFunctionHandle(functionPath: FunctionPath) {
710
- return `function://${functionPath.componentPath};${functionPath.udfPath}`;
736
+ return `${FUNCTION_HANDLE_PREFIX}${functionPath.componentPath};${functionPath.udfPath}`;
711
737
  }
712
738
 
713
- export function parseFunctionHandle(handle: string) {
714
- const [componentPath, udfPath] = handle.split("function://")[1].split(";");
715
- return { componentPath, udfPath };
739
+ /**
740
+ * The function a handle the kernel minted names, or `undefined` for a string
741
+ * that is not `function://<componentPath>;<udfPath>` with a function path.
742
+ */
743
+ function readFunctionHandle(handle: string): FunctionPath | undefined {
744
+ if (!handle.startsWith(FUNCTION_HANDLE_PREFIX)) return undefined;
745
+ const parts = handle.slice(FUNCTION_HANDLE_PREFIX.length).split(";");
746
+ if (parts.length !== 2 || parts[1] === "") return undefined;
747
+ return { componentPath: parts[0]!, udfPath: parts[1]! };
748
+ }
749
+
750
+ export function parseFunctionHandle(handle: string): FunctionPath {
751
+ const functionPath = readFunctionHandle(handle);
752
+ if (functionPath === undefined) {
753
+ throw new Error(`Invalid function handle \`${handle}\``);
754
+ }
755
+ return functionPath;
716
756
  }
717
757
  export function simpleHash(string: string) {
718
758
  let hash = 0;
@@ -102,11 +102,16 @@ export const make = (
102
102
  const registered = new Set<string>();
103
103
  let tail = Promise.resolve();
104
104
 
105
- const register: RegisterComponent = (componentPath, schema, modules) => {
105
+ const register: RegisterComponent = (
106
+ componentPath,
107
+ schema,
108
+ modules,
109
+ options,
110
+ ) => {
106
111
  if (
107
112
  !normalized.success.some((mount) => mount.componentPath === componentPath)
108
113
  ) {
109
- registerComponent(componentPath, schema, modules);
114
+ registerComponent(componentPath, schema, modules, options);
110
115
  return;
111
116
  }
112
117
  const generated = Object.keys(modules).find((key) =>
@@ -188,10 +193,12 @@ export const make = (
188
193
  }
189
194
  },
190
195
  });
191
- registerComponent(componentPath, schema, {
192
- ...modules,
193
- [bridgePath]: () => Promise.resolve({ invoke }),
194
- });
196
+ registerComponent(
197
+ componentPath,
198
+ schema,
199
+ { ...modules, [bridgePath]: () => Promise.resolve({ invoke }) },
200
+ options,
201
+ );
195
202
  registered.add(componentPath);
196
203
  };
197
204
 
@@ -0,0 +1,126 @@
1
+ import type { Value } from "convex/values";
2
+ import type * as Invocation from "./Invocation.js";
3
+ import type * as Kernel from "./Kernel.js";
4
+ import type {
5
+ DocumentId,
6
+ FunctionPath,
7
+ StoredDocument,
8
+ } from "./KernelValues.js";
9
+
10
+ /**
11
+ * Convex's system table of scheduled functions. Each component has its own.
12
+ */
13
+ export const table = "_scheduled_functions";
14
+
15
+ export type JobState =
16
+ | { readonly kind: "pending" }
17
+ | { readonly kind: "inProgress" }
18
+ | { readonly kind: "success" }
19
+ | { readonly kind: "failed"; readonly error: string }
20
+ | { readonly kind: "canceled" };
21
+
22
+ /**
23
+ * What the kernel keeps beside a job's row: the function it calls, which a
24
+ * handle may place in another component, whether a standing source owns the
25
+ * job, and the test world whose invocation scheduled it. The row holds only
26
+ * what Convex's system table holds.
27
+ */
28
+ export interface Target {
29
+ readonly functionPath: FunctionPath;
30
+ readonly standing: boolean;
31
+ /**
32
+ * The job runs as a fresh invocation of this world, with its services, or
33
+ * unbound when no invocation was bound as it was scheduled.
34
+ */
35
+ readonly world: Invocation.World | undefined;
36
+ }
37
+
38
+ /**
39
+ * A pending job as a driver sees it.
40
+ */
41
+ export interface Job {
42
+ readonly id: DocumentId;
43
+ readonly componentPath: string;
44
+ readonly name: string;
45
+ readonly scheduledTime: number;
46
+ /**
47
+ * A standing job is never drain work. Time reaches it through `advance`.
48
+ */
49
+ readonly standing: boolean;
50
+ }
51
+
52
+ /**
53
+ * A pending job with what running it needs.
54
+ */
55
+ export interface Due extends Job {
56
+ readonly target: FunctionPath;
57
+ readonly args: Value;
58
+ readonly world: Invocation.World | undefined;
59
+ }
60
+
61
+ export const row = (
62
+ id: DocumentId,
63
+ creationTime: number,
64
+ name: string,
65
+ args: Value,
66
+ scheduledTime: number,
67
+ ): StoredDocument => ({
68
+ _id: id,
69
+ _creationTime: creationTime,
70
+ name,
71
+ args: [args],
72
+ scheduledTime,
73
+ state: { kind: "pending" },
74
+ });
75
+
76
+ export const stateOf = (doc: StoredDocument): JobState =>
77
+ doc["state"] as JobState;
78
+
79
+ const byDeadline = (a: Due, b: Due) =>
80
+ a.scheduledTime - b.scheduledTime || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
81
+
82
+ /**
83
+ * Every committed pending job of every component, earliest deadline first. Jobs
84
+ * that share a deadline run in the order they were scheduled.
85
+ */
86
+ export const pending = (
87
+ state: Kernel.State,
88
+ targets: ReadonlyMap<DocumentId, Target>,
89
+ ): Array<Due> => {
90
+ const due: Array<Due> = [];
91
+ for (const [componentPath, tables] of state.tables) {
92
+ for (const { doc } of tables.get(table)?.values() ?? []) {
93
+ if (stateOf(doc).kind !== "pending") continue;
94
+ const name = doc["name"] as string;
95
+ const target = targets.get(doc._id) ?? {
96
+ functionPath: { componentPath, udfPath: name },
97
+ standing: false,
98
+ world: undefined,
99
+ };
100
+ due.push({
101
+ id: doc._id,
102
+ componentPath,
103
+ name,
104
+ scheduledTime: doc["scheduledTime"] as number,
105
+ standing: target.standing,
106
+ target: target.functionPath,
107
+ args: (doc["args"] as ReadonlyArray<Value>)[0]!,
108
+ world: target.world,
109
+ });
110
+ }
111
+ }
112
+ return due.sort(byDeadline);
113
+ };
114
+
115
+ /**
116
+ * The distinct functions of `jobs`, for a refusal that names what is left.
117
+ */
118
+ export const functions = (
119
+ jobs: ReadonlyArray<Job>,
120
+ ): Array<{ readonly componentPath: string; readonly name: string }> => {
121
+ const seen = new Map<string, { componentPath: string; name: string }>();
122
+ for (const { componentPath, name } of jobs) {
123
+ seen.set(`${componentPath}\u0000${name}`, { componentPath, name });
124
+ }
125
+ return [...seen.values()];
126
+ };