@proteinjs/db 1.30.0 → 1.32.0

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 (71) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/generated/index.js +1 -1
  3. package/dist/generated/index.js.map +1 -1
  4. package/dist/generated/test/index.d.ts.map +1 -1
  5. package/dist/generated/test/index.js +3 -1
  6. package/dist/generated/test/index.js.map +1 -1
  7. package/dist/index.d.ts +1 -0
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +1 -0
  10. package/dist/index.js.map +1 -1
  11. package/dist/src/CursorWindowPager.d.ts +74 -0
  12. package/dist/src/CursorWindowPager.d.ts.map +1 -0
  13. package/dist/src/CursorWindowPager.js +178 -0
  14. package/dist/src/CursorWindowPager.js.map +1 -0
  15. package/dist/src/Db.d.ts +20 -0
  16. package/dist/src/Db.d.ts.map +1 -1
  17. package/dist/src/Db.js +10 -0
  18. package/dist/src/Db.js.map +1 -1
  19. package/dist/src/MigrationRunner.d.ts +58 -1
  20. package/dist/src/MigrationRunner.d.ts.map +1 -1
  21. package/dist/src/MigrationRunner.js +108 -2
  22. package/dist/src/MigrationRunner.js.map +1 -1
  23. package/dist/src/RecordIterator.d.ts +16 -6
  24. package/dist/src/RecordIterator.d.ts.map +1 -1
  25. package/dist/src/RecordIterator.js +50 -37
  26. package/dist/src/RecordIterator.js.map +1 -1
  27. package/dist/src/schema/SchemaOperations.d.ts +7 -1
  28. package/dist/src/schema/SchemaOperations.d.ts.map +1 -1
  29. package/dist/src/schema/TableManager.d.ts +8 -0
  30. package/dist/src/schema/TableManager.d.ts.map +1 -1
  31. package/dist/src/schema/TableManager.js +71 -23
  32. package/dist/src/schema/TableManager.js.map +1 -1
  33. package/dist/src/tables/MigrationTable.d.ts +10 -0
  34. package/dist/src/tables/MigrationTable.d.ts.map +1 -1
  35. package/dist/src/tables/MigrationTable.js +1 -0
  36. package/dist/src/tables/MigrationTable.js.map +1 -1
  37. package/dist/test/CursorWindowPager.test.d.ts +2 -0
  38. package/dist/test/CursorWindowPager.test.d.ts.map +1 -0
  39. package/dist/test/CursorWindowPager.test.js +213 -0
  40. package/dist/test/CursorWindowPager.test.js.map +1 -0
  41. package/dist/test/index.d.ts +2 -0
  42. package/dist/test/index.d.ts.map +1 -1
  43. package/dist/test/index.js +2 -0
  44. package/dist/test/index.js.map +1 -1
  45. package/dist/test/reusable/RecordIteratorTests.d.ts +13 -0
  46. package/dist/test/reusable/RecordIteratorTests.d.ts.map +1 -0
  47. package/dist/test/reusable/RecordIteratorTests.js +448 -0
  48. package/dist/test/reusable/RecordIteratorTests.js.map +1 -0
  49. package/dist/test/util/DbTestEnvironment.d.ts.map +1 -1
  50. package/dist/test/util/DbTestEnvironment.js +2 -1
  51. package/dist/test/util/DbTestEnvironment.js.map +1 -1
  52. package/dist/test/util/tables/recordIteratorTestTables.d.ts +25 -0
  53. package/dist/test/util/tables/recordIteratorTestTables.d.ts.map +1 -0
  54. package/dist/test/util/tables/recordIteratorTestTables.js +38 -0
  55. package/dist/test/util/tables/recordIteratorTestTables.js.map +1 -0
  56. package/generated/index.ts +1 -1
  57. package/generated/test/index.ts +3 -1
  58. package/index.ts +1 -0
  59. package/package.json +3 -3
  60. package/src/CursorWindowPager.ts +153 -0
  61. package/src/Db.ts +18 -0
  62. package/src/MigrationRunner.ts +114 -2
  63. package/src/RecordIterator.ts +34 -24
  64. package/src/schema/SchemaOperations.ts +7 -1
  65. package/src/schema/TableManager.ts +36 -8
  66. package/src/tables/MigrationTable.ts +11 -1
  67. package/test/CursorWindowPager.test.ts +185 -0
  68. package/test/index.ts +2 -0
  69. package/test/reusable/RecordIteratorTests.ts +190 -0
  70. package/test/util/DbTestEnvironment.ts +2 -0
  71. package/test/util/tables/recordIteratorTestTables.ts +23 -0
@@ -0,0 +1,185 @@
1
+ /**
2
+ * CursorWindowPager (src/CursorWindowPager.ts) at the pure level: effective-sort derivation
3
+ * (id tiebreak appended, explicit id ends the list, byValues rejected), lexicographic anchor
4
+ * conditions, next-anchor derivation (short-window and null-tail exhaustion), anchor
5
+ * encode/decode fidelity, and fresh-query-per-window construction. The db round trip is a
6
+ * constructor-injected stub — no module mocking, the pager's `options.db` seam IS the
7
+ * injection point.
8
+ */
9
+ import moment from 'moment';
10
+ import { QueryBuilder } from '@proteinjs/db-query';
11
+ import { AnchoredWindow, CursorAnchor, CursorWindowPager, Db, SortCriteria, Table } from '@proteinjs/db';
12
+ // Load the reflection source graph so the house Serializer's custom serializers (moments)
13
+ // are registered — anchor encode/decode fidelity rides them.
14
+ import '../generated/index';
15
+
16
+ type Row = {
17
+ id: string;
18
+ name: string;
19
+ seq?: number | null;
20
+ stampedAt?: moment.Moment | null;
21
+ };
22
+
23
+ const table = { name: 'pager_test' } as Table<Row & { created: moment.Moment; updated: moment.Moment }>;
24
+ type PagerRow = Row & { created: moment.Moment; updated: moment.Moment };
25
+
26
+ const staged: { rows: Row[] } = { rows: [] };
27
+ const executed: QueryBuilder<PagerRow>[] = [];
28
+ const stubDb = {
29
+ query: async (_table: unknown, qb: QueryBuilder<PagerRow>) => {
30
+ executed.push(qb);
31
+ return staged.rows;
32
+ },
33
+ } as unknown as Db;
34
+
35
+ const createQuery = () => new QueryBuilder<PagerRow>(table.name);
36
+
37
+ const pager = (sort?: SortCriteria<PagerRow>[]) =>
38
+ new CursorWindowPager<PagerRow>(table, createQuery, sort, { db: stubDb });
39
+
40
+ /** House pattern: private internals reachable via a typed cast on the instance. */
41
+ type PagerInternals = {
42
+ effectiveSort: SortCriteria<PagerRow>[];
43
+ buildWindowQuery(anchor: CursorAnchor | null, windowSize: number): QueryBuilder<PagerRow>;
44
+ deriveNextAnchor(rows: Row[], windowSize: number): CursorAnchor | null;
45
+ };
46
+ const internals = (instance: CursorWindowPager<PagerRow>) => instance as unknown as PagerInternals;
47
+
48
+ const graphNodes = (qb: QueryBuilder<PagerRow>): any[] => qb.graph.nodes().map((id: string) => qb.graph.node(id));
49
+ const conditionNodes = (qb: QueryBuilder<PagerRow>) => graphNodes(qb).filter((node) => node?.type === 'CONDITION');
50
+ const logicalNodes = (qb: QueryBuilder<PagerRow>) => graphNodes(qb).filter((node) => node?.type === 'LOGICAL');
51
+ const paginationNode = (qb: QueryBuilder<PagerRow>) => graphNodes(qb).find((node) => node?.type === 'PAGINATION');
52
+ const sortNodes = (qb: QueryBuilder<PagerRow>) => graphNodes(qb).filter((node) => node?.type === 'SORT');
53
+
54
+ const row = (id: string, seq: number | null, at?: string | null): Row => ({
55
+ id,
56
+ name: id,
57
+ seq,
58
+ stampedAt: at ? moment(at) : at === null ? null : undefined,
59
+ });
60
+
61
+ beforeEach(() => {
62
+ staged.rows = [];
63
+ executed.length = 0;
64
+ });
65
+
66
+ describe('effective sort', () => {
67
+ it('appends the id tiebreak to the consumer sort', () => {
68
+ const instance = pager([{ field: 'seq', desc: true }]);
69
+ expect(internals(instance).effectiveSort).toEqual([
70
+ { field: 'seq', desc: true },
71
+ { field: 'id', desc: false },
72
+ ]);
73
+ });
74
+
75
+ it('no sort = pure id order', () => {
76
+ expect(internals(pager()).effectiveSort).toEqual([{ field: 'id', desc: false }]);
77
+ });
78
+
79
+ it('an explicit id criterion ends the list (axes after it can never influence order)', () => {
80
+ const instance = pager([
81
+ { field: 'id', desc: true },
82
+ { field: 'seq', desc: false },
83
+ ]);
84
+ expect(internals(instance).effectiveSort).toEqual([{ field: 'id', desc: true }]);
85
+ });
86
+
87
+ it('rejects byValues sorts — a CASE ordering has no comparable cursor axis', () => {
88
+ expect(() => pager([{ field: 'name', byValues: ['a', 'b'] }])).toThrow(/byValues/);
89
+ });
90
+ });
91
+
92
+ describe('buildWindowQuery', () => {
93
+ it('the first window carries no cursor conditions — just the effective sort and the window pagination', () => {
94
+ const qb = internals(pager([{ field: 'seq', desc: false }])).buildWindowQuery(null, 30);
95
+ expect(conditionNodes(qb)).toHaveLength(0);
96
+ expect(paginationNode(qb)).toMatchObject({ start: 0, end: 30 });
97
+ expect(sortNodes(qb).map((node) => node.criteria)).toEqual([
98
+ { field: 'seq', desc: false },
99
+ { field: 'id', desc: false },
100
+ ]);
101
+ });
102
+
103
+ it('an anchored window builds the lexicographic continuation: past-on-axis OR (equal-on-axis AND past-on-id)', () => {
104
+ const qb = internals(pager([{ field: 'seq', desc: true }])).buildWindowQuery({ values: [40, 'row-4'] }, 10);
105
+ // Disjunct 1: seq < 40. Disjunct 2: seq = 40 AND id > 'row-4'.
106
+ expect(conditionNodes(qb)).toEqual(
107
+ expect.arrayContaining([
108
+ expect.objectContaining({ field: 'seq', operator: '<', value: 40 }),
109
+ expect.objectContaining({ field: 'seq', operator: '=', value: 40 }),
110
+ expect.objectContaining({ field: 'id', operator: '>', value: 'row-4' }),
111
+ ])
112
+ );
113
+ expect(conditionNodes(qb)).toHaveLength(3);
114
+ const operators = logicalNodes(qb).map((node) => node.operator);
115
+ expect(operators).toContain('OR');
116
+ expect(operators).toContain('AND');
117
+ });
118
+
119
+ it('an ascending axis flips the past operator to `>`', () => {
120
+ const qb = internals(pager([{ field: 'seq', desc: false }])).buildWindowQuery({ values: [40, 'row-4'] }, 10);
121
+ expect(conditionNodes(qb)).toEqual(
122
+ expect.arrayContaining([expect.objectContaining({ field: 'seq', operator: '>', value: 40 })])
123
+ );
124
+ });
125
+
126
+ it('every window builds on a FRESH query — cursor conditions never accumulate', () => {
127
+ const instance = pager([{ field: 'seq', desc: true }]);
128
+ internals(instance).buildWindowQuery({ values: [40, 'row-4'] }, 10);
129
+ const second = internals(instance).buildWindowQuery({ values: [20, 'row-2'] }, 10);
130
+ expect(conditionNodes(second)).toHaveLength(3);
131
+ });
132
+ });
133
+
134
+ describe('deriveNextAnchor', () => {
135
+ const instance = pager([{ field: 'seq', desc: false }]);
136
+
137
+ it('a short window is exhausted', () => {
138
+ expect(internals(instance).deriveNextAnchor([row('a', 10)], 3)).toBeNull();
139
+ expect(internals(instance).deriveNextAnchor([], 3)).toBeNull();
140
+ });
141
+
142
+ it('a full window anchors at its tail row, one value per effective criterion', () => {
143
+ const anchor = internals(instance).deriveNextAnchor([row('a', 10), row('b', 20), row('c', 30)], 3);
144
+ expect(anchor).toEqual({ values: [30, 'c'] });
145
+ });
146
+
147
+ it('a full window with a NULL sort-value tail is exhausted (null rows collect at the tail; no anchor)', () => {
148
+ expect(internals(instance).deriveNextAnchor([row('a', 10), row('b', 20), row('c', null)], 3)).toBeNull();
149
+ });
150
+ });
151
+
152
+ describe('anchor encode/decode', () => {
153
+ it('round-trips values with type fidelity (moments survive)', () => {
154
+ const at = moment('2026-08-10T12:00:00.000Z');
155
+ const encoded = CursorWindowPager.encodeAnchor({ values: [at, 'row-1'] });
156
+ expect(typeof encoded).toBe('string');
157
+ const decoded = CursorWindowPager.decodeAnchor(encoded);
158
+ expect(moment.isMoment(decoded.values[0])).toBe(true);
159
+ expect((decoded.values[0] as moment.Moment).toISOString()).toBe(at.toISOString());
160
+ expect(decoded.values[1]).toBe('row-1');
161
+ });
162
+ });
163
+
164
+ describe('loadWindow', () => {
165
+ it('returns the queried rows with the derived next anchor, querying through the injected db', async () => {
166
+ const instance = pager([{ field: 'seq', desc: false }]);
167
+ staged.rows = [row('a', 10), row('b', 20), row('c', 30)];
168
+ const window: AnchoredWindow<PagerRow> = await instance.loadWindow(null, 3);
169
+ expect(window.rows).toBe(staged.rows);
170
+ expect(window.nextAnchor).toEqual({ values: [30, 'c'] });
171
+ expect(executed).toHaveLength(1);
172
+ expect(paginationNode(executed[0])).toMatchObject({ start: 0, end: 3 });
173
+ });
174
+
175
+ it('returns an exhausted window (null anchor) when the query comes back short', async () => {
176
+ const instance = pager([{ field: 'seq', desc: false }]);
177
+ staged.rows = [row('a', 10)];
178
+ const window = await instance.loadWindow({ values: [5, 'z'] }, 3);
179
+ expect(window.nextAnchor).toBeNull();
180
+ });
181
+
182
+ it('rejects a nonsensical window size', async () => {
183
+ await expect(pager().loadWindow(null, 0)).rejects.toThrow(/window size/i);
184
+ });
185
+ });
package/test/index.ts CHANGED
@@ -5,6 +5,7 @@ export * from './reusable/DynamicReferenceColumnTests';
5
5
  export * from './reusable/TransactionTests';
6
6
  export * from './reusable/CascadeDeleteTests';
7
7
  export * from './reusable/PreloadReferencesTests';
8
+ export * from './reusable/RecordIteratorTests';
8
9
 
9
10
  export * from './util/testUser';
10
11
 
@@ -12,5 +13,6 @@ export * from './util/tables/cascadeDeleteTestTables';
12
13
  export * from './util/tables/columnTypesTestTables';
13
14
  export * from './util/tables/crudTestTables';
14
15
  export * from './util/tables/dynamicReferenceColumnTestTables';
16
+ export * from './util/tables/recordIteratorTestTables';
15
17
  export * from './util/tables/tableManagerTestTables';
16
18
  export * from './util/tables/transactionTestTables';
@@ -0,0 +1,190 @@
1
+ import moment, { Moment } from 'moment';
2
+ import { QueryBuilder } from '@proteinjs/db-query';
3
+ import {
4
+ DbDriver,
5
+ Db,
6
+ Record,
7
+ Table,
8
+ DefaultTransactionContextFactory,
9
+ RecordIterator,
10
+ QueryBuilderFactory,
11
+ } from '@proteinjs/db';
12
+ import { DbTestEnvironment } from '../util/DbTestEnvironment';
13
+ import { recordIteratorTestTables, IterationRow } from '../util/tables/recordIteratorTestTables';
14
+
15
+ /**
16
+ * RecordIterator's iteration contract under CONCURRENT WRITES — the offset-window drift class.
17
+ *
18
+ * Offset paging frames every window by position (`OFFSET n`), so a row deleted behind the
19
+ * iterator slides an unvisited row into the already-consumed range (SKIP), and a row inserted
20
+ * ahead of the consumed prefix slides a consumed row back into the next window (DUPLICATE).
21
+ * Cursor windows frame by the tail row's sort values instead, so concurrent writes can never
22
+ * shift the frame. These tests mutate the table exactly at a window boundary (window size 3,
23
+ * mutation after the 3rd yield) and assert the OUTCOME: every seeded row visited exactly once.
24
+ */
25
+ export const recordIteratorTests = (
26
+ driver: DbDriver,
27
+ transactionContextFactory: DefaultTransactionContextFactory,
28
+ dropTable: (table: Table<any>) => Promise<void>
29
+ ) => {
30
+ return () => {
31
+ const db = new Db(driver, undefined, transactionContextFactory);
32
+ const testEnv = new DbTestEnvironment(driver, dropTable);
33
+ const table = recordIteratorTestTables.IterationRow;
34
+ const WINDOW = 3;
35
+
36
+ beforeAll(async () => await testEnv.beforeAll(), 30000);
37
+ afterAll(async () => await testEnv.afterAll(), 30000);
38
+
39
+ beforeEach(async () => {
40
+ await db.delete(
41
+ table,
42
+ new QueryBuilderFactory().getQueryBuilder(table).condition({ field: 'id', operator: 'IS NOT NULL' })
43
+ );
44
+ });
45
+
46
+ /** Seed rows r01..rNN with seq 10, 20, 30, ... (gaps leave room to insert ahead of them). */
47
+ const seed = async (count: number): Promise<IterationRow[]> => {
48
+ const rows: IterationRow[] = [];
49
+ for (let i = 1; i <= count; i++) {
50
+ rows.push(
51
+ await db.insert(table, { name: `r${String(i).padStart(2, '0')}`, seq: i * 10 } as Omit<
52
+ IterationRow,
53
+ keyof Record
54
+ >)
55
+ );
56
+ }
57
+ return rows;
58
+ };
59
+
60
+ const seqAscQuery = () =>
61
+ new QueryBuilderFactory()
62
+ .getQueryBuilder(table)
63
+ .sort([{ field: 'seq', desc: false }]) as QueryBuilder<IterationRow>;
64
+
65
+ /** Iterate, running `atWindowBoundary` right after the first window's last row is consumed. */
66
+ const iterateWithBoundaryMutation = async (
67
+ query: QueryBuilder<IterationRow> | Partial<IterationRow>,
68
+ atWindowBoundary: () => Promise<void>
69
+ ): Promise<string[]> => {
70
+ const iterator = new RecordIterator<IterationRow>(table, query, WINDOW, db);
71
+ const visited: string[] = [];
72
+ let mutated = false;
73
+ for await (const row of iterator) {
74
+ visited.push(row.name);
75
+ if (!mutated && visited.length === WINDOW) {
76
+ mutated = true;
77
+ await atWindowBoundary();
78
+ }
79
+ }
80
+ return visited;
81
+ };
82
+
83
+ test('a row deleted behind the iterator never causes surviving rows to be skipped', async () => {
84
+ await seed(9);
85
+ const visited = await iterateWithBoundaryMutation(seqAscQuery(), async () => {
86
+ // Delete an ALREADY-CONSUMED row: under offset paging the whole set slides one position
87
+ // left, so the next `OFFSET 3` window starts one row too far — r04 is skipped.
88
+ const deleted = await db.delete(table, { name: 'r01' });
89
+ expect(deleted).toBe(1);
90
+ });
91
+ expect(visited).toEqual(['r01', 'r02', 'r03', 'r04', 'r05', 'r06', 'r07', 'r08', 'r09']);
92
+ });
93
+
94
+ test('a row inserted ahead of the consumed prefix never duplicates rows', async () => {
95
+ await seed(9);
96
+ const visited = await iterateWithBoundaryMutation(seqAscQuery(), async () => {
97
+ // Insert a row that sorts BEFORE every consumed row: under offset paging the set slides
98
+ // one position right, so the next `OFFSET 3` window re-serves the last consumed row.
99
+ await db.insert(table, { name: 'x-front', seq: 1 } as Omit<IterationRow, keyof Record>);
100
+ });
101
+ // Every seeded row exactly once; whether the concurrent insert surfaces is not part of the
102
+ // contract (a cursor window anchored past it correctly excludes it), but duplicates are.
103
+ const seeded = visited.filter((name) => name !== 'x-front');
104
+ expect(seeded).toEqual(['r01', 'r02', 'r03', 'r04', 'r05', 'r06', 'r07', 'r08', 'r09']);
105
+ });
106
+
107
+ test('default iteration (no sort) is complete under a concurrent delete', async () => {
108
+ const rows = await seed(7);
109
+ // With no consumer sort the iterator orders by id — learn the iteration order first.
110
+ const idOrder = rows
111
+ .map((row) => ({ id: row.id, name: row.name }))
112
+ .sort((a, b) => (a.id < b.id ? -1 : 1))
113
+ .map((row) => row.name);
114
+ const visited = await iterateWithBoundaryMutation({}, async () => {
115
+ // Delete the first row in iteration order (already consumed).
116
+ const deleted = await db.delete(table, { name: idOrder[0] });
117
+ expect(deleted).toBe(1);
118
+ });
119
+ expect(visited).toEqual(idOrder);
120
+ });
121
+
122
+ test('tied sort values across a window boundary are all visited exactly once', async () => {
123
+ // Six rows over two stampedAt values, window size 3: the boundary falls INSIDE the t1 tie
124
+ // (t1 fills the first window's tail and continues into the second). A position-framed or
125
+ // single-axis cursor breaks here; the id tiebreak walks through the tie.
126
+ const t1 = moment('2026-08-10T12:00:00.000Z');
127
+ const t2 = moment('2026-08-09T12:00:00.000Z');
128
+ const stamps = [t1, t1, t1, t1, t2, t2];
129
+ for (let i = 0; i < stamps.length; i++) {
130
+ await db.insert(table, {
131
+ name: `s${String(i + 1).padStart(2, '0')}`,
132
+ stampedAt: stamps[i],
133
+ } as Omit<IterationRow, keyof Record>);
134
+ }
135
+ const query = new QueryBuilderFactory()
136
+ .getQueryBuilder(table)
137
+ .sort([{ field: 'stampedAt', desc: true }]) as QueryBuilder<IterationRow>;
138
+ const iterator = new RecordIterator<IterationRow>(table, query, WINDOW, db);
139
+ const visited: { name: string; stampedAt?: Moment | null }[] = [];
140
+ for await (const row of iterator) {
141
+ visited.push({ name: row.name, stampedAt: row.stampedAt });
142
+ }
143
+ expect(visited.map((v) => v.name).sort()).toEqual(['s01', 's02', 's03', 's04', 's05', 's06']);
144
+ // Order honors the sort: all t1 rows before all t2 rows.
145
+ const t1Names = visited.slice(0, 4).map((v) => moment(v.stampedAt!).toISOString());
146
+ expect(t1Names).toEqual(Array(4).fill(t1.toISOString()));
147
+ });
148
+
149
+ test('consumed rows are never re-served when id order opposes the sort order (equals-guarded continuation)', async () => {
150
+ // Insert in REVERSE sort order so id order (insertion order) OPPOSES the seq-ascending
151
+ // iteration: every already-consumed row carries a LATER id than the anchor row's. This is
152
+ // the dataset that convicts a degenerate continuation: without the equals guard on the id
153
+ // disjunct (`seq = anchor AND id > anchor-id`), the disjunct is a bare `id > anchor-id`
154
+ // catch-all that re-serves the whole consumed prefix (and can re-anchor forever). The
155
+ // forward-seeded suites above can never catch that — their id order AGREES with the sort,
156
+ // so a consumed row's id always sorts before the anchor's.
157
+ for (let i = 6; i >= 1; i--) {
158
+ await db.insert(table, { name: `v${String(i).padStart(2, '0')}`, seq: i * 10 } as Omit<
159
+ IterationRow,
160
+ keyof Record
161
+ >);
162
+ }
163
+ const iterator = new RecordIterator<IterationRow>(table, seqAscQuery(), WINDOW, db);
164
+ const visited: string[] = [];
165
+ for await (const row of iterator) {
166
+ visited.push(row.name);
167
+ if (visited.length > 12) {
168
+ break; // a re-anchoring loop never terminates on its own — cap it and let the assert convict
169
+ }
170
+ }
171
+ expect(visited).toEqual(['v01', 'v02', 'v03', 'v04', 'v05', 'v06']);
172
+ });
173
+
174
+ test('iteration never mutates the caller query builder', async () => {
175
+ await seed(4);
176
+ const qb = seqAscQuery();
177
+ const nodesBefore = qb.graph.nodes().length;
178
+ const iterator = new RecordIterator<IterationRow>(table, qb, WINDOW, db);
179
+ const visited: string[] = [];
180
+ for await (const row of iterator) {
181
+ visited.push(row.name);
182
+ }
183
+ expect(visited).toHaveLength(4);
184
+ // The old implementation paginated the CALLER's builder (and re-ran column-query
185
+ // injection into it every page); iteration must build windows on fresh copies instead.
186
+ expect(qb.graph.nodes().length).toBe(nodesBefore);
187
+ expect(qb.paginationNodeId).toBeUndefined();
188
+ });
189
+ };
190
+ };
@@ -4,6 +4,7 @@ import { cascadeDeleteTestTables } from './tables/cascadeDeleteTestTables';
4
4
  import { columnTypesTestTables } from './tables/columnTypesTestTables';
5
5
  import { crudTestTables } from './tables/crudTestTables';
6
6
  import { dynamicReferenceTestTables } from './tables/dynamicReferenceColumnTestTables';
7
+ import { recordIteratorTestTables } from './tables/recordIteratorTestTables';
7
8
  import { tableManagerTestTables } from './tables/tableManagerTestTables';
8
9
  import { transactionTestTables } from './tables/transactionTestTables';
9
10
 
@@ -12,6 +13,7 @@ const TABLES: Table<any>[] = [
12
13
  ...Object.values(columnTypesTestTables),
13
14
  ...Object.values(crudTestTables),
14
15
  ...Object.values(dynamicReferenceTestTables),
16
+ ...Object.values(recordIteratorTestTables),
15
17
  ...Object.values(tableManagerTestTables),
16
18
  ...Object.values(transactionTestTables),
17
19
  ];
@@ -0,0 +1,23 @@
1
+ import { Moment } from 'moment';
2
+ import { withRecordColumns, Record, IntegerColumn, StringColumn, DateTimeColumn, Table } from '@proteinjs/db';
3
+
4
+ export interface IterationRow extends Record {
5
+ name: string;
6
+ /** Deterministic integer sort axis (unique per row unless a test seeds ties). */
7
+ seq?: number | null;
8
+ /** Datetime sort axis for tie/round-trip coverage (values controlled by the tests). */
9
+ stampedAt?: Moment | null;
10
+ }
11
+
12
+ export class RecordIteratorTestTable extends Table<IterationRow> {
13
+ name = 'db_test_record_iterator';
14
+ columns = withRecordColumns<IterationRow>({
15
+ name: new StringColumn('name'),
16
+ seq: new IntegerColumn('seq'),
17
+ stampedAt: new DateTimeColumn('stamped_at'),
18
+ });
19
+ }
20
+
21
+ export const recordIteratorTestTables = {
22
+ IterationRow: new RecordIteratorTestTable() as Table<IterationRow>,
23
+ };