|
1 | 1 | // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
2 | 2 |
|
3 | 3 | /** |
4 | | - * Deterministic paged reads for the in-memory driver (objectui#3106) — the |
5 | | - * contract on `IDataDriver.find`, checked against the shared cases in |
6 | | - * `@objectstack/spec/data`. |
| 4 | + * Deterministic paged reads for the in-memory driver (objectui#3106, |
| 5 | + * objectstack#4363) — the contract on `IDataDriver.find`, checked against the |
| 6 | + * shared cases in `@objectstack/spec/data`. |
7 | 7 | * |
8 | | - * This driver needed **no change** to satisfy it, and that is worth a suite |
9 | | - * rather than a shrug. It sorts with `Array#sort`, which ES2019 onward |
10 | | - * guarantees is stable, over a table array whose order does not move between |
11 | | - * two reads — so equal keys keep the same relative arrangement on page 2 that |
12 | | - * they had on page 1, which is precisely what the contract asks for. |
| 8 | + * This driver needed **no change** to satisfy either half, and that is worth a |
| 9 | + * suite rather than a shrug. |
| 10 | + * |
| 11 | + * - **Sorted pages.** It sorts with `Array#sort`, which ES2019 onward |
| 12 | + * guarantees is stable, over a table array whose order does not move between |
| 13 | + * two reads — so equal keys keep the same relative arrangement on page 2 that |
| 14 | + * they had on page 1. |
| 15 | + * - **Unsorted pages.** With no `orderBy` it slices that same array directly, |
| 16 | + * and the array is the storage: two reads with no write between them see the |
| 17 | + * identical sequence. There is no plan to change its mind and no natural |
| 18 | + * order to move, so the walk partitions the set without a sort being imposed. |
| 19 | + * The contract asks for determinism, not for id order, and this driver |
| 20 | + * supplies it from a different direction than SQL and MongoDB do. |
13 | 21 | * |
14 | 22 | * The guarantee is therefore load-bearing but implicit: it rests on `sort` |
15 | | - * being stable and on `applySort` copying rather than reordering the table in |
16 | | - * place. Both are easy to lose in a refactor that looks like a speed-up — a |
17 | | - * hand-rolled quicksort, or sorting the backing array directly — and neither |
18 | | - * loss would fail any other test in this package. That is what this file is |
19 | | - * for: it holds the property against the day the implementation changes, which |
20 | | - * is the only day it could break. |
| 23 | + * being stable, on `applySort` copying rather than reordering the table in |
| 24 | + * place, and on `find` slicing a copy rather than the live table. All are easy |
| 25 | + * to lose in a refactor that looks like a speed-up — a hand-rolled quicksort, |
| 26 | + * sorting the backing array directly, a "reuse the array" allocation saving — |
| 27 | + * and no other test in this package would fail. That is what this file is for: |
| 28 | + * it holds the property against the day the implementation changes, which is |
| 29 | + * the only day it could break. |
21 | 30 | */ |
22 | 31 |
|
23 | 32 | import { describe, it, expect, beforeEach } from 'vitest'; |
24 | | -import { PAGINATION_ALL_IDS, PAGINATION_CASES, PAGINATION_ROWS } from '@objectstack/spec/data'; |
| 33 | +import { |
| 34 | + PAGINATION_ALL_IDS, |
| 35 | + PAGINATION_CASES, |
| 36 | + PAGINATION_ROWS, |
| 37 | + PAGINATION_UNORDERED_CASES, |
| 38 | +} from '@objectstack/spec/data'; |
25 | 39 | import { InMemoryDriver } from './memory-driver.js'; |
26 | 40 |
|
27 | 41 | describe('InMemoryDriver — paged reads are a partition of the result set (objectui#3106)', () => { |
@@ -67,4 +81,33 @@ describe('InMemoryDriver — paged reads are a partition of the result set (obje |
67 | 81 | expect(paged.map((r) => r.id)).toEqual(whole.map((r: any) => r.id)); |
68 | 82 | }); |
69 | 83 | } |
| 84 | + |
| 85 | + for (const testCase of PAGINATION_UNORDERED_CASES) { |
| 86 | + it(`visits every row exactly once with NO orderBy at all — ${testCase.name}`, async () => { |
| 87 | + const seen: string[] = []; |
| 88 | + for (let offset = 0; offset < PAGINATION_ROWS.length; offset += testCase.pageSize) { |
| 89 | + const page = await driver.find('ticket', { limit: testCase.pageSize, offset } as any); |
| 90 | + seen.push(...page.map((r: any) => String(r.id))); |
| 91 | + } |
| 92 | + |
| 93 | + expect(seen).toHaveLength(PAGINATION_ALL_IDS.length); |
| 94 | + expect(new Set(seen).size).toBe(PAGINATION_ALL_IDS.length); |
| 95 | + expect([...seen].sort()).toEqual([...PAGINATION_ALL_IDS].sort()); |
| 96 | + }); |
| 97 | + |
| 98 | + it(`page boundaries are invisible with NO orderBy — ${testCase.name}`, async () => { |
| 99 | + const paged: any[] = []; |
| 100 | + for (let offset = 0; offset < PAGINATION_ROWS.length; offset += testCase.pageSize) { |
| 101 | + const page = await driver.find('ticket', { limit: testCase.pageSize, offset } as any); |
| 102 | + paged.push(...page); |
| 103 | + } |
| 104 | + |
| 105 | + // Concatenating the pages reproduces the unpaged read exactly. Note this |
| 106 | + // is insertion order, NOT id order: the contract asks for a partition, |
| 107 | + // and this driver's storage already provides one, so nothing is imposed. |
| 108 | + const whole = await driver.find('ticket', {} as any); |
| 109 | + expect(paged.map((r) => r.id)).toEqual(whole.map((r: any) => r.id)); |
| 110 | + expect(paged.map((r) => r.id)).toEqual(PAGINATION_ROWS.map((r) => r.id)); |
| 111 | + }); |
| 112 | + } |
70 | 113 | }); |
0 commit comments