@gaganref/confect-test 10.3.1-fork.86 → 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.
|
|
3
|
+
"version": "10.3.1-fork.88",
|
|
4
4
|
"peerDependencies": {
|
|
5
|
-
"@confect/core": "npm:@gaganref/confect-core@10.3.1-fork.
|
|
6
|
-
"@confect/server": "npm:@gaganref/confect-server@10.3.1-fork.
|
|
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;
|