@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.
Files changed (46) hide show
  1. package/dist/internal/Attempt.d.ts +77 -0
  2. package/dist/internal/Attempt.d.ts.map +1 -0
  3. package/dist/internal/Attempt.js +154 -0
  4. package/dist/internal/Attempt.js.map +1 -0
  5. package/dist/internal/ConvexTest.d.ts +34 -0
  6. package/dist/internal/ConvexTest.d.ts.map +1 -0
  7. package/dist/internal/ConvexTest.js +59 -0
  8. package/dist/internal/ConvexTest.js.map +1 -0
  9. package/dist/internal/Execution.d.ts +167 -0
  10. package/dist/internal/Execution.d.ts.map +1 -0
  11. package/dist/internal/Execution.js +790 -0
  12. package/dist/internal/Execution.js.map +1 -0
  13. package/dist/internal/Globals.d.ts +11 -0
  14. package/dist/internal/Globals.d.ts.map +1 -0
  15. package/dist/internal/Globals.js +76 -0
  16. package/dist/internal/Globals.js.map +1 -0
  17. package/dist/internal/Kernel.d.ts +93 -0
  18. package/dist/internal/Kernel.d.ts.map +1 -0
  19. package/dist/internal/Kernel.js +91 -0
  20. package/dist/internal/Kernel.js.map +1 -0
  21. package/dist/internal/KernelQuery.d.ts +55 -0
  22. package/dist/internal/KernelQuery.d.ts.map +1 -0
  23. package/dist/internal/KernelQuery.js +205 -0
  24. package/dist/internal/KernelQuery.js.map +1 -0
  25. package/dist/internal/KernelRefusal.d.ts +77 -0
  26. package/dist/internal/KernelRefusal.d.ts.map +1 -0
  27. package/dist/internal/KernelRefusal.js +73 -0
  28. package/dist/internal/KernelRefusal.js.map +1 -0
  29. package/dist/internal/KernelValues.d.ts +136 -0
  30. package/dist/internal/KernelValues.d.ts.map +1 -0
  31. package/dist/internal/KernelValues.js +266 -0
  32. package/dist/internal/KernelValues.js.map +1 -0
  33. package/dist/internal/ReadSet.d.ts +55 -0
  34. package/dist/internal/ReadSet.d.ts.map +1 -0
  35. package/dist/internal/ReadSet.js +29 -0
  36. package/dist/internal/ReadSet.js.map +1 -0
  37. package/package.json +3 -3
  38. package/src/internal/Attempt.ts +185 -0
  39. package/src/internal/ConvexTest.ts +118 -0
  40. package/src/internal/Execution.ts +1430 -0
  41. package/src/internal/Globals.ts +93 -0
  42. package/src/internal/Kernel.ts +190 -0
  43. package/src/internal/KernelQuery.ts +405 -0
  44. package/src/internal/KernelRefusal.ts +88 -0
  45. package/src/internal/KernelValues.ts +725 -0
  46. 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
+ };