@gaganref/confect-test 10.3.1-fork.87 → 10.3.1-fork.89
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.
- package/dist/internal/Attempt.d.ts +77 -0
- package/dist/internal/Attempt.d.ts.map +1 -0
- package/dist/internal/Attempt.js +154 -0
- package/dist/internal/Attempt.js.map +1 -0
- package/dist/internal/ConvexTest.d.ts +34 -0
- package/dist/internal/ConvexTest.d.ts.map +1 -0
- package/dist/internal/ConvexTest.js +59 -0
- package/dist/internal/ConvexTest.js.map +1 -0
- package/dist/internal/Execution.d.ts +167 -0
- package/dist/internal/Execution.d.ts.map +1 -0
- package/dist/internal/Execution.js +790 -0
- package/dist/internal/Execution.js.map +1 -0
- package/dist/internal/Globals.d.ts +11 -0
- package/dist/internal/Globals.d.ts.map +1 -0
- package/dist/internal/Globals.js +76 -0
- package/dist/internal/Globals.js.map +1 -0
- package/dist/internal/Invocation.d.ts +57 -0
- package/dist/internal/Invocation.d.ts.map +1 -0
- package/dist/internal/Invocation.js +145 -0
- package/dist/internal/Invocation.js.map +1 -0
- package/dist/internal/Kernel.d.ts +93 -0
- package/dist/internal/Kernel.d.ts.map +1 -0
- package/dist/internal/Kernel.js +91 -0
- package/dist/internal/Kernel.js.map +1 -0
- package/dist/internal/KernelQuery.d.ts +55 -0
- package/dist/internal/KernelQuery.d.ts.map +1 -0
- package/dist/internal/KernelQuery.js +205 -0
- package/dist/internal/KernelQuery.js.map +1 -0
- package/dist/internal/KernelRefusal.d.ts +77 -0
- package/dist/internal/KernelRefusal.d.ts.map +1 -0
- package/dist/internal/KernelRefusal.js +73 -0
- package/dist/internal/KernelRefusal.js.map +1 -0
- package/dist/internal/KernelValues.d.ts +136 -0
- package/dist/internal/KernelValues.d.ts.map +1 -0
- package/dist/internal/KernelValues.js +266 -0
- package/dist/internal/KernelValues.js.map +1 -0
- package/dist/internal/ReadSet.d.ts +55 -0
- package/dist/internal/ReadSet.d.ts.map +1 -0
- package/dist/internal/ReadSet.js +29 -0
- package/dist/internal/ReadSet.js.map +1 -0
- package/package.json +3 -3
- package/src/internal/Attempt.ts +185 -0
- package/src/internal/ConvexTest.ts +118 -0
- package/src/internal/Execution.ts +1430 -0
- package/src/internal/Globals.ts +93 -0
- package/src/internal/Invocation.ts +291 -0
- package/src/internal/Kernel.ts +190 -0
- package/src/internal/KernelQuery.ts +405 -0
- package/src/internal/KernelRefusal.ts +88 -0
- package/src/internal/KernelValues.ts +725 -0
- package/src/internal/ReadSet.ts +108 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Convex gives every function execution its own globals. The kernel gives each
|
|
5
|
+
* function call it starts its own copy of the globals a test invocation pins,
|
|
6
|
+
* as convex-test 0.0.65 does for every handler (its `dist/index.js:1744`,
|
|
7
|
+
* `:1883-1905` and `:1927-1930`). An assignment inside the call lands in its
|
|
8
|
+
* copy only, so the invocation bridge's pins stay with the handler that made
|
|
9
|
+
* them, and overlapping or nested calls keep their own.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const names = ["Date", "Math", "crypto"] as const;
|
|
13
|
+
|
|
14
|
+
type Name = (typeof names)[number];
|
|
15
|
+
|
|
16
|
+
type Copies = { [name in Name]?: unknown };
|
|
17
|
+
|
|
18
|
+
interface State {
|
|
19
|
+
readonly copies: AsyncLocalStorage<Copies>;
|
|
20
|
+
/**
|
|
21
|
+
* The getters this module installed, so a later install skips them.
|
|
22
|
+
*/
|
|
23
|
+
readonly getters: WeakSet<() => unknown>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const key = Symbol.for("@confect/test/Globals");
|
|
27
|
+
|
|
28
|
+
// One store per test runtime, so every copy of this module reads the same
|
|
29
|
+
// copies.
|
|
30
|
+
const state = (): State =>
|
|
31
|
+
((globalThis as { [key]?: State })[key] ??= {
|
|
32
|
+
copies: new AsyncLocalStorage(),
|
|
33
|
+
getters: new WeakSet(),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Point each global at the calling function's copy, falling back to the
|
|
38
|
+
* global's own value outside one. A test that redefines a global, as
|
|
39
|
+
* `vi.stubGlobal` does, removes the accessor, so every call installs it again
|
|
40
|
+
* over what the test left.
|
|
41
|
+
*/
|
|
42
|
+
const install = ({ copies, getters }: State) => {
|
|
43
|
+
const global = globalThis as unknown as Record<Name, unknown>;
|
|
44
|
+
for (const name of names) {
|
|
45
|
+
const own = Object.getOwnPropertyDescriptor(global, name);
|
|
46
|
+
// oxlint-disable-next-line typescript/unbound-method -- Compared by identity, never called.
|
|
47
|
+
if (own?.get !== undefined && getters.has(own.get)) continue;
|
|
48
|
+
const base: PropertyDescriptor = own ?? {
|
|
49
|
+
value: undefined,
|
|
50
|
+
writable: true,
|
|
51
|
+
};
|
|
52
|
+
const get = () => {
|
|
53
|
+
const copy = copies.getStore();
|
|
54
|
+
if (copy !== undefined && name in copy) return copy[name];
|
|
55
|
+
return "value" in base ? base.value : base.get?.call(global);
|
|
56
|
+
};
|
|
57
|
+
const set = (value: unknown) => {
|
|
58
|
+
const copy = copies.getStore();
|
|
59
|
+
if (copy !== undefined) copy[name] = value;
|
|
60
|
+
else if ("value" in base && base.writable === true) base.value = value;
|
|
61
|
+
else if (base.set !== undefined) base.set.call(global, value);
|
|
62
|
+
else throw new TypeError(`Cannot assign to read only global '${name}'`);
|
|
63
|
+
};
|
|
64
|
+
try {
|
|
65
|
+
Object.defineProperty(global, name, {
|
|
66
|
+
get,
|
|
67
|
+
set,
|
|
68
|
+
configurable: true,
|
|
69
|
+
enumerable: own?.enumerable ?? false,
|
|
70
|
+
});
|
|
71
|
+
getters.add(get);
|
|
72
|
+
} catch {
|
|
73
|
+
// A global that cannot be redefined keeps no copy, and the bridge
|
|
74
|
+
// refuses to pin it.
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Run `call` with a fresh copy of the globals. It starts from the test
|
|
81
|
+
* environment's, whatever the caller assigned in its own copy.
|
|
82
|
+
*/
|
|
83
|
+
export const own = <A>(call: () => A): A => {
|
|
84
|
+
const current = state();
|
|
85
|
+
install(current);
|
|
86
|
+
return current.copies.run({}, call);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Read `read` against the test environment's globals, never a function call's
|
|
91
|
+
* copy, as the kernel's own clock must.
|
|
92
|
+
*/
|
|
93
|
+
export const host = <A>(read: () => A): A => state().copies.exit(read);
|
|
@@ -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;
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import * as Globals from "./Globals.js";
|
|
2
|
+
import type { DocumentId, StoredDocument } from "./KernelValues.js";
|
|
3
|
+
import { resolveCommittedValues, tableNameFromId } from "./KernelValues.js";
|
|
4
|
+
import * as ReadSet from "./ReadSet.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The number of commits the kernel has published. The empty world is 0.
|
|
8
|
+
*/
|
|
9
|
+
export type Version = number;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A committed document and the version that wrote it.
|
|
13
|
+
*/
|
|
14
|
+
export interface Row {
|
|
15
|
+
readonly doc: StoredDocument;
|
|
16
|
+
readonly version: Version;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Committed rows, keyed by component path, then table name, then document ID. A
|
|
21
|
+
* published state is never changed. A commit makes a new one.
|
|
22
|
+
*/
|
|
23
|
+
export type Tables = ReadonlyMap<
|
|
24
|
+
string,
|
|
25
|
+
ReadonlyMap<string, ReadonlyMap<DocumentId, Row>>
|
|
26
|
+
>;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* One document's final value in a commit. `null` deletes it.
|
|
30
|
+
*/
|
|
31
|
+
export interface Write {
|
|
32
|
+
readonly componentPath: string;
|
|
33
|
+
readonly id: DocumentId;
|
|
34
|
+
readonly doc: StoredDocument | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* One document a commit changed: its value before and after. `null` is absent.
|
|
39
|
+
*/
|
|
40
|
+
export interface Change {
|
|
41
|
+
readonly componentPath: string;
|
|
42
|
+
readonly id: DocumentId;
|
|
43
|
+
readonly before: StoredDocument | null;
|
|
44
|
+
readonly after: StoredDocument | null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface Commit {
|
|
48
|
+
readonly version: Version;
|
|
49
|
+
readonly commitTs: bigint;
|
|
50
|
+
readonly changes: ReadonlyArray<Change>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The kernel's committed state: rows, the latest version and commit timestamp,
|
|
55
|
+
* and the write log, oldest first.
|
|
56
|
+
*/
|
|
57
|
+
export interface State {
|
|
58
|
+
readonly tables: Tables;
|
|
59
|
+
readonly version: Version;
|
|
60
|
+
readonly commitTs: bigint;
|
|
61
|
+
readonly log: ReadonlyArray<Commit>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const empty: State = {
|
|
65
|
+
tables: new Map(),
|
|
66
|
+
version: 0,
|
|
67
|
+
commitTs: 0n,
|
|
68
|
+
log: [],
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The committed row for `id` in `componentPath`, if any.
|
|
73
|
+
*/
|
|
74
|
+
export const get = (
|
|
75
|
+
state: State,
|
|
76
|
+
componentPath: string,
|
|
77
|
+
id: DocumentId,
|
|
78
|
+
): Row | undefined => {
|
|
79
|
+
const tableName = tableNameFromId(id);
|
|
80
|
+
return tableName === null
|
|
81
|
+
? undefined
|
|
82
|
+
: state.tables.get(componentPath)?.get(tableName)?.get(id);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Every committed row of one table in one component.
|
|
87
|
+
*/
|
|
88
|
+
export const rows = (
|
|
89
|
+
state: State,
|
|
90
|
+
componentPath: string,
|
|
91
|
+
tableName: string,
|
|
92
|
+
): Iterable<Row> =>
|
|
93
|
+
state.tables.get(componentPath)?.get(tableName)?.values() ?? [];
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Publish one attempt's writes as a single commit at `commitTs`. Every
|
|
97
|
+
* component's writes land in the same version, so a reader sees all of them or
|
|
98
|
+
* none. A commit with no writes publishes nothing and leaves the state as it
|
|
99
|
+
* was.
|
|
100
|
+
*/
|
|
101
|
+
export const apply = (
|
|
102
|
+
state: State,
|
|
103
|
+
writes: ReadonlyArray<Write>,
|
|
104
|
+
commitTs: bigint,
|
|
105
|
+
): State => {
|
|
106
|
+
if (writes.length === 0) return state;
|
|
107
|
+
const version = state.version + 1;
|
|
108
|
+
const tables = new Map(
|
|
109
|
+
Array.from(state.tables, ([path, byTable]) => [path, new Map(byTable)]),
|
|
110
|
+
);
|
|
111
|
+
const changes = writes.map(({ componentPath, id, doc }): Change => ({
|
|
112
|
+
componentPath,
|
|
113
|
+
id,
|
|
114
|
+
before: get(state, componentPath, id)?.doc ?? null,
|
|
115
|
+
after: doc === null ? null : resolveCommittedValues(doc, commitTs),
|
|
116
|
+
}));
|
|
117
|
+
const touched = new Set<string>();
|
|
118
|
+
for (const { componentPath, id, after } of changes) {
|
|
119
|
+
const tableName = tableNameFromId(id)!;
|
|
120
|
+
const byTable = tables.get(componentPath) ?? new Map();
|
|
121
|
+
tables.set(componentPath, byTable);
|
|
122
|
+
const key = `${componentPath}\u0000${tableName}`;
|
|
123
|
+
const table = touched.has(key)
|
|
124
|
+
? byTable.get(tableName)!
|
|
125
|
+
: new Map(byTable.get(tableName) ?? []);
|
|
126
|
+
touched.add(key);
|
|
127
|
+
byTable.set(tableName, table);
|
|
128
|
+
if (after === null) table.delete(id);
|
|
129
|
+
else table.set(id, { doc: after, version });
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
tables,
|
|
133
|
+
version,
|
|
134
|
+
commitTs,
|
|
135
|
+
log: [...state.log, { version, commitTs, changes }],
|
|
136
|
+
};
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The first change published after version `snapshot` that invalidates one of
|
|
141
|
+
* `reads`, if any.
|
|
142
|
+
*/
|
|
143
|
+
export const conflict = (
|
|
144
|
+
state: State,
|
|
145
|
+
snapshot: Version,
|
|
146
|
+
reads: ReadonlyArray<ReadSet.Read>,
|
|
147
|
+
): Change | undefined => {
|
|
148
|
+
for (const commit of state.log.slice(snapshot)) {
|
|
149
|
+
for (const change of commit.changes) {
|
|
150
|
+
if (reads.some((read) => ReadSet.covers(read, change))) return change;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return undefined;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* What the driver supplies. The kernel holds no clock of its own.
|
|
158
|
+
*/
|
|
159
|
+
export interface KernelRuntime {
|
|
160
|
+
/**
|
|
161
|
+
* The time in milliseconds, for each inserted document's creation time.
|
|
162
|
+
*/
|
|
163
|
+
readonly now: () => number;
|
|
164
|
+
/**
|
|
165
|
+
* The commit timestamp of `version`, asked once as it is published. The
|
|
166
|
+
* answers strictly increase. A mutation that wrote nothing publishes no
|
|
167
|
+
* version and asks nothing.
|
|
168
|
+
*/
|
|
169
|
+
readonly commitTs: (version: Version) => bigint;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The wall clock, as convex-test reads it. Each answer is the time in
|
|
174
|
+
* nanoseconds when it is asked for, bumped past the previous answer when the
|
|
175
|
+
* clock stands still.
|
|
176
|
+
*/
|
|
177
|
+
export const wallClock = (): KernelRuntime => {
|
|
178
|
+
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());
|
|
182
|
+
return {
|
|
183
|
+
now,
|
|
184
|
+
commitTs: () => {
|
|
185
|
+
const nanos = BigInt(now()) * 1_000_000n;
|
|
186
|
+
latest = nanos > latest ? nanos : latest + 1n;
|
|
187
|
+
return latest;
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
};
|