@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
package/index.ts CHANGED
@@ -10,6 +10,7 @@ export * from './src/reference/Reference';
10
10
  export * from './src/UpdatePreserving';
11
11
  export * from './src/reference/ReferenceCache';
12
12
  export * from './src/RecordIterator';
13
+ export * from './src/CursorWindowPager';
13
14
  export * from './src/source/SourceRecord';
14
15
  export * from './src/source/SourceRecordRepo';
15
16
  export * from './src/MigrationRunner';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proteinjs/db",
3
- "version": "1.30.0",
3
+ "version": "1.32.0",
4
4
  "main": "./dist/generated/index.js",
5
5
  "types": "./dist/generated/index.d.ts",
6
6
  "exports": {
@@ -41,7 +41,7 @@
41
41
  "test": "jest --passWithNoTests"
42
42
  },
43
43
  "dependencies": {
44
- "@proteinjs/db-query": "^1.6.3",
44
+ "@proteinjs/db-query": "^1.7.0",
45
45
  "@proteinjs/logger": "^1.0.21",
46
46
  "@proteinjs/reflection": "^1.1.12",
47
47
  "@proteinjs/serializer": "^1.1.10",
@@ -66,5 +66,5 @@
66
66
  "ts-jest": "29.1.1",
67
67
  "typescript": "5.2.2"
68
68
  },
69
- "gitHead": "fd3dd48fae93c6da8c0b3decd6dd30a506a9109a"
69
+ "gitHead": "edfed2d7826c7e42ddc0ae84bf97cda11614c1f0"
70
70
  }
@@ -0,0 +1,153 @@
1
+ import { Serializer } from '@proteinjs/serializer';
2
+ import { Condition, LogicalGroup, QueryBuilder, SortCriteria } from '@proteinjs/db-query';
3
+ import { Db, getDb } from './Db';
4
+ import { QueryOptions } from './services/DbService';
5
+ import { Table } from './Table';
6
+ import { Record } from './Record';
7
+
8
+ /**
9
+ * A cursor anchor names the last row a window served: the row's value for each effective sort
10
+ * criterion, in criteria order. The final criterion is always the unique `id` axis, so an
11
+ * anchor always identifies exactly one row — ties on the other axes can never blur where the
12
+ * next window starts.
13
+ */
14
+ export type CursorAnchor = {
15
+ values: unknown[];
16
+ };
17
+
18
+ export type AnchoredWindow<T> = {
19
+ rows: T[];
20
+ /** Anchor for the window after this one; null = the data set is exhausted. */
21
+ nextAnchor: CursorAnchor | null;
22
+ };
23
+
24
+ /**
25
+ * THE owner of cursor-window paging over a table query — every windowed consumer (the
26
+ * server-side `RecordIterator`, the UI's `QueryCursorLoader`) frames its windows here.
27
+ *
28
+ * Each window builds a FRESH query (builders are mutable) carrying a lexicographic
29
+ * continuation past the anchor row, the effective sort, and `paginate(0, windowSize)` — so
30
+ * rows inserted or deleted while paging can never shift the window frame, the offset-paging
31
+ * drift class (`OFFSET n` frames windows by position; concurrent writes slide unvisited rows
32
+ * into the consumed range or consumed rows back into the next window).
33
+ *
34
+ * The effective sort is the consumer's criteria with a unique `id` tiebreak appended (an
35
+ * explicit `id` criterion ends the list — axes after it can never influence order). Ties on
36
+ * consumer axes therefore continue across window boundaries instead of being skipped by a
37
+ * bare `field < cursor`. Sort axes should be non-null columns: a window whose tail carries a
38
+ * NULL sort value ends paging (a null can't anchor a lexicographic continuation — under a
39
+ * descending sort, null rows collect at the tail). `byValues` sorts cannot be paged by cursor
40
+ * and are rejected.
41
+ *
42
+ * Anchors are plain in-memory values; `encodeAnchor`/`decodeAnchor` (house `Serializer`, so
43
+ * dates/moments survive) turn them into strings for consumers whose cursors must serialize —
44
+ * e.g. react-query page params.
45
+ */
46
+ export class CursorWindowPager<T extends Record> {
47
+ private effectiveSort: SortCriteria<T>[];
48
+
49
+ /**
50
+ * @param table the table to load windows from
51
+ * @param createQuery factory producing a FRESH conditions-only QueryBuilder per call —
52
+ * every window builds its own to hang the cursor conditions, sort, and pagination on
53
+ * @param sort the consumer's sort criteria; the effective sort appends the `id` tiebreak.
54
+ * Omitted/empty = pure `id` order (the complete-iteration default).
55
+ * @param options `db` to ride a specific Db (scoped/system/transactional); defaults to
56
+ * `getDb()` per window. `queryOptions` pass through to every window query.
57
+ */
58
+ constructor(
59
+ private table: Table<T>,
60
+ private createQuery: () => QueryBuilder<T>,
61
+ sort?: SortCriteria<T>[],
62
+ private options?: { db?: Db; queryOptions?: QueryOptions<T> }
63
+ ) {
64
+ this.effectiveSort = this.normalizeSort(sort ?? []);
65
+ }
66
+
67
+ /** Load one window: the first when `anchor` is null, otherwise the rows past the anchor. */
68
+ async loadWindow(anchor: CursorAnchor | null, windowSize: number): Promise<AnchoredWindow<T>> {
69
+ if (windowSize < 1) {
70
+ throw new Error(`Cursor window size must be at least 1 (got ${windowSize})`);
71
+ }
72
+
73
+ const qb = this.buildWindowQuery(anchor, windowSize);
74
+ const db = this.options?.db ?? getDb();
75
+ const rows = await db.query(this.table, qb, this.options?.queryOptions);
76
+ return { rows, nextAnchor: this.deriveNextAnchor(rows, windowSize) };
77
+ }
78
+
79
+ static encodeAnchor(anchor: CursorAnchor): string {
80
+ return Serializer.serialize(anchor);
81
+ }
82
+
83
+ static decodeAnchor(encoded: string): CursorAnchor {
84
+ return Serializer.deserialize(encoded);
85
+ }
86
+
87
+ private buildWindowQuery(anchor: CursorAnchor | null, windowSize: number): QueryBuilder<T> {
88
+ const qb = this.createQuery();
89
+ if (anchor) {
90
+ qb.or(this.anchorConditions(anchor));
91
+ }
92
+ return qb.sort(this.effectiveSort).paginate({ start: 0, end: windowSize });
93
+ }
94
+
95
+ /**
96
+ * Lexicographic continuation past the anchor row: one disjunct per sort criterion — equal on
97
+ * every earlier criterion, strictly past on this one (`<` descending, `>` ascending). The
98
+ * final `id` disjunct is what walks through ties on the consumer's axes.
99
+ */
100
+ private anchorConditions(anchor: CursorAnchor): Array<Condition<T> | LogicalGroup<T>> {
101
+ return this.effectiveSort.map((criterion, index) => {
102
+ const past: Condition<T> = {
103
+ field: criterion.field,
104
+ operator: criterion.desc ? '<' : '>',
105
+ value: anchor.values[index] as T[keyof T],
106
+ };
107
+ if (index === 0) {
108
+ return past;
109
+ }
110
+ const equals: Condition<T>[] = this.effectiveSort.slice(0, index).map((earlier, i) => ({
111
+ field: earlier.field,
112
+ operator: '=',
113
+ value: anchor.values[i] as T[keyof T],
114
+ }));
115
+ return { operator: 'AND', children: [...equals, past] };
116
+ });
117
+ }
118
+
119
+ private deriveNextAnchor(rows: T[], windowSize: number): CursorAnchor | null {
120
+ // A short window means the data set is exhausted; a full one implies more may exist —
121
+ // the next (short or empty) window settles it.
122
+ if (rows.length < windowSize) {
123
+ return null;
124
+ }
125
+
126
+ const tail = rows[rows.length - 1] as unknown as { [field: string]: unknown };
127
+ const values = this.effectiveSort.map((criterion) => tail[criterion.field as string]);
128
+ // A null sort value can't anchor a lexicographic continuation; under a descending sort
129
+ // null rows collect at the tail, so the data set's sortable rows are exhausted here.
130
+ if (values.some((value) => value == null)) {
131
+ return null;
132
+ }
133
+
134
+ return { values };
135
+ }
136
+
137
+ private normalizeSort(sort: SortCriteria<T>[]): SortCriteria<T>[] {
138
+ for (const criterion of sort) {
139
+ if (criterion.byValues && criterion.byValues.length > 0) {
140
+ throw new Error(
141
+ `Cursor windows cannot page a byValues sort (field '${String(criterion.field)}'): a CASE ordering has no comparable cursor axis`
142
+ );
143
+ }
144
+ }
145
+
146
+ const idIndex = sort.findIndex((criterion) => criterion.field === 'id');
147
+ if (idIndex >= 0) {
148
+ return sort.slice(0, idIndex + 1);
149
+ }
150
+
151
+ return [...sort, { field: 'id' as keyof T, desc: false }];
152
+ }
153
+ }
package/src/Db.ts CHANGED
@@ -59,6 +59,14 @@ export interface DefaultDbDriverFactory extends Loadable {
59
59
  export interface DbDriver {
60
60
  getDbName(): string;
61
61
  createDbIfNotExists(): Promise<void>;
62
+ /**
63
+ * Create the named database. When `ddl` is provided the statements are applied as part of
64
+ * creation (on Spanner: `CreateDatabase.extra_statements` — one operation, atomic with the
65
+ * create), so the database is born with its full schema. Fails if the database already exists.
66
+ */
67
+ createDb(name: string, options?: { ddl?: string[] }): Promise<void>;
68
+ /** Drop the named database. */
69
+ dropDb(name: string): Promise<void>;
62
70
  start?(): Promise<void>;
63
71
  stop?(): Promise<void>;
64
72
  getTableManager(): TableManager;
@@ -517,6 +525,16 @@ export class Db<R extends Record = Record> implements DbService<R> {
517
525
  }
518
526
  }
519
527
 
528
+ /**
529
+ * Run a query. Column queries (scope guards etc.) are always applied.
530
+ *
531
+ * PAGING: this method is also the cursor-window surface — `CursorWindowPager` composes each
532
+ * window as a fresh QueryBuilder (cursor conditions + sort + `paginate(0, windowSize)`)
533
+ * through here, so every window rides the caller's driver, ambient transaction, and column
534
+ * queries. Iterating consumers go through it (`RecordIterator` server-side,
535
+ * `QueryCursorLoader` in the UI) instead of positional `paginate(start, end)` offsets,
536
+ * which drift under concurrent writes (rows slide across window frames).
537
+ */
520
538
  async query<T extends R>(table: Table<T>, query: Query<T>, options?: QueryOptions<T>): Promise<T[]> {
521
539
  const qb = new QueryBuilderFactory().getQueryBuilder(table, query);
522
540
 
@@ -4,12 +4,33 @@ import { Table } from './Table';
4
4
  import { SourceRecordRepo } from './source/SourceRecordRepo';
5
5
  import { MigrationRunnerService, getMigrationRunnerService } from './services/MigrationRunnerService';
6
6
  import { Migration, MigrationTable } from './tables/MigrationTable';
7
+ import { QueryBuilderFactory } from './QueryBuilderFactory';
7
8
  import { Service } from '@proteinjs/service';
8
9
  import { Logger } from '@proteinjs/logger';
9
10
 
10
11
  export const getMigrationRunner = () =>
11
12
  typeof self === 'undefined' ? new MigrationRunner() : (getMigrationRunnerService() as MigrationRunner);
12
13
 
14
+ /**
15
+ * What one deploy-gated series run did (plans/POST_RELEASE_QUEUE.md 27f). Ids appear in ledger
16
+ * order (oldest-first). The caller's gate is `failed`: present → the series stopped there, the
17
+ * deploy Job must exit non-zero, the rollout must not advance.
18
+ */
19
+ export interface MigrationSeriesSummary {
20
+ /** Ran to success in this series. */
21
+ applied: string[];
22
+ /** Excluded by the explicit `manual` flag (they keep the Migrations-page flow). */
23
+ skippedManual: string[];
24
+ /** Already in 'success' status — skipped (ensureMigrationRun's idempotence). */
25
+ alreadyApplied: string[];
26
+ /** Ledger rows with no source record (a loader deleted after its run; the table keeps history). */
27
+ unresolved: string[];
28
+ /** The failure that stopped the series, if any. */
29
+ failed?: { id: string; description: string; failureMessage?: string };
30
+ /** Ordered after the failure — never started (later migrations may build on earlier ones). */
31
+ notAttempted: string[];
32
+ }
33
+
13
34
  export class MigrationRunner implements MigrationRunnerService {
14
35
  private logger = new Logger({ name: this.constructor.name });
15
36
  public serviceMetadata: Service['serviceMetadata'] = {
@@ -44,18 +65,109 @@ export class MigrationRunner implements MigrationRunnerService {
44
65
  *
45
66
  * 'ensure' = idempotent: a row already in 'success' status is logged and skipped. A prior
46
67
  * 'failure' row is retried by design — a fixed migration should run on the next boot.
68
+ *
69
+ * Returns the migration with its final run state (the skipped row, or the run's outcome) —
70
+ * the series runner ({@link runPendingMigrations}) gates on it.
47
71
  */
48
- async ensureMigrationRun(id: string): Promise<void> {
72
+ async ensureMigrationRun(id: string): Promise<Migration> {
49
73
  const migrationTable: Table<Migration> = new MigrationTable();
50
74
  const db = getDbAsSystem();
51
75
  const migrationRow = await db.get(migrationTable, { id });
52
76
  if (migrationRow?.status === 'success') {
53
77
  this.logger.info({ message: `Migration (${id}) already applied, skipping` });
54
- return;
78
+ return migrationRow;
55
79
  }
56
80
 
57
81
  const migration = this.resolveMigration(migrationTable, id);
58
82
  await this.runAndRecord(migrationTable, migration, () => db);
83
+ return migration;
84
+ }
85
+
86
+ /**
87
+ * Deploy-path API (plans/POST_RELEASE_QUEUE.md 27f): the deploy pipeline's migration Job calls
88
+ * this AFTER `new Db().init()` (schema sync + source-record sync — every source-declared
89
+ * migration has a ledger row by then) and BEFORE the rollout advances. Pod boot never calls it:
90
+ * boot stays migration-free (the startupProbe invariant — DbInitStartupTask is schema sync and
91
+ * source records only).
92
+ *
93
+ * Discovery + order: the migration LEDGER (the migration table) is the authority. Rows run in
94
+ * SERIES, oldest-first by the row's `created` (id tiebreak) — a migration that shipped in an
95
+ * earlier release always runs before a later one. Never parallel.
96
+ *
97
+ * Policy:
98
+ * - `manual: true` (the source record's declaration) is EXCLUDED — the explicit non-automatable
99
+ * class keeps the Migrations-page flow ({@link Migration.manual}).
100
+ * - rows already in 'success' are skipped; any other status is pending — including 'running'
101
+ * (a crashed earlier Job): {@link ensureMigrationRun} re-runs it.
102
+ * - rows with no source record are history (a loader deleted after its run) — skipped,
103
+ * reported as `unresolved`.
104
+ * - the FIRST failure STOPS the series (later migrations may build on earlier ones). The
105
+ * caller exits non-zero, the Job fails, the rollout does not advance.
106
+ *
107
+ * EXPAND-CONTRACT INVARIANT (documented at this seam on purpose): every automated migration —
108
+ * and the schema sync that fronts it — must be backward-compatible with the STILL-RUNNING old
109
+ * release, because the old pods keep serving while this runs and keep serving indefinitely if
110
+ * it fails. "Roll back" = do not advance the code; DDL and applied migrations are never
111
+ * un-applied (Spanner DDL is not transactional-reversible). Contractions (drops, rewrites that
112
+ * break the old reader) belong in the `manual` class, run only after every consumer of the old
113
+ * shape is gone.
114
+ */
115
+ async runPendingMigrations(): Promise<MigrationSeriesSummary> {
116
+ const migrationTable: Table<Migration> = new MigrationTable();
117
+ const db = getDbAsSystem();
118
+ const qb = new QueryBuilderFactory().createQueryBuilder(migrationTable).sort([
119
+ { field: 'created', desc: false },
120
+ { field: 'id', desc: false },
121
+ ]);
122
+ const ledger = await db.query(migrationTable, qb);
123
+
124
+ const summary: MigrationSeriesSummary = {
125
+ applied: [],
126
+ skippedManual: [],
127
+ alreadyApplied: [],
128
+ unresolved: [],
129
+ notAttempted: [],
130
+ };
131
+ const sourceRecordRepo = new SourceRecordRepo();
132
+ const pending: Migration[] = [];
133
+ for (const row of ledger) {
134
+ const source = sourceRecordRepo.getSourceRecord<Migration>(migrationTable.name, row.id);
135
+ if (!source) {
136
+ summary.unresolved.push(row.id);
137
+ continue;
138
+ }
139
+ if (source.manual) {
140
+ summary.skippedManual.push(row.id);
141
+ continue;
142
+ }
143
+ if (row.status === 'success') {
144
+ summary.alreadyApplied.push(row.id);
145
+ continue;
146
+ }
147
+ pending.push(row);
148
+ }
149
+
150
+ this.logger.info({
151
+ message: `Running ${pending.length} pending migration${pending.length === 1 ? '' : 's'} in series, oldest-first`,
152
+ obj: { pending: pending.map((row) => row.id), skippedManual: summary.skippedManual },
153
+ });
154
+ for (let i = 0; i < pending.length; i++) {
155
+ const outcome = await this.ensureMigrationRun(pending[i].id);
156
+ if (outcome.status === 'success') {
157
+ summary.applied.push(outcome.id);
158
+ continue;
159
+ }
160
+ summary.failed = {
161
+ id: outcome.id,
162
+ description: outcome.description,
163
+ failureMessage: outcome.failureMessage,
164
+ };
165
+ summary.notAttempted = pending.slice(i + 1).map((row) => row.id);
166
+ break;
167
+ }
168
+
169
+ this.logger.info({ message: `Migration series finished`, obj: summary as any });
170
+ return summary;
59
171
  }
60
172
 
61
173
  // The db is taken as a provider, resolved inside this async body: on the service path,
@@ -1,44 +1,54 @@
1
- import { Db, getDb } from './Db';
1
+ import { Db } from './Db';
2
2
  import { Table } from './Table';
3
3
  import { Record } from './Record';
4
4
  import { Query } from './services/DbService';
5
5
  import { QueryBuilderFactory } from './QueryBuilderFactory';
6
6
  import { QueryBuilder } from '@proteinjs/db-query';
7
+ import { CursorAnchor, CursorWindowPager } from './CursorWindowPager';
7
8
 
9
+ /**
10
+ * Iterate a table query in cursor-anchored windows (`CursorWindowPager`), `windowSize` rows
11
+ * per query. Cursor windows frame on the last row served instead of a positional offset, so
12
+ * rows inserted or deleted while iterating can never skip or duplicate rows — every row that
13
+ * exists throughout the iteration is yielded exactly once.
14
+ *
15
+ * Ordering: the sort criteria on the consumer's query builder become the cursor axes, with a
16
+ * unique `id` tiebreak appended (ties can't skip across window boundaries); with no sort the
17
+ * iteration runs in plain `id` order. Sort axes should be non-null columns — a window whose
18
+ * tail carries a NULL sort value ends the iteration (a null can't anchor a continuation).
19
+ *
20
+ * The caller's query builder is never mutated: every window builds on a fresh copy.
21
+ */
8
22
  export class RecordIterator<T extends Record> implements AsyncIterable<T> {
9
- private db: Db;
10
- private table: Table<T>;
11
- private query: QueryBuilder<T>;
12
- private pageSize: number;
13
- private currentPage: number;
23
+ private pager: CursorWindowPager<T>;
14
24
 
15
- constructor(table: Table<T>, query: Query<T>, pageSize: number = 10, db?: Db) {
16
- this.db = db || getDb();
17
- this.table = table;
18
- this.query = new QueryBuilderFactory().getQueryBuilder(table, query);
19
- this.pageSize = pageSize;
20
- this.currentPage = 0;
25
+ constructor(
26
+ table: Table<T>,
27
+ query: Query<T>,
28
+ private windowSize: number = 10,
29
+ db?: Db
30
+ ) {
31
+ // A fresh copy of the consumer's query; its sort criteria lift off the base (the pager
32
+ // owns ordering — cursor axes and ORDER BY have to agree) and the conditions-only base
33
+ // seeds every window's fresh build.
34
+ const base = new QueryBuilderFactory().createQueryBuilder(table, query);
35
+ const sort = base.removeSortCriteria();
36
+ this.pager = new CursorWindowPager<T>(table, () => QueryBuilder.fromQueryBuilder(base, table.name), sort, { db });
21
37
  }
22
38
 
23
39
  async *[Symbol.asyncIterator](): AsyncIterator<T> {
40
+ let anchor: CursorAnchor | null = null;
24
41
  while (true) {
25
- const pagination = { start: this.currentPage * this.pageSize, end: (this.currentPage + 1) * this.pageSize };
26
- this.query.paginate(pagination);
27
- const results = await this.db.query(this.table, this.query);
28
-
29
- if (results.length === 0) {
30
- return;
31
- }
32
-
33
- for (const item of results) {
34
- yield item;
42
+ const window = await this.pager.loadWindow(anchor, this.windowSize);
43
+ for (const row of window.rows) {
44
+ yield row;
35
45
  }
36
46
 
37
- if (results.length < this.pageSize) {
47
+ if (!window.nextAnchor) {
38
48
  return;
39
49
  }
40
50
 
41
- this.currentPage++;
51
+ anchor = window.nextAnchor;
42
52
  }
43
53
  }
44
54
  }
@@ -1,7 +1,13 @@
1
1
  import { Table } from '../Table';
2
2
 
3
3
  export interface SchemaOperations {
4
- createTable(table: Table<any>): Promise<void>;
4
+ /**
5
+ * Create every table in `tables`, in the given order. The order is load-bearing: a table whose
6
+ * foreign keys reference another absent table must appear after it. Drivers that support batched
7
+ * DDL (Spanner) apply the whole set as ONE schema-update operation — statements inside a batch
8
+ * apply in order, so the ordering contract is preserved.
9
+ */
10
+ createTables(tables: Table<any>[]): Promise<void>;
5
11
  alterTable(table: Table<any>, changes: TableChanges): Promise<void>;
6
12
  }
7
13
 
@@ -33,10 +33,34 @@ export class TableManager {
33
33
  return await this.schemaMetadata.tableExists(table);
34
34
  }
35
35
 
36
+ /**
37
+ * Reconcile every registered table with the live schema. Absent tables are created as ONE
38
+ * batch (a single schema-update operation on drivers that support it — the prod-boot win),
39
+ * preserving their registration order so foreign keys to other absent tables resolve; existing
40
+ * tables are altered individually after, so an alter that adds a foreign key to a
41
+ * just-created table sees it live.
42
+ */
36
43
  async loadTables(): Promise<void> {
37
44
  const tables = getTables();
45
+ const absentTables: Table<any>[] = [];
46
+ const existingTables: Table<any>[] = [];
38
47
  for (const table of tables) {
39
- await this.loadTable(table);
48
+ this.validateDynamicReferenceColumns(table);
49
+ if (await this.tableExists(table)) {
50
+ existingTables.push(table);
51
+ } else {
52
+ absentTables.push(table);
53
+ }
54
+ }
55
+
56
+ if (absentTables.length > 0) {
57
+ this.logger.info({ message: `Creating tables: ${absentTables.map((table) => table.name).join(', ')}` });
58
+ await this.schemaOperations.createTables(absentTables);
59
+ this.logger.info({ message: `Finished creating ${absentTables.length} tables` });
60
+ }
61
+
62
+ for (const table of existingTables) {
63
+ await this.alterTableIfChanged(table);
40
64
  }
41
65
  }
42
66
 
@@ -44,19 +68,23 @@ export class TableManager {
44
68
  this.validateDynamicReferenceColumns(table);
45
69
 
46
70
  if (await this.tableExists(table)) {
47
- const tableChanges = await this.getTableChanges(table);
48
- if (this.shouldAlterTable(tableChanges)) {
49
- this.logger.info({ message: `Altering table: ${table.name}` });
50
- await this.schemaOperations.alterTable(table, tableChanges);
51
- this.logger.info({ message: `Finished altering table: ${table.name}` });
52
- }
71
+ await this.alterTableIfChanged(table);
53
72
  } else {
54
73
  this.logger.info({ message: `Creating table: ${table.name}` });
55
- await this.schemaOperations.createTable(table);
74
+ await this.schemaOperations.createTables([table]);
56
75
  this.logger.info({ message: `Finished creating table: ${table.name}` });
57
76
  }
58
77
  }
59
78
 
79
+ private async alterTableIfChanged(table: Table<any>): Promise<void> {
80
+ const tableChanges = await this.getTableChanges(table);
81
+ if (this.shouldAlterTable(tableChanges)) {
82
+ this.logger.info({ message: `Altering table: ${table.name}` });
83
+ await this.schemaOperations.alterTable(table, tableChanges);
84
+ this.logger.info({ message: `Finished altering table: ${table.name}` });
85
+ }
86
+ }
87
+
60
88
  private validateDynamicReferenceColumns(table: Table<any>): void {
61
89
  const isDynamicRefColumn = (column: any): column is DynamicReferenceColumn<any> =>
62
90
  typeof column.dynamicRefTableColName === 'string';
@@ -1,10 +1,19 @@
1
1
  import { Moment } from '../opt/moment';
2
- import { DateTimeColumn, ObjectColumn, StringColumn } from '../Columns';
2
+ import { BooleanColumn, DateTimeColumn, ObjectColumn, StringColumn } from '../Columns';
3
3
  import { Table } from '../Table';
4
4
  import { SourceRecord, withSourceRecordColumns } from '../source/SourceRecord';
5
5
 
6
6
  export interface Migration extends SourceRecord {
7
7
  description: string;
8
+ /**
9
+ * The explicit non-automatable class (plans/POST_RELEASE_QUEUE.md 27f): destructive
10
+ * contractions (column/table drops), long backfills over big tables, data-dependent one-offs.
11
+ * `manual: true` EXCLUDES the migration from the deploy-gated auto-run
12
+ * ({@link MigrationRunner.runPendingMigrations}); it keeps the Migrations-page flow
13
+ * (`runMigration`). Absence of the flag = automated — the invariant lives here, in the
14
+ * schema, not in deploy-pipeline prose.
15
+ */
16
+ manual?: boolean;
8
17
  status?: 'proposed' | 'running' | 'success' | 'failure';
9
18
  failureMessage?: string;
10
19
  failureStack?: string;
@@ -29,6 +38,7 @@ export class MigrationTable extends Table<Migration> {
29
38
  };
30
39
  public columns = withSourceRecordColumns<Migration>({
31
40
  description: new StringColumn('description', {}, 4000),
41
+ manual: new BooleanColumn('manual'),
32
42
  status: new StringColumn('status', { defaultValue: async () => 'proposed' }),
33
43
  failureMessage: new StringColumn('failure_message', {}, 4000),
34
44
  failureStack: new StringColumn('failure_stack', {}, 'MAX'),