@gaganref/confect-test 10.3.1-fork.88 → 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/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/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,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
|
+
};
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
// Derived from convex-test 0.0.65 (index.ts, `DatabaseFake`), Copyright Convex,
|
|
2
|
+
// Inc., licensed under the Apache License, Version 2.0
|
|
3
|
+
// (https://www.apache.org/licenses/LICENSE-2.0). The ordering, index range and
|
|
4
|
+
// pagination rules are convex-test's. They read a table through the attempt
|
|
5
|
+
// that runs the query instead of convex-test's database, and record the
|
|
6
|
+
// interval each query examined.
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
GenericDocument,
|
|
10
|
+
GenericSchema,
|
|
11
|
+
SchemaDefinition,
|
|
12
|
+
} from "convex/server";
|
|
13
|
+
import {
|
|
14
|
+
compareValues,
|
|
15
|
+
convexToJson,
|
|
16
|
+
getDocumentSize,
|
|
17
|
+
jsonToConvex,
|
|
18
|
+
} from "convex/values";
|
|
19
|
+
import * as KernelRefusal from "./KernelRefusal.js";
|
|
20
|
+
import * as ReadSet from "./ReadSet.js";
|
|
21
|
+
import type {
|
|
22
|
+
FilterJson,
|
|
23
|
+
Index,
|
|
24
|
+
SerializedQuery,
|
|
25
|
+
SerializedRangeExpression,
|
|
26
|
+
StoredDocument,
|
|
27
|
+
ValidatorJSON,
|
|
28
|
+
} from "./KernelValues.js";
|
|
29
|
+
import {
|
|
30
|
+
evaluateFieldPath,
|
|
31
|
+
evaluateFilter,
|
|
32
|
+
evaluateRangeFilter,
|
|
33
|
+
isValidIdentifier,
|
|
34
|
+
resolvePendingValues,
|
|
35
|
+
validateFieldNames,
|
|
36
|
+
validateIndexRangeExpression,
|
|
37
|
+
validateValidator,
|
|
38
|
+
} from "./KernelValues.js";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A component's tables as its schema declares them.
|
|
42
|
+
*/
|
|
43
|
+
export interface KernelSchema {
|
|
44
|
+
readonly schemaValidation: boolean;
|
|
45
|
+
readonly tables: ReadonlyMap<
|
|
46
|
+
string,
|
|
47
|
+
{
|
|
48
|
+
readonly indexes: ReadonlyArray<Index>;
|
|
49
|
+
readonly documentType: ValidatorJSON;
|
|
50
|
+
}
|
|
51
|
+
>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const exportSchema = (
|
|
55
|
+
schema: SchemaDefinition<GenericSchema, boolean> | null | undefined,
|
|
56
|
+
): KernelSchema | null => {
|
|
57
|
+
if (schema === null || schema === undefined) return null;
|
|
58
|
+
const exported: KernelSchema = {
|
|
59
|
+
schemaValidation: (schema as unknown as { schemaValidation: boolean })
|
|
60
|
+
.schemaValidation,
|
|
61
|
+
tables: new Map(
|
|
62
|
+
Object.entries(schema.tables).map(([name, table]) => [
|
|
63
|
+
name,
|
|
64
|
+
(table as unknown as { export: () => any }).export(),
|
|
65
|
+
]),
|
|
66
|
+
),
|
|
67
|
+
};
|
|
68
|
+
exported.tables.forEach((table, tableName) => {
|
|
69
|
+
if (!isValidIdentifier(tableName)) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
`Table names must be valid identifiers, got "${tableName}"`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
validateFieldNames(table.documentType);
|
|
75
|
+
table.indexes.forEach(({ indexDescriptor }) => {
|
|
76
|
+
if (!isValidIdentifier(indexDescriptor)) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`Index names must be valid identifiers, got "${indexDescriptor}"`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
return exported;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Check a document against its table's declared type, as the backend does
|
|
88
|
+
* before a write.
|
|
89
|
+
*/
|
|
90
|
+
export const validateDocument = (
|
|
91
|
+
schema: KernelSchema | null,
|
|
92
|
+
tableName: string,
|
|
93
|
+
doc: Record<string, unknown>,
|
|
94
|
+
) => {
|
|
95
|
+
if (schema === null || !schema.schemaValidation) return;
|
|
96
|
+
const validator = schema.tables.get(tableName)?.documentType;
|
|
97
|
+
if (validator === undefined) return;
|
|
98
|
+
validateValidator(validator, resolvePendingValues(doc as GenericDocument));
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* An interval a query reads, before it says where it starts and how far it got.
|
|
103
|
+
*/
|
|
104
|
+
export type Interval = Omit<
|
|
105
|
+
ReadSet.IntervalRead,
|
|
106
|
+
"componentPath" | "after" | "through"
|
|
107
|
+
>;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* What a query reads: one table of one component, as the running attempt sees
|
|
111
|
+
* it. `read` records an interval in the attempt's read set and returns how to
|
|
112
|
+
* advance it.
|
|
113
|
+
*/
|
|
114
|
+
export interface TableView {
|
|
115
|
+
readonly schema: KernelSchema | null;
|
|
116
|
+
readonly scan: (tableName: string) => ReadonlyArray<StoredDocument>;
|
|
117
|
+
readonly read: (
|
|
118
|
+
interval: Interval,
|
|
119
|
+
after: ReadSet.Key | null,
|
|
120
|
+
) => (through: ReadSet.Key | "end") => void;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
interface ResolvedSource {
|
|
124
|
+
readonly sortedDocs: ReadonlyArray<GenericDocument>;
|
|
125
|
+
readonly filterFn: (doc: GenericDocument) => boolean;
|
|
126
|
+
readonly limit: number | null;
|
|
127
|
+
readonly order: "asc" | "desc";
|
|
128
|
+
readonly interval: Interval;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const resolveSource = (
|
|
132
|
+
view: TableView,
|
|
133
|
+
query: SerializedQuery,
|
|
134
|
+
): ResolvedSource => {
|
|
135
|
+
const source = query.source;
|
|
136
|
+
let docs: Array<GenericDocument>;
|
|
137
|
+
let fieldPathsToSortBy: Array<string>;
|
|
138
|
+
let order: "asc" | "desc";
|
|
139
|
+
let table: string;
|
|
140
|
+
let range: ReadonlyArray<SerializedRangeExpression> = [];
|
|
141
|
+
switch (source.type) {
|
|
142
|
+
case "FullTableScan": {
|
|
143
|
+
table = source.tableName;
|
|
144
|
+
docs = [...view.scan(source.tableName)];
|
|
145
|
+
order = source.order ?? "asc";
|
|
146
|
+
fieldPathsToSortBy = ["_creationTime"];
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
case "IndexRange": {
|
|
150
|
+
const [tableName, indexName] = source.indexName.split(".") as [
|
|
151
|
+
string,
|
|
152
|
+
string,
|
|
153
|
+
];
|
|
154
|
+
table = tableName;
|
|
155
|
+
range = source.range;
|
|
156
|
+
order = source.order ?? "asc";
|
|
157
|
+
let fields: Array<string>;
|
|
158
|
+
if (indexName === "by_creation_time") {
|
|
159
|
+
fields = ["_creationTime", "_id"];
|
|
160
|
+
} else if (indexName === "by_id") {
|
|
161
|
+
fields = ["_id"];
|
|
162
|
+
} else {
|
|
163
|
+
const index = view.schema?.tables
|
|
164
|
+
.get(tableName)
|
|
165
|
+
?.indexes.find(
|
|
166
|
+
({ indexDescriptor }) => indexDescriptor === indexName,
|
|
167
|
+
);
|
|
168
|
+
if (index === undefined) {
|
|
169
|
+
throw new Error(
|
|
170
|
+
`Cannot use index "${indexName}" for table "${tableName}" because ` +
|
|
171
|
+
`it is not declared in the schema.`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
fields = index.fields.concat(["_creationTime", "_id"]);
|
|
175
|
+
}
|
|
176
|
+
fieldPathsToSortBy = fields;
|
|
177
|
+
validateIndexRangeExpression(source, fields);
|
|
178
|
+
docs = view
|
|
179
|
+
.scan(tableName)
|
|
180
|
+
.filter((doc) =>
|
|
181
|
+
source.range.every((filter) => evaluateRangeFilter(doc, filter)),
|
|
182
|
+
);
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
case "Search": {
|
|
186
|
+
throw KernelRefusal.requiresBinary("textSearch");
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const filters = query.operators
|
|
191
|
+
.filter(
|
|
192
|
+
(operator): operator is { filter: FilterJson } => "filter" in operator,
|
|
193
|
+
)
|
|
194
|
+
.map((operator) => operator.filter);
|
|
195
|
+
const limit =
|
|
196
|
+
query.operators.find(
|
|
197
|
+
(operator): operator is { limit: number } => "limit" in operator,
|
|
198
|
+
) ?? null;
|
|
199
|
+
|
|
200
|
+
const orderMultiplier = order === "asc" ? 1 : -1;
|
|
201
|
+
docs.sort((a, b) => {
|
|
202
|
+
for (const fp of fieldPathsToSortBy) {
|
|
203
|
+
const v = compareValues(
|
|
204
|
+
evaluateFieldPath(fp, a),
|
|
205
|
+
evaluateFieldPath(fp, b),
|
|
206
|
+
);
|
|
207
|
+
if (v !== 0) return v * orderMultiplier;
|
|
208
|
+
}
|
|
209
|
+
return 0;
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
sortedDocs: docs,
|
|
214
|
+
filterFn: (doc) => filters.every((f) => evaluateFilter(doc, f)),
|
|
215
|
+
limit: limit?.limit ?? null,
|
|
216
|
+
order,
|
|
217
|
+
interval: {
|
|
218
|
+
kind: "interval",
|
|
219
|
+
table,
|
|
220
|
+
fields: fieldPathsToSortBy.includes("_id")
|
|
221
|
+
? fieldPathsToSortBy
|
|
222
|
+
: [...fieldPathsToSortBy, "_id"],
|
|
223
|
+
order,
|
|
224
|
+
range,
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* A query's documents in order. The source is read when the query starts, as a
|
|
231
|
+
* snapshot, and consumed lazily. The read set holds the prefix the stream has
|
|
232
|
+
* examined, filtered documents included, or the whole range once it ends.
|
|
233
|
+
*/
|
|
234
|
+
export const stream = (
|
|
235
|
+
view: TableView,
|
|
236
|
+
query: SerializedQuery,
|
|
237
|
+
): Iterator<GenericDocument> => {
|
|
238
|
+
const { sortedDocs, filterFn, limit, interval } = resolveSource(view, query);
|
|
239
|
+
const examined = view.read(interval, null);
|
|
240
|
+
return (function* () {
|
|
241
|
+
let returned = 0;
|
|
242
|
+
for (const doc of sortedDocs) {
|
|
243
|
+
if (limit !== null && returned >= limit) return;
|
|
244
|
+
examined(ReadSet.keyOf(interval.fields, doc as StoredDocument));
|
|
245
|
+
if (filterFn(doc)) {
|
|
246
|
+
returned++;
|
|
247
|
+
yield doc;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
examined("end");
|
|
251
|
+
})();
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const encodeCursor = (
|
|
255
|
+
doc: GenericDocument,
|
|
256
|
+
fieldPaths: ReadonlyArray<string>,
|
|
257
|
+
): string =>
|
|
258
|
+
JSON.stringify(
|
|
259
|
+
fieldPaths.map((fp) => {
|
|
260
|
+
const k = evaluateFieldPath(fp, doc);
|
|
261
|
+
return k === undefined ? null : JSON.stringify(convexToJson(k));
|
|
262
|
+
}),
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
const parseCursor = (c: string | null | undefined) =>
|
|
266
|
+
c
|
|
267
|
+
? (JSON.parse(c) as Array<string | null>).map((k) =>
|
|
268
|
+
k === null ? undefined : jsonToConvex(JSON.parse(k)),
|
|
269
|
+
)
|
|
270
|
+
: null;
|
|
271
|
+
|
|
272
|
+
const compareToCursor = (
|
|
273
|
+
doc: GenericDocument,
|
|
274
|
+
cursorKeys: ReadonlyArray<unknown>,
|
|
275
|
+
fieldPaths: ReadonlyArray<string>,
|
|
276
|
+
order: "asc" | "desc",
|
|
277
|
+
): number => {
|
|
278
|
+
const orderMultiplier = order === "asc" ? 1 : -1;
|
|
279
|
+
for (let i = 0; i < fieldPaths.length; i++) {
|
|
280
|
+
const cmp = compareValues(
|
|
281
|
+
evaluateFieldPath(fieldPaths[i]!, doc),
|
|
282
|
+
cursorKeys[i] as any,
|
|
283
|
+
);
|
|
284
|
+
if (cmp !== 0) return cmp * orderMultiplier;
|
|
285
|
+
}
|
|
286
|
+
return 0;
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
export interface PageRequest {
|
|
290
|
+
readonly query: SerializedQuery;
|
|
291
|
+
readonly cursor: string | null;
|
|
292
|
+
readonly endCursor?: string | null;
|
|
293
|
+
readonly pageSize: number;
|
|
294
|
+
readonly maximumRowsRead?: number | null;
|
|
295
|
+
readonly maximumBytesRead?: number | null;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export const paginate = (
|
|
299
|
+
view: TableView,
|
|
300
|
+
{
|
|
301
|
+
query,
|
|
302
|
+
cursor,
|
|
303
|
+
endCursor,
|
|
304
|
+
pageSize,
|
|
305
|
+
maximumRowsRead,
|
|
306
|
+
maximumBytesRead,
|
|
307
|
+
}: PageRequest,
|
|
308
|
+
) => {
|
|
309
|
+
const { sortedDocs, filterFn, order, interval } = resolveSource(view, query);
|
|
310
|
+
// A page from the terminal cursor reads the empty interval past the end, so
|
|
311
|
+
// it depends on nothing.
|
|
312
|
+
if (cursor === "_end_cursor") {
|
|
313
|
+
return {
|
|
314
|
+
page: [],
|
|
315
|
+
isDone: true,
|
|
316
|
+
continueCursor: "_end_cursor",
|
|
317
|
+
splitCursor: null,
|
|
318
|
+
pageStatus: null,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
const cursorFields = interval.fields;
|
|
322
|
+
const cursorKeys = parseCursor(cursor);
|
|
323
|
+
const hasEndBoundary = !!endCursor && endCursor !== "_end_cursor";
|
|
324
|
+
const endCursorKeys = hasEndBoundary ? parseCursor(endCursor) : null;
|
|
325
|
+
const examined = view.read(interval, cursorKeys);
|
|
326
|
+
let reachedEnd = true;
|
|
327
|
+
|
|
328
|
+
const page: Array<GenericDocument> = [];
|
|
329
|
+
let isInPage = cursor === null;
|
|
330
|
+
let continueCursor = "_end_cursor";
|
|
331
|
+
let splitCursor: string | null = null;
|
|
332
|
+
let pageStatus: "SplitRecommended" | "SplitRequired" | null = null;
|
|
333
|
+
let rowsRead = 0;
|
|
334
|
+
let bytesRead = 0;
|
|
335
|
+
const readDocCursors: Array<string> = [];
|
|
336
|
+
|
|
337
|
+
for (let i = 0; i < sortedDocs.length; i++) {
|
|
338
|
+
const doc = sortedDocs[i]!;
|
|
339
|
+
const isLastDoc = i === sortedDocs.length - 1;
|
|
340
|
+
if (!isInPage) {
|
|
341
|
+
if (cursorKeys) {
|
|
342
|
+
if (compareToCursor(doc, cursorKeys, cursorFields, order) > 0) {
|
|
343
|
+
isInPage = true;
|
|
344
|
+
} else {
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
if (hasEndBoundary && endCursorKeys) {
|
|
350
|
+
const cmp = compareToCursor(doc, endCursorKeys, cursorFields, order);
|
|
351
|
+
if (cmp >= 0) {
|
|
352
|
+
reachedEnd = false;
|
|
353
|
+
examined(endCursorKeys);
|
|
354
|
+
if (cmp === 0) {
|
|
355
|
+
rowsRead += 1;
|
|
356
|
+
bytesRead += getDocumentSize(doc);
|
|
357
|
+
readDocCursors.push(encodeCursor(doc, cursorFields));
|
|
358
|
+
if (filterFn(doc)) page.push(doc);
|
|
359
|
+
continueCursor = encodeCursor(doc, cursorFields);
|
|
360
|
+
}
|
|
361
|
+
break;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
rowsRead += 1;
|
|
365
|
+
bytesRead += getDocumentSize(doc);
|
|
366
|
+
readDocCursors.push(encodeCursor(doc, cursorFields));
|
|
367
|
+
examined(ReadSet.keyOf(cursorFields, doc as StoredDocument));
|
|
368
|
+
const hitLimit =
|
|
369
|
+
(!!maximumRowsRead && rowsRead >= maximumRowsRead) ||
|
|
370
|
+
(!!maximumBytesRead && bytesRead >= maximumBytesRead);
|
|
371
|
+
if (filterFn(doc)) page.push(doc);
|
|
372
|
+
if (hitLimit) {
|
|
373
|
+
reachedEnd = false;
|
|
374
|
+
pageStatus = "SplitRequired";
|
|
375
|
+
continueCursor = encodeCursor(doc, cursorFields);
|
|
376
|
+
break;
|
|
377
|
+
}
|
|
378
|
+
if (!endCursor && page.length >= pageSize) {
|
|
379
|
+
reachedEnd = isLastDoc;
|
|
380
|
+
if (!isLastDoc) continueCursor = encodeCursor(doc, cursorFields);
|
|
381
|
+
break;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
// A page bounded by an end cursor examined the range only up to it.
|
|
385
|
+
if (reachedEnd) examined(endCursorKeys ?? "end");
|
|
386
|
+
|
|
387
|
+
if (pageStatus === "SplitRequired" && readDocCursors.length >= 2) {
|
|
388
|
+
splitCursor = readDocCursors[Math.floor((readDocCursors.length - 1) / 2)]!;
|
|
389
|
+
} else if (
|
|
390
|
+
pageStatus === null &&
|
|
391
|
+
rowsRead > pageSize + 1 &&
|
|
392
|
+
readDocCursors.length >= 2
|
|
393
|
+
) {
|
|
394
|
+
pageStatus = "SplitRecommended";
|
|
395
|
+
splitCursor = readDocCursors[Math.floor((readDocCursors.length - 1) / 2)]!;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return {
|
|
399
|
+
page,
|
|
400
|
+
isDone: continueCursor === "_end_cursor",
|
|
401
|
+
continueCursor,
|
|
402
|
+
splitCursor,
|
|
403
|
+
pageStatus,
|
|
404
|
+
};
|
|
405
|
+
};
|