@gaganref/confect-test 10.3.1-fork.87 → 10.3.1-fork.88

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.
@@ -0,0 +1,57 @@
1
+ import type { RequestMetadata } from "convex/server";
2
+ import type * as Context from "effect/Context";
3
+ /**
4
+ * Four re-executions after the first try. A conflict on attempt 5 exhausts.
5
+ */
6
+ export type Attempt = 1 | 2 | 3 | 4 | 5;
7
+ /**
8
+ * One test backend. Its `services`, `clock` and `random` reach every invocation
9
+ * it starts on its own, such as a scheduled job.
10
+ */
11
+ export interface World {
12
+ readonly name: string;
13
+ readonly services: Context.Context<never>;
14
+ /**
15
+ * The world's controlled clock in epoch milliseconds. An invocation the world
16
+ * starts on its own pins its start time from one reading. A bound action's
17
+ * `Date` reads it live.
18
+ */
19
+ readonly clock: () => number;
20
+ /**
21
+ * The random stream, uniform on [0, 1), that an invocation the world starts
22
+ * on its own draws from.
23
+ */
24
+ readonly random: () => number;
25
+ }
26
+ export interface Invocation {
27
+ readonly world: World;
28
+ readonly componentPath: string;
29
+ readonly attempt: Attempt;
30
+ readonly request: Omit<RequestMetadata, "authToken">;
31
+ readonly services: Context.Context<never>;
32
+ /**
33
+ * The start time in epoch milliseconds. Every query or mutation of the
34
+ * invocation reads it from `Date.now()` and `new Date()`, so Effect's default
35
+ * `Clock` reads it too. An action reads its world's clock instead, as it
36
+ * runs, and a query or mutation an action calls starts at the action's time
37
+ * then.
38
+ */
39
+ readonly startedAt: number;
40
+ /**
41
+ * The random stream, uniform on [0, 1). Every handler of the invocation draws
42
+ * from it through `Math.random` and `crypto.getRandomValues`, so Effect's
43
+ * default `Random` draws from it too.
44
+ */
45
+ readonly random: () => number;
46
+ }
47
+ /**
48
+ * Run `start` with `invocation` bound. Every registered handler it reaches
49
+ * through convex-test runs with the invocation's services, clock and random
50
+ * stream.
51
+ */
52
+ export declare const bind: <A>(invocation: Invocation, start: () => Promise<A>) => Promise<A>;
53
+ /**
54
+ * The invocation the calling handler runs under, if one is bound.
55
+ */
56
+ export declare const current: () => Invocation | undefined;
57
+ //# sourceMappingURL=Invocation.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Invocation.d.ts","sourceRoot":"","sources":["../../src/internal/Invocation.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAoB,eAAe,EAAE,MAAM,eAAe,CAAC;AACvE,OAAO,KAAK,KAAK,OAAO,MAAM,gBAAgB,CAAC;AAE/C;;GAEG;AACH,MAAM,MAAM,OAAO,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;AAExC;;;GAGG;AACH,MAAM,WAAW,KAAK;IACpB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;IAC1C;;;;OAIG;IACH,QAAQ,CAAC,KAAK,EAAE,MAAM,MAAM,CAAC;IAC7B;;;OAGG;IACH,QAAQ,CAAC,MAAM,EAAE,MAAM,MAAM,CAAC;CAC/B;AAED,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAC;IACtB,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,OAAO,EAAE,IAAI,CAAC,eAAe,EAAE,WAAW,CAAC,CAAC;IACrD,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;IAC1C;;;;;;OAMG;IACH,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B;;;;OAIG;IACH,QAAQ,CAAC,MAAM,EAAE,MAAM,MAAM,CAAC;CAC/B;AA+ND;;;;GAIG;AACH,eAAO,MAAM,IAAI,GAAI,CAAC,cACR,UAAU,SACf,MAAM,OAAO,CAAC,CAAC,CAAC,KACtB,OAAO,CAAC,CAAC,CAIT,CAAC;AAEJ;;GAEG;AACH,eAAO,MAAM,OAAO,QAAO,UAAU,GAAG,SACQ,CAAC"}
@@ -0,0 +1,145 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ //#region src/internal/Invocation.ts
3
+ const key = Symbol.for("@confect/test/Invocation");
4
+ /**
5
+ * The invocation a function of `functionType` called from `running` joins. A
6
+ * query or mutation an action calls is a transaction of its own, so it starts
7
+ * at the world's time when it begins. Any other call shares its caller's
8
+ * start.
9
+ */
10
+ const caller = (running, functionType) => running.functionType === "action" && functionType !== "action" ? {
11
+ ...running.invocation,
12
+ startedAt: running.invocation.world.clock()
13
+ } : running.invocation;
14
+ /**
15
+ * The invocation a handler runs under. A call nested in the bound request joins
16
+ * it at the callee's component path. A job Convex started from a timer set
17
+ * inside the bound request is a request of its own: its scheduled function ID
18
+ * differs, so it gets a fresh record with the world's services, clock and
19
+ * random stream.
20
+ */
21
+ const resolve = (bound, componentPath, request) => {
22
+ if (request !== void 0 && request.scheduledFunctionId !== bound.request.scheduledFunctionId) {
23
+ const { ip, userAgent, requestId, scheduledFunctionId } = request;
24
+ return {
25
+ world: bound.world,
26
+ componentPath,
27
+ attempt: 1,
28
+ request: {
29
+ ip,
30
+ userAgent,
31
+ requestId,
32
+ scheduledFunctionId
33
+ },
34
+ services: bound.world.services,
35
+ startedAt: bound.world.clock(),
36
+ random: bound.world.random
37
+ };
38
+ }
39
+ return componentPath === bound.componentPath ? bound : {
40
+ ...bound,
41
+ componentPath
42
+ };
43
+ };
44
+ /**
45
+ * What `Date` answers for a function of `functionType` under `invocation`: a
46
+ * query's or mutation's start, or the world's clock as it runs in an action.
47
+ */
48
+ const clockOf = (invocation, functionType) => functionType === "action" ? invocation.world.clock : () => invocation.startedAt;
49
+ const pinnedDate = (base, now) => new Proxy(base, {
50
+ get: (target, property, receiver) => property === "now" ? now : Reflect.get(target, property, receiver),
51
+ construct: (target, args, newTarget) => Reflect.construct(target, args.length === 0 ? [now()] : args, newTarget),
52
+ apply: () => new base(now()).toString()
53
+ });
54
+ const drawingMath = (base, random) => new Proxy(base, { get: (target, property, receiver) => property === "random" ? random : Reflect.get(target, property, receiver) });
55
+ const drawingCrypto = (base, random) => {
56
+ function getRandomValues(array) {
57
+ Reflect.apply(base.getRandomValues, this === drawing ? base : this, [array]);
58
+ if (array === null) return array;
59
+ const bytes = new Uint8Array(array.buffer, array.byteOffset, array.byteLength);
60
+ for (let index = 0; index < bytes.length; index++) bytes[index] = Math.floor(random() * 256);
61
+ return array;
62
+ }
63
+ const drawing = new Proxy(base, { get: (target, property) => {
64
+ if (property === "getRandomValues") return getRandomValues;
65
+ const value = Reflect.get(target, property, target);
66
+ return typeof value === "function" ? value.bind(target) : value;
67
+ } });
68
+ return drawing;
69
+ };
70
+ /**
71
+ * Point the calling handler's `Date`, `Math` and `crypto` at `invocation`, its
72
+ * `Date` through {@link clockOf}. convex-test gives each handler call its own
73
+ * copy of these globals, and an assignment inside a handler lands in that copy
74
+ * only, so overlapping handlers and the handler's own nested calls keep
75
+ * theirs.
76
+ *
77
+ * `outside` reads in the test's own async context. Where the handler has no
78
+ * copy, an assignment either throws or reaches test code, so `pin` puts back
79
+ * each global that reached it and throws.
80
+ */
81
+ const pin = (invocation, functionType, outside) => {
82
+ const host = globalThis;
83
+ const previous = {
84
+ Date: host.Date,
85
+ Math: host.Math,
86
+ crypto: host.crypto
87
+ };
88
+ const pinned = {
89
+ Date: pinnedDate(previous.Date, clockOf(invocation, functionType)),
90
+ Math: drawingMath(previous.Math, invocation.random),
91
+ crypto: drawingCrypto(previous.crypto, invocation.random)
92
+ };
93
+ try {
94
+ host.Date = pinned.Date;
95
+ host.Math = pinned.Math;
96
+ host.crypto = pinned.crypto;
97
+ } catch {}
98
+ const reached = outside(() => ({
99
+ Date: host.Date === pinned.Date,
100
+ Math: host.Math === pinned.Math,
101
+ crypto: host.crypto === pinned.crypto
102
+ }));
103
+ if (host.Date === pinned.Date && host.Math === pinned.Math && host.crypto === pinned.crypto && !reached.Date && !reached.Math && !reached.crypto) return;
104
+ if (reached.Date) host.Date = previous.Date;
105
+ if (reached.Math) host.Math = previous.Math;
106
+ if (reached.crypto) host.crypto = previous.crypto;
107
+ throw new Error("@confect/test cannot pin this handler's Date, Math and crypto: it runs without convex-test's per-handler globals, so pinning would change them for every caller");
108
+ };
109
+ const make = () => {
110
+ const storage = new AsyncLocalStorage();
111
+ const outside = AsyncLocalStorage.snapshot();
112
+ return {
113
+ storage,
114
+ start: (meta, run, functionType) => {
115
+ const running = storage.getStore();
116
+ if (running === void 0) return void 0;
117
+ return Promise.all([meta.getFunctionMetadata(), meta.getRequestMetadata?.()]).then(([{ componentPath }, request]) => {
118
+ const invocation = resolve(caller(running, functionType), componentPath, request);
119
+ pin(invocation, functionType, outside);
120
+ return storage.run({
121
+ invocation,
122
+ functionType
123
+ }, () => run(invocation.services));
124
+ });
125
+ }
126
+ };
127
+ };
128
+ const installed = globalThis;
129
+ /**
130
+ * Run `start` with `invocation` bound. Every registered handler it reaches
131
+ * through convex-test runs with the invocation's services, clock and random
132
+ * stream.
133
+ */
134
+ const bind = (invocation, start) => (installed[key] ??= make()).storage.run({
135
+ invocation,
136
+ functionType: void 0
137
+ }, start);
138
+ /**
139
+ * The invocation the calling handler runs under, if one is bound.
140
+ */
141
+ const current = () => installed[key]?.storage.getStore()?.invocation;
142
+ //#endregion
143
+ export { bind, current };
144
+
145
+ //# sourceMappingURL=Invocation.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Invocation.js","names":[],"sources":["../../src/internal/Invocation.ts"],"sourcesContent":["import { AsyncLocalStorage } from \"node:async_hooks\";\nimport type { FunctionMetadata, RequestMetadata } from \"convex/server\";\nimport type * as Context from \"effect/Context\";\n\n/**\n * Four re-executions after the first try. A conflict on attempt 5 exhausts.\n */\nexport type Attempt = 1 | 2 | 3 | 4 | 5;\n\n/**\n * One test backend. Its `services`, `clock` and `random` reach every invocation\n * it starts on its own, such as a scheduled job.\n */\nexport interface World {\n readonly name: string;\n readonly services: Context.Context<never>;\n /**\n * The world's controlled clock in epoch milliseconds. An invocation the world\n * starts on its own pins its start time from one reading. A bound action's\n * `Date` reads it live.\n */\n readonly clock: () => number;\n /**\n * The random stream, uniform on [0, 1), that an invocation the world starts\n * on its own draws from.\n */\n readonly random: () => number;\n}\n\nexport interface Invocation {\n readonly world: World;\n readonly componentPath: string;\n readonly attempt: Attempt;\n readonly request: Omit<RequestMetadata, \"authToken\">;\n readonly services: Context.Context<never>;\n /**\n * The start time in epoch milliseconds. Every query or mutation of the\n * invocation reads it from `Date.now()` and `new Date()`, so Effect's default\n * `Clock` reads it too. An action reads its world's clock instead, as it\n * runs, and a query or mutation an action calls starts at the action's time\n * then.\n */\n readonly startedAt: number;\n /**\n * The random stream, uniform on [0, 1). Every handler of the invocation draws\n * from it through `Math.random` and `crypto.getRandomValues`, so Effect's\n * default `Random` draws from it too.\n */\n readonly random: () => number;\n}\n\ntype FunctionType = \"query\" | \"mutation\" | \"action\";\n\n/**\n * The invocation a handler runs under, and the type of the function it runs.\n * Test code that binds an invocation runs no function, so it has no type.\n */\ninterface Running {\n readonly invocation: Invocation;\n readonly functionType: FunctionType | undefined;\n}\n\n/**\n * What `runHandlerPromise` in `@confect/server` reads from `globalThis` under\n * the same key. `start` answers `undefined` when no invocation is bound.\n */\ninterface Bridge {\n readonly storage: AsyncLocalStorage<Running>;\n readonly start: <A>(\n meta: {\n readonly getFunctionMetadata: () => Promise<FunctionMetadata>;\n readonly getRequestMetadata?: () => Promise<RequestMetadata>;\n },\n run: (services: Context.Context<never>) => Promise<A>,\n functionType: FunctionType,\n ) => Promise<A> | undefined;\n}\n\nconst key = Symbol.for(\"@confect/test/Invocation\");\n\n/**\n * The invocation a function of `functionType` called from `running` joins. A\n * query or mutation an action calls is a transaction of its own, so it starts\n * at the world's time when it begins. Any other call shares its caller's\n * start.\n */\nconst caller = (running: Running, functionType: FunctionType): Invocation =>\n running.functionType === \"action\" && functionType !== \"action\"\n ? { ...running.invocation, startedAt: running.invocation.world.clock() }\n : running.invocation;\n\n/**\n * The invocation a handler runs under. A call nested in the bound request joins\n * it at the callee's component path. A job Convex started from a timer set\n * inside the bound request is a request of its own: its scheduled function ID\n * differs, so it gets a fresh record with the world's services, clock and\n * random stream.\n */\nconst resolve = (\n bound: Invocation,\n componentPath: string,\n request: RequestMetadata | undefined,\n): Invocation => {\n if (\n request !== undefined &&\n request.scheduledFunctionId !== bound.request.scheduledFunctionId\n ) {\n const { ip, userAgent, requestId, scheduledFunctionId } = request;\n return {\n world: bound.world,\n componentPath,\n attempt: 1,\n request: { ip, userAgent, requestId, scheduledFunctionId },\n services: bound.world.services,\n startedAt: bound.world.clock(),\n random: bound.world.random,\n };\n }\n return componentPath === bound.componentPath\n ? bound\n : { ...bound, componentPath };\n};\n\n/**\n * What `Date` answers for a function of `functionType` under `invocation`: a\n * query's or mutation's start, or the world's clock as it runs in an action.\n */\nconst clockOf = (\n invocation: Invocation,\n functionType: FunctionType,\n): (() => number) =>\n functionType === \"action\"\n ? invocation.world.clock\n : () => invocation.startedAt;\n\nconst pinnedDate = (\n base: DateConstructor,\n now: () => number,\n): DateConstructor =>\n new Proxy(base, {\n get: (target, property, receiver) =>\n property === \"now\" ? now : Reflect.get(target, property, receiver),\n construct: (target, args, newTarget) =>\n Reflect.construct(target, args.length === 0 ? [now()] : args, newTarget),\n apply: () => new base(now()).toString(),\n });\n\nconst drawingMath = (base: Math, random: () => number): Math =>\n new Proxy(base, {\n get: (target, property, receiver) =>\n property === \"random\" ? random : Reflect.get(target, property, receiver),\n });\n\nconst drawingCrypto = (base: Crypto, random: () => number): Crypto => {\n function getRandomValues<T extends Parameters<Crypto[\"getRandomValues\"]>[0]>(\n this: unknown,\n array: T,\n ): T {\n // The host call checks the receiver and the argument and throws the host's\n // errors, so a detached call fails as it does in production.\n // oxlint-disable-next-line typescript/unbound-method -- Applied with the caller's receiver.\n Reflect.apply(base.getRandomValues, this === drawing ? base : this, [\n array,\n ]);\n if (array === null) return array;\n const bytes = new Uint8Array(\n array.buffer,\n array.byteOffset,\n array.byteLength,\n );\n for (let index = 0; index < bytes.length; index++) {\n bytes[index] = Math.floor(random() * 256);\n }\n return array;\n }\n const drawing: Crypto = new Proxy(base, {\n get: (target, property) => {\n if (property === \"getRandomValues\") return getRandomValues;\n // Crypto's methods and getters need the host object as `this`.\n const value: unknown = Reflect.get(target, property, target);\n return typeof value === \"function\" ? value.bind(target) : value;\n },\n });\n return drawing;\n};\n\n/**\n * Point the calling handler's `Date`, `Math` and `crypto` at `invocation`, its\n * `Date` through {@link clockOf}. convex-test gives each handler call its own\n * copy of these globals, and an assignment inside a handler lands in that copy\n * only, so overlapping handlers and the handler's own nested calls keep\n * theirs.\n *\n * `outside` reads in the test's own async context. Where the handler has no\n * copy, an assignment either throws or reaches test code, so `pin` puts back\n * each global that reached it and throws.\n */\nconst pin = (\n invocation: Invocation,\n functionType: FunctionType,\n outside: <R>(read: () => R) => R,\n): void => {\n const host = globalThis;\n const previous = { Date: host.Date, Math: host.Math, crypto: host.crypto };\n const pinned = {\n Date: pinnedDate(previous.Date, clockOf(invocation, functionType)),\n Math: drawingMath(previous.Math, invocation.random),\n crypto: drawingCrypto(previous.crypto, invocation.random),\n };\n try {\n host.Date = pinned.Date;\n host.Math = pinned.Math;\n host.crypto = pinned.crypto;\n } catch {\n // A global without a per-handler copy can be read-only. The check below\n // refuses the handler.\n }\n const reached = outside(() => ({\n Date: host.Date === pinned.Date,\n Math: host.Math === pinned.Math,\n crypto: host.crypto === pinned.crypto,\n }));\n if (\n host.Date === pinned.Date &&\n host.Math === pinned.Math &&\n host.crypto === pinned.crypto &&\n !reached.Date &&\n !reached.Math &&\n !reached.crypto\n ) {\n return;\n }\n if (reached.Date) host.Date = previous.Date;\n if (reached.Math) host.Math = previous.Math;\n if (reached.crypto) host.crypto = previous.crypto;\n throw new Error(\n \"@confect/test cannot pin this handler's Date, Math and crypto: it runs without convex-test's per-handler globals, so pinning would change them for every caller\",\n );\n};\n\nconst make = (): Bridge => {\n const storage = new AsyncLocalStorage<Running>();\n // Taken at the first `bind`, which test code calls outside any handler, so\n // it reads the test environment's globals.\n const outside = AsyncLocalStorage.snapshot();\n return {\n storage,\n start: (meta, run, functionType) => {\n const running = storage.getStore();\n if (running === undefined) return undefined;\n return Promise.all([\n meta.getFunctionMetadata(),\n meta.getRequestMetadata?.(),\n ]).then(([{ componentPath }, request]) => {\n const invocation = resolve(\n caller(running, functionType),\n componentPath,\n request,\n );\n pin(invocation, functionType, outside);\n return storage.run({ invocation, functionType }, () =>\n run(invocation.services),\n );\n });\n },\n };\n};\n\n// One bridge per test runtime, so every copy of this module that a test loads\n// binds through the store `runHandlerPromise` reads.\nconst installed = globalThis as { [key]?: Bridge };\n\n/**\n * Run `start` with `invocation` bound. Every registered handler it reaches\n * through convex-test runs with the invocation's services, clock and random\n * stream.\n */\nexport const bind = <A>(\n invocation: Invocation,\n start: () => Promise<A>,\n): Promise<A> =>\n (installed[key] ??= make()).storage.run(\n { invocation, functionType: undefined },\n start,\n );\n\n/**\n * The invocation the calling handler runs under, if one is bound.\n */\nexport const current = (): Invocation | undefined =>\n installed[key]?.storage.getStore()?.invocation;\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@gaganref/confect-test",
3
- "version": "10.3.1-fork.87",
3
+ "version": "10.3.1-fork.88",
4
4
  "peerDependencies": {
5
- "@confect/core": "npm:@gaganref/confect-core@10.3.1-fork.87",
6
- "@confect/server": "npm:@gaganref/confect-server@10.3.1-fork.87",
5
+ "@confect/core": "npm:@gaganref/confect-core@10.3.1-fork.88",
6
+ "@confect/server": "npm:@gaganref/confect-server@10.3.1-fork.88",
7
7
  "convex": "^1.32.0",
8
8
  "convex-test": ">=0.0.65 <0.1.0",
9
9
  "effect": ">=4.0.0 <4.1.0"
@@ -0,0 +1,291 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import type { FunctionMetadata, RequestMetadata } from "convex/server";
3
+ import type * as Context from "effect/Context";
4
+
5
+ /**
6
+ * Four re-executions after the first try. A conflict on attempt 5 exhausts.
7
+ */
8
+ export type Attempt = 1 | 2 | 3 | 4 | 5;
9
+
10
+ /**
11
+ * One test backend. Its `services`, `clock` and `random` reach every invocation
12
+ * it starts on its own, such as a scheduled job.
13
+ */
14
+ export interface World {
15
+ readonly name: string;
16
+ readonly services: Context.Context<never>;
17
+ /**
18
+ * The world's controlled clock in epoch milliseconds. An invocation the world
19
+ * starts on its own pins its start time from one reading. A bound action's
20
+ * `Date` reads it live.
21
+ */
22
+ readonly clock: () => number;
23
+ /**
24
+ * The random stream, uniform on [0, 1), that an invocation the world starts
25
+ * on its own draws from.
26
+ */
27
+ readonly random: () => number;
28
+ }
29
+
30
+ export interface Invocation {
31
+ readonly world: World;
32
+ readonly componentPath: string;
33
+ readonly attempt: Attempt;
34
+ readonly request: Omit<RequestMetadata, "authToken">;
35
+ readonly services: Context.Context<never>;
36
+ /**
37
+ * The start time in epoch milliseconds. Every query or mutation of the
38
+ * invocation reads it from `Date.now()` and `new Date()`, so Effect's default
39
+ * `Clock` reads it too. An action reads its world's clock instead, as it
40
+ * runs, and a query or mutation an action calls starts at the action's time
41
+ * then.
42
+ */
43
+ readonly startedAt: number;
44
+ /**
45
+ * The random stream, uniform on [0, 1). Every handler of the invocation draws
46
+ * from it through `Math.random` and `crypto.getRandomValues`, so Effect's
47
+ * default `Random` draws from it too.
48
+ */
49
+ readonly random: () => number;
50
+ }
51
+
52
+ type FunctionType = "query" | "mutation" | "action";
53
+
54
+ /**
55
+ * The invocation a handler runs under, and the type of the function it runs.
56
+ * Test code that binds an invocation runs no function, so it has no type.
57
+ */
58
+ interface Running {
59
+ readonly invocation: Invocation;
60
+ readonly functionType: FunctionType | undefined;
61
+ }
62
+
63
+ /**
64
+ * What `runHandlerPromise` in `@confect/server` reads from `globalThis` under
65
+ * the same key. `start` answers `undefined` when no invocation is bound.
66
+ */
67
+ interface Bridge {
68
+ readonly storage: AsyncLocalStorage<Running>;
69
+ readonly start: <A>(
70
+ meta: {
71
+ readonly getFunctionMetadata: () => Promise<FunctionMetadata>;
72
+ readonly getRequestMetadata?: () => Promise<RequestMetadata>;
73
+ },
74
+ run: (services: Context.Context<never>) => Promise<A>,
75
+ functionType: FunctionType,
76
+ ) => Promise<A> | undefined;
77
+ }
78
+
79
+ const key = Symbol.for("@confect/test/Invocation");
80
+
81
+ /**
82
+ * The invocation a function of `functionType` called from `running` joins. A
83
+ * query or mutation an action calls is a transaction of its own, so it starts
84
+ * at the world's time when it begins. Any other call shares its caller's
85
+ * start.
86
+ */
87
+ const caller = (running: Running, functionType: FunctionType): Invocation =>
88
+ running.functionType === "action" && functionType !== "action"
89
+ ? { ...running.invocation, startedAt: running.invocation.world.clock() }
90
+ : running.invocation;
91
+
92
+ /**
93
+ * The invocation a handler runs under. A call nested in the bound request joins
94
+ * it at the callee's component path. A job Convex started from a timer set
95
+ * inside the bound request is a request of its own: its scheduled function ID
96
+ * differs, so it gets a fresh record with the world's services, clock and
97
+ * random stream.
98
+ */
99
+ const resolve = (
100
+ bound: Invocation,
101
+ componentPath: string,
102
+ request: RequestMetadata | undefined,
103
+ ): Invocation => {
104
+ if (
105
+ request !== undefined &&
106
+ request.scheduledFunctionId !== bound.request.scheduledFunctionId
107
+ ) {
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
+ };
118
+ }
119
+ return componentPath === bound.componentPath
120
+ ? bound
121
+ : { ...bound, componentPath };
122
+ };
123
+
124
+ /**
125
+ * What `Date` answers for a function of `functionType` under `invocation`: a
126
+ * query's or mutation's start, or the world's clock as it runs in an action.
127
+ */
128
+ const clockOf = (
129
+ invocation: Invocation,
130
+ functionType: FunctionType,
131
+ ): (() => number) =>
132
+ functionType === "action"
133
+ ? invocation.world.clock
134
+ : () => invocation.startedAt;
135
+
136
+ const pinnedDate = (
137
+ base: DateConstructor,
138
+ now: () => number,
139
+ ): DateConstructor =>
140
+ new Proxy(base, {
141
+ get: (target, property, receiver) =>
142
+ property === "now" ? now : Reflect.get(target, property, receiver),
143
+ construct: (target, args, newTarget) =>
144
+ Reflect.construct(target, args.length === 0 ? [now()] : args, newTarget),
145
+ apply: () => new base(now()).toString(),
146
+ });
147
+
148
+ const drawingMath = (base: Math, random: () => number): Math =>
149
+ new Proxy(base, {
150
+ get: (target, property, receiver) =>
151
+ property === "random" ? random : Reflect.get(target, property, receiver),
152
+ });
153
+
154
+ const drawingCrypto = (base: Crypto, random: () => number): Crypto => {
155
+ function getRandomValues<T extends Parameters<Crypto["getRandomValues"]>[0]>(
156
+ this: unknown,
157
+ array: T,
158
+ ): T {
159
+ // The host call checks the receiver and the argument and throws the host's
160
+ // errors, so a detached call fails as it does in production.
161
+ // oxlint-disable-next-line typescript/unbound-method -- Applied with the caller's receiver.
162
+ Reflect.apply(base.getRandomValues, this === drawing ? base : this, [
163
+ array,
164
+ ]);
165
+ if (array === null) return array;
166
+ const bytes = new Uint8Array(
167
+ array.buffer,
168
+ array.byteOffset,
169
+ array.byteLength,
170
+ );
171
+ for (let index = 0; index < bytes.length; index++) {
172
+ bytes[index] = Math.floor(random() * 256);
173
+ }
174
+ return array;
175
+ }
176
+ const drawing: Crypto = new Proxy(base, {
177
+ get: (target, property) => {
178
+ if (property === "getRandomValues") return getRandomValues;
179
+ // Crypto's methods and getters need the host object as `this`.
180
+ const value: unknown = Reflect.get(target, property, target);
181
+ return typeof value === "function" ? value.bind(target) : value;
182
+ },
183
+ });
184
+ return drawing;
185
+ };
186
+
187
+ /**
188
+ * Point the calling handler's `Date`, `Math` and `crypto` at `invocation`, its
189
+ * `Date` through {@link clockOf}. convex-test gives each handler call its own
190
+ * copy of these globals, and an assignment inside a handler lands in that copy
191
+ * only, so overlapping handlers and the handler's own nested calls keep
192
+ * theirs.
193
+ *
194
+ * `outside` reads in the test's own async context. Where the handler has no
195
+ * copy, an assignment either throws or reaches test code, so `pin` puts back
196
+ * each global that reached it and throws.
197
+ */
198
+ const pin = (
199
+ invocation: Invocation,
200
+ functionType: FunctionType,
201
+ outside: <R>(read: () => R) => R,
202
+ ): void => {
203
+ const host = globalThis;
204
+ const previous = { Date: host.Date, Math: host.Math, crypto: host.crypto };
205
+ const pinned = {
206
+ Date: pinnedDate(previous.Date, clockOf(invocation, functionType)),
207
+ Math: drawingMath(previous.Math, invocation.random),
208
+ crypto: drawingCrypto(previous.crypto, invocation.random),
209
+ };
210
+ try {
211
+ host.Date = pinned.Date;
212
+ host.Math = pinned.Math;
213
+ host.crypto = pinned.crypto;
214
+ } catch {
215
+ // A global without a per-handler copy can be read-only. The check below
216
+ // refuses the handler.
217
+ }
218
+ const reached = outside(() => ({
219
+ Date: host.Date === pinned.Date,
220
+ Math: host.Math === pinned.Math,
221
+ crypto: host.crypto === pinned.crypto,
222
+ }));
223
+ if (
224
+ host.Date === pinned.Date &&
225
+ host.Math === pinned.Math &&
226
+ host.crypto === pinned.crypto &&
227
+ !reached.Date &&
228
+ !reached.Math &&
229
+ !reached.crypto
230
+ ) {
231
+ return;
232
+ }
233
+ if (reached.Date) host.Date = previous.Date;
234
+ if (reached.Math) host.Math = previous.Math;
235
+ if (reached.crypto) host.crypto = previous.crypto;
236
+ throw new Error(
237
+ "@confect/test cannot pin this handler's Date, Math and crypto: it runs without convex-test's per-handler globals, so pinning would change them for every caller",
238
+ );
239
+ };
240
+
241
+ const make = (): Bridge => {
242
+ const storage = new AsyncLocalStorage<Running>();
243
+ // Taken at the first `bind`, which test code calls outside any handler, so
244
+ // it reads the test environment's globals.
245
+ const outside = AsyncLocalStorage.snapshot();
246
+ return {
247
+ storage,
248
+ start: (meta, run, functionType) => {
249
+ const running = storage.getStore();
250
+ if (running === undefined) return undefined;
251
+ return Promise.all([
252
+ meta.getFunctionMetadata(),
253
+ meta.getRequestMetadata?.(),
254
+ ]).then(([{ componentPath }, request]) => {
255
+ const invocation = resolve(
256
+ caller(running, functionType),
257
+ componentPath,
258
+ request,
259
+ );
260
+ pin(invocation, functionType, outside);
261
+ return storage.run({ invocation, functionType }, () =>
262
+ run(invocation.services),
263
+ );
264
+ });
265
+ },
266
+ };
267
+ };
268
+
269
+ // One bridge per test runtime, so every copy of this module that a test loads
270
+ // binds through the store `runHandlerPromise` reads.
271
+ const installed = globalThis as { [key]?: Bridge };
272
+
273
+ /**
274
+ * Run `start` with `invocation` bound. Every registered handler it reaches
275
+ * through convex-test runs with the invocation's services, clock and random
276
+ * stream.
277
+ */
278
+ export const bind = <A>(
279
+ invocation: Invocation,
280
+ start: () => Promise<A>,
281
+ ): Promise<A> =>
282
+ (installed[key] ??= make()).storage.run(
283
+ { invocation, functionType: undefined },
284
+ start,
285
+ );
286
+
287
+ /**
288
+ * The invocation the calling handler runs under, if one is bound.
289
+ */
290
+ export const current = (): Invocation | undefined =>
291
+ installed[key]?.storage.getStore()?.invocation;