@proteinjs/db 1.49.1 → 1.50.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proteinjs/db",
3
- "version": "1.49.1",
3
+ "version": "1.50.1",
4
4
  "main": "./dist/generated/index.js",
5
5
  "types": "./dist/generated/index.d.ts",
6
6
  "exports": {
@@ -66,5 +66,5 @@
66
66
  "ts-jest": "29.1.1",
67
67
  "typescript": "5.2.2"
68
68
  },
69
- "gitHead": "21e5b7232ef86cfd43b47fab96e90dad1d855bf8"
69
+ "gitHead": "a324a92e60161ddf69fec4e17b646882fe830f85"
70
70
  }
@@ -5,7 +5,7 @@ import { SourceRecordRepo } from './source/SourceRecordRepo';
5
5
  import { SourceRecordSyncRunner } from './source/SourceRecordSyncRunner';
6
6
  import { getSourceRecordLoaders } from './source/SourceRecord';
7
7
  import { MigrationRunnerService, getMigrationRunnerService } from './services/MigrationRunnerService';
8
- import { Migration, MigrationTable } from './tables/MigrationTable';
8
+ import { Migration, MigrationEstimate, MigrationTable } from './tables/MigrationTable';
9
9
  import { QueryBuilderFactory } from './QueryBuilderFactory';
10
10
  import { TableManager } from './schema/TableManager';
11
11
  import { Service } from '@proteinjs/service';
@@ -43,6 +43,62 @@ export interface MigrationSeriesSummary {
43
43
  notAttempted: string[];
44
44
  }
45
45
 
46
+ /**
47
+ * The plan for the pending series, read BEFORE anything runs: each pending migration's estimate
48
+ * ({@link Migration.estimate}) and the projection over the ones that declared seconds per unit.
49
+ * {@link MigrationRunner.planPendingMigrations} returns it without applying anything (the dry
50
+ * run); {@link MigrationRunner.runPendingMigrations} logs it before its first run. Ids appear in
51
+ * the order the series would run them.
52
+ */
53
+ export interface MigrationPlan {
54
+ /** The migrations the series would run, in order, each with its estimate when declared. */
55
+ pending: MigrationPlanEntry[];
56
+ /** Seconds projected over the entries that declared `secondsPerUnit` — the deadline's input. */
57
+ projectedSeconds: number;
58
+ /** How many entries the projection covers. */
59
+ projected: number;
60
+ /** Ids the projection does NOT cover: no estimate declared, or no seconds per unit. */
61
+ unprojected: string[];
62
+ /** Excluded by the explicit `manual` flag. */
63
+ skippedManual: string[];
64
+ /** Already in 'success' status. */
65
+ alreadyApplied: string[];
66
+ /** Ledger rows with no source record in this build (the run stamps these retired; the plan only reports). */
67
+ unresolved: string[];
68
+ /** Rows carrying `retired: true`. */
69
+ retired: string[];
70
+ /**
71
+ * True when the ledger table does not exist: a database no schema sync has touched, so every
72
+ * declared migration runs over empty tables — the estimates are not read (nothing to count).
73
+ */
74
+ freshDatabase: boolean;
75
+ }
76
+
77
+ export interface MigrationPlanEntry {
78
+ id: string;
79
+ description: string;
80
+ /**
81
+ * Runs during `Db.init()` before schema sync rather than in the series
82
+ * ({@link Migration.preSchemaSync}) — planned all the same: a deploy's deadline covers init.
83
+ */
84
+ preSchemaSync?: boolean;
85
+ estimate?: MigrationEstimate;
86
+ /** `ceil(units × secondsPerUnit)` when the estimate declares seconds per unit. */
87
+ projectedSeconds?: number;
88
+ /** Why the entry carries no projection, in the words the plan line uses. */
89
+ unprojectedReason?: string;
90
+ }
91
+
92
+ /** The classification of a ledger row against this build's source declarations. */
93
+ interface LedgerClassification {
94
+ /** The source declarations that would run, in series order. */
95
+ pending: Migration[];
96
+ skippedManual: string[];
97
+ alreadyApplied: string[];
98
+ unresolved: string[];
99
+ retired: string[];
100
+ }
101
+
46
102
  export class MigrationRunner implements MigrationRunnerService {
47
103
  private logger = new Logger({ name: this.constructor.name });
48
104
  public serviceMetadata: Service['serviceMetadata'] = {
@@ -176,6 +232,9 @@ export class MigrationRunner implements MigrationRunnerService {
176
232
  * build — until un-retired on the Migrations page; skipped, reported as `retired`.
177
233
  * - the FIRST failure STOPS the series (later migrations may build on earlier ones). The
178
234
  * caller exits non-zero, the Job fails, the rollout does not advance.
235
+ * - the PLAN comes first: before the first run, every pending migration's estimate
236
+ * ({@link Migration.estimate}) is logged as one line and the series' total as another — the
237
+ * same lines {@link planPendingMigrations} produces on its own for a dry run.
179
238
  *
180
239
  * EXPAND-CONTRACT INVARIANT (documented at this seam on purpose): every automated migration —
181
240
  * and the schema sync that fronts it — must be backward-compatible with the STILL-RUNNING old
@@ -188,56 +247,40 @@ export class MigrationRunner implements MigrationRunnerService {
188
247
  async runPendingMigrations(): Promise<MigrationSeriesSummary> {
189
248
  const migrationTable: Table<Migration> = new MigrationTable();
190
249
  const db = getDbAsSystem();
191
- const qb = new QueryBuilderFactory().createQueryBuilder(migrationTable).sort([
192
- { field: 'created', desc: false },
193
- { field: 'id', desc: false },
194
- ]);
195
- const ledger = await db.query(migrationTable, qb);
250
+ const ledger = await db.query(migrationTable, this.ledgerInSeriesOrder(migrationTable));
251
+ const sourceRecordRepo = new SourceRecordRepo();
252
+ const classified = this.classifyLedger(ledger, (id) =>
253
+ sourceRecordRepo.getSourceRecord<Migration>(migrationTable.name, id)
254
+ );
255
+ for (const id of classified.unresolved) {
256
+ // Stamp, don't just skip: the ledger must remember the source class was gone. If the
257
+ // class ships again in a later build, the row stays excluded until a human un-retires it
258
+ // on the Migrations page — a returned loader id is not consent to auto-run.
259
+ await db.update(migrationTable, { id, retired: true } as Partial<Migration>);
260
+ }
196
261
 
197
262
  const summary: MigrationSeriesSummary = {
198
263
  applied: [],
199
- skippedManual: [],
200
- alreadyApplied: [],
201
- unresolved: [],
202
- retired: [],
264
+ skippedManual: classified.skippedManual,
265
+ alreadyApplied: classified.alreadyApplied,
266
+ unresolved: classified.unresolved,
267
+ retired: classified.retired,
203
268
  notAttempted: [],
204
269
  };
205
- const sourceRecordRepo = new SourceRecordRepo();
206
- const pending: Migration[] = [];
207
- for (const row of ledger) {
208
- if (row.retired) {
209
- summary.retired.push(row.id);
210
- continue;
211
- }
212
- const source = sourceRecordRepo.getSourceRecord<Migration>(migrationTable.name, row.id);
213
- if (!source) {
214
- // Stamp, don't just skip: the ledger must remember the source class was gone. If the
215
- // class ships again in a later build, the row stays excluded until a human un-retires it
216
- // on the Migrations page — a returned loader id is not consent to auto-run.
217
- await db.update(migrationTable, { id: row.id, retired: true } as Partial<Migration>);
218
- summary.unresolved.push(row.id);
219
- continue;
220
- }
221
- if (source.manual) {
222
- summary.skippedManual.push(row.id);
223
- continue;
224
- }
225
- if (row.status === 'success') {
226
- summary.alreadyApplied.push(row.id);
227
- continue;
228
- }
229
- pending.push(row);
230
- }
231
-
270
+ const pending = classified.pending;
232
271
  this.logger.info({
233
272
  message: `Running ${pending.length} pending migration${pending.length === 1 ? '' : 's'} in series, oldest-first`,
234
273
  obj: {
235
- pending: pending.map((row) => row.id),
274
+ pending: pending.map((migration) => migration.id),
236
275
  skippedManual: summary.skippedManual,
237
276
  skippedRetired: summary.retired,
238
277
  stampedRetired: summary.unresolved,
239
278
  },
240
279
  });
280
+ // The plan, BEFORE the first run: every pending migration's estimate as one line, then the
281
+ // total — the reader of the Job's log knows what the series is about to do and how long the
282
+ // projection says it takes, before a row changes.
283
+ await this.estimateSeries(pending, { ...classified, freshDatabase: false });
241
284
  for (let i = 0; i < pending.length; i++) {
242
285
  const outcome = await this.ensureMigrationRun(pending[i].id);
243
286
  if (outcome.status === 'success') {
@@ -249,7 +292,7 @@ export class MigrationRunner implements MigrationRunnerService {
249
292
  description: outcome.description,
250
293
  failureMessage: outcome.failureMessage,
251
294
  };
252
- summary.notAttempted = pending.slice(i + 1).map((row) => row.id);
295
+ summary.notAttempted = pending.slice(i + 1).map((migration) => migration.id);
253
296
  break;
254
297
  }
255
298
 
@@ -257,6 +300,52 @@ export class MigrationRunner implements MigrationRunnerService {
257
300
  return summary;
258
301
  }
259
302
 
303
+ /**
304
+ * The DRY RUN — the plan and nothing else: what {@link runPendingMigrations} would run, with
305
+ * each migration's estimate ({@link Migration.estimate}, read-only by contract) and the
306
+ * projection over the ones that declared seconds per unit. Applies NOTHING: no `Db.init()`
307
+ * (the deploy entrypoint calls this INSTEAD of init — init would create the database, run the
308
+ * pre-schema-sync migrations, sync the schema and sync the source records), no run, no ledger
309
+ * write — an unresolved row is reported, not stamped.
310
+ *
311
+ * Runs before init by construction, so the declarations come from the BUILD (the reflection
312
+ * source-record loaders for the migration table) joined to the ledger as it stands: a row's
313
+ * status says whether its migration is pending; a declaration with no row yet is what this
314
+ * release's source sync will insert as 'proposed' — pending, planned after the ledger's own
315
+ * pending rows (their `created` is older than a row born at the next init), in id order among
316
+ * themselves. Source records already registered in this process (init has run, or a test
317
+ * planted them) take precedence over the build's declaration of the same id — the same object
318
+ * after init.
319
+ *
320
+ * A database the schema sync has never touched has no ledger table: every declared migration
321
+ * is listed, no estimate is read (their tables do not exist either — nothing to count), and
322
+ * the plan says `freshDatabase`. An estimate that throws fails the plan loudly — a plan that
323
+ * cannot count is not a plan.
324
+ */
325
+ async planPendingMigrations(): Promise<MigrationPlan> {
326
+ const migrationTable: Table<Migration> = new MigrationTable();
327
+ const db = getDbAsSystem();
328
+ const declared = this.declaredMigrations(migrationTable);
329
+ const sourceRecordRepo = new SourceRecordRepo();
330
+ const sourceOf = (id: string) =>
331
+ sourceRecordRepo.getSourceRecord<Migration>(migrationTable.name, id) ?? declared.get(id);
332
+ const freshDatabase = !(await db.tableExists(migrationTable));
333
+ const ledger = freshDatabase ? [] : await db.query(migrationTable, this.ledgerInSeriesOrder(migrationTable));
334
+ const classified = this.classifyLedger(ledger, sourceOf);
335
+ const inLedger = new Set(ledger.map((row) => row.id));
336
+ const unsynced = Array.from(declared.values())
337
+ .filter((migration) => !inLedger.has(migration.id))
338
+ .sort((a, b) => a.id.localeCompare(b.id));
339
+ for (const migration of unsynced) {
340
+ if (migration.manual) {
341
+ classified.skippedManual.push(migration.id);
342
+ } else {
343
+ classified.pending.push(migration);
344
+ }
345
+ }
346
+ return await this.estimateSeries(classified.pending, { ...classified, freshDatabase });
347
+ }
348
+
260
349
  // The db is taken as a provider, resolved inside this async body: on the service path,
261
350
  // constructing the Db is itself run infrastructure — its failure must REJECT the detached
262
351
  // promise, not throw synchronously from runMigration (only a bogus id may reach the client).
@@ -346,4 +435,152 @@ export class MigrationRunner implements MigrationRunnerService {
346
435
 
347
436
  return parts.join(' ');
348
437
  }
438
+
439
+ /** The ledger in SERIES order: oldest-first by the row's `created`, id tiebreak. */
440
+ private ledgerInSeriesOrder(migrationTable: Table<Migration>) {
441
+ return new QueryBuilderFactory().createQueryBuilder(migrationTable).sort([
442
+ { field: 'created', desc: false },
443
+ { field: 'id', desc: false },
444
+ ]);
445
+ }
446
+
447
+ /**
448
+ * One owner of "what does a ledger row mean for the series": retired rows are skipped; rows
449
+ * with no source record are unresolved (the RUN stamps them retired; the plan reports them);
450
+ * `manual` declarations are excluded; 'success' rows are already applied; anything else is
451
+ * pending — including 'running' (a crashed earlier run). Pure over the rows: no writes.
452
+ */
453
+ private classifyLedger(ledger: Migration[], sourceOf: (id: string) => Migration | undefined): LedgerClassification {
454
+ const classified: LedgerClassification = {
455
+ pending: [],
456
+ skippedManual: [],
457
+ alreadyApplied: [],
458
+ unresolved: [],
459
+ retired: [],
460
+ };
461
+ for (const row of ledger) {
462
+ if (row.retired) {
463
+ classified.retired.push(row.id);
464
+ continue;
465
+ }
466
+ const source = sourceOf(row.id);
467
+ if (!source) {
468
+ classified.unresolved.push(row.id);
469
+ continue;
470
+ }
471
+ if (source.manual) {
472
+ classified.skippedManual.push(row.id);
473
+ continue;
474
+ }
475
+ if (row.status === 'success') {
476
+ classified.alreadyApplied.push(row.id);
477
+ continue;
478
+ }
479
+ classified.pending.push(source);
480
+ }
481
+ return classified;
482
+ }
483
+
484
+ /** This build's migration declarations by id — the reflection source-record loaders for the migration table. */
485
+ private declaredMigrations(migrationTable: Table<Migration>): Map<string, Migration> {
486
+ const declared = new Map<string, Migration>();
487
+ for (const { loader } of getSourceRecordLoaders<Migration>()) {
488
+ if (loader.table.name === migrationTable.name) {
489
+ const migration = loader.record as Migration;
490
+ declared.set(migration.id, migration);
491
+ }
492
+ }
493
+ return declared;
494
+ }
495
+
496
+ /**
497
+ * The plan's lines: each pending migration's estimate (read here — the one call of
498
+ * {@link Migration.estimate}), logged as one line, then the total. Read-only by the door's
499
+ * contract; nothing here writes.
500
+ */
501
+ private async estimateSeries(
502
+ pending: Migration[],
503
+ context: Omit<MigrationPlan, 'pending' | 'projectedSeconds' | 'projected' | 'unprojected'>
504
+ ): Promise<MigrationPlan> {
505
+ const plan: MigrationPlan = {
506
+ pending: [],
507
+ projectedSeconds: 0,
508
+ projected: 0,
509
+ unprojected: [],
510
+ skippedManual: context.skippedManual,
511
+ alreadyApplied: context.alreadyApplied,
512
+ unresolved: context.unresolved,
513
+ retired: context.retired,
514
+ freshDatabase: context.freshDatabase,
515
+ };
516
+ for (const migration of pending) {
517
+ const entry: MigrationPlanEntry = {
518
+ id: migration.id,
519
+ description: migration.description,
520
+ ...(migration.preSchemaSync ? { preSchemaSync: true } : {}),
521
+ };
522
+ if (context.freshDatabase) {
523
+ entry.unprojectedReason = 'fresh database, nothing to count';
524
+ } else if (!migration.estimate) {
525
+ entry.unprojectedReason = 'no estimate declared';
526
+ } else {
527
+ entry.estimate = await this.readEstimate(migration);
528
+ if (entry.estimate.secondsPerUnit === undefined) {
529
+ entry.unprojectedReason = 'no seconds per unit declared';
530
+ } else {
531
+ entry.projectedSeconds = Math.ceil(entry.estimate.units * entry.estimate.secondsPerUnit);
532
+ }
533
+ }
534
+ if (entry.projectedSeconds === undefined) {
535
+ plan.unprojected.push(entry.id);
536
+ } else {
537
+ plan.projected++;
538
+ plan.projectedSeconds += entry.projectedSeconds;
539
+ }
540
+ plan.pending.push(entry);
541
+ this.logger.info({ message: this.planLine(entry) });
542
+ }
543
+ const without =
544
+ plan.unprojected.length > 0
545
+ ? `, ${plan.unprojected.length} without a projection (${plan.unprojected.join(', ')})`
546
+ : '';
547
+ this.logger.info({
548
+ message:
549
+ `Migration plan total: ${plan.pending.length} pending, ≈ ${plan.projectedSeconds} s projected over ` +
550
+ `${plan.projected} of them${without}`,
551
+ obj: plan as any,
552
+ });
553
+ return plan;
554
+ }
555
+
556
+ /**
557
+ * The one call of {@link Migration.estimate}. A throw fails the plan loudly, NAMING the migration:
558
+ * the reader of a plan that could not count must know which count failed, and the estimate's own
559
+ * message ("count query failed") does not say.
560
+ */
561
+ private async readEstimate(migration: Migration): Promise<MigrationEstimate> {
562
+ try {
563
+ return await migration.estimate!();
564
+ } catch (error: any) {
565
+ const failure = new Error(
566
+ `Migration plan: (${migration.id}) ${migration.description} — the estimate failed: ${error?.message ?? error}`
567
+ );
568
+ (failure as Error & { cause?: unknown }).cause = error;
569
+ throw failure;
570
+ }
571
+ }
572
+
573
+ /** One line per pending migration: the count, the unit, the projection when there is one, the note. */
574
+ private planLine(entry: MigrationPlanEntry): string {
575
+ const head = `Migration plan: (${entry.id}) ${entry.description}${entry.preSchemaSync ? ' [runs at init, before schema sync]' : ''} — `;
576
+ if (!entry.estimate) {
577
+ return `${head}${entry.unprojectedReason}`;
578
+ }
579
+ const { units, unit, secondsPerUnit, note } = entry.estimate;
580
+ const projection =
581
+ entry.projectedSeconds === undefined
582
+ ? entry.unprojectedReason
583
+ : `≈ ${entry.projectedSeconds} s at ${secondsPerUnit} s per unit`;
584
+ return `${head}${units} ${unit}, ${projection}${note ? ` (${note})` : ''}`;
585
+ }
349
586
  }
package/src/Table.ts CHANGED
@@ -217,8 +217,12 @@ export abstract class Table<T extends Record> implements Loadable, CustomSeriali
217
217
  };
218
218
  }
219
219
 
220
+ /**
221
+ * A record's data fields: its methods are not columns — a required one (`run`) and an OPTIONAL
222
+ * one (`estimate?`, whose type is the function or `undefined`) alike.
223
+ */
220
224
  type ExcludeFunctions<T> = {
221
- [P in keyof T as T[P] extends Function ? never : P]: T[P];
225
+ [P in keyof T as NonNullable<T[P]> extends Function ? never : P]: T[P];
222
226
  };
223
227
 
224
228
  type RequiredProps<T> = {
@@ -132,8 +132,32 @@ export class TableAuth {
132
132
 
133
133
  // Within a declared block an undeclared operation stays closed to everyone (the block is
134
134
  // the whole declaration) — `allows` reads undefined as false, so the two doors are exactly
135
- // the ones the table wrote down.
136
- return this.allows(tableAuth.all) || this.allows(tableAuth[operation]);
135
+ // the ones the table wrote down...
136
+ if (this.allows(tableAuth.all) || this.allows(tableAuth[operation])) {
137
+ return true;
138
+ }
139
+
140
+ // ...except that a db door the block leaves UNDECLARED mirrors the door the table's SERVICE
141
+ // block declares for the same operation. The RPC runs both doors — the service gate
142
+ // (TableServiceAuth), then the inner Db's db-api re-check as the calling user — so a service
143
+ // door with no db mirror admitted the caller at the gate and refused them behind it, in the
144
+ // gate's own words ("db doors mirror service doors" was a rule tables hand-copied; the owner
145
+ // derives it). One-way: a service block that omits the door keeps the lock (the session /
146
+ // audit-trail pattern — no write door on either api, break-glass included), and a table with
147
+ // no service block keeps its db block as written.
148
+ return api === 'db' && tableAuth.all === undefined && tableAuth[operation] === undefined
149
+ ? this.allowsServiceDoor(table, operation)
150
+ : false;
151
+ }
152
+
153
+ /** The service block's door for `operation`, as the RPC gate reads it; a table with no service block declares none. */
154
+ private allowsServiceDoor(table: Table<any>, operation: 'query' | 'insert' | 'update' | 'delete'): boolean {
155
+ const serviceAuth = table.auth?.service;
156
+ if (!serviceAuth || Object.keys(serviceAuth).length == 0) {
157
+ return false;
158
+ }
159
+
160
+ return this.allows(serviceAuth.all) || this.allows(serviceAuth[operation]);
137
161
  }
138
162
 
139
163
  /** One identity against the current user; undefined (nothing declared) is not a grant. */
@@ -3,6 +3,26 @@ import { BooleanColumn, DateTimeColumn, ObjectColumn, StringColumn } from '../Co
3
3
  import { Table } from '../Table';
4
4
  import { SourceRecord, withSourceRecordColumns } from '../source/SourceRecord';
5
5
 
6
+ /**
7
+ * What a migration's run would touch, counted READ-ONLY before the series runs: the plan a
8
+ * deploy reads before it commits to the run ({@link MigrationRunner.planPendingMigrations}), and
9
+ * the number a caller sizes the run's deadline from.
10
+ */
11
+ export interface MigrationEstimate {
12
+ /** How many units the run would touch — zero when nothing is left to do (a rerun over converted data). */
13
+ units: number;
14
+ /** The unit's name, a plural noun ("roots", "rows"). */
15
+ unit: string;
16
+ /**
17
+ * Seconds one unit takes, when the migration has measured it (a rehearsal over a copy of a live
18
+ * database); the plan projects `units × secondsPerUnit`. Absent, the plan reports the count and
19
+ * no projection.
20
+ */
21
+ secondsPerUnit?: number;
22
+ /** Where the numbers come from, and anything the reader of the plan should know. */
23
+ note?: string;
24
+ }
25
+
6
26
  export interface Migration extends SourceRecord {
7
27
  /**
8
28
  * The declaring loader's class name (`BackfillOnboardingStateForExistingAccounts`) — the
@@ -61,6 +81,13 @@ export interface Migration extends SourceRecord {
61
81
  duration?: string;
62
82
  output?: any;
63
83
  run: () => Promise<any | void>;
84
+ /**
85
+ * The estimate door: a READ-ONLY count of the work `run` would do ({@link MigrationEstimate}).
86
+ * Optional — the plan logs "no estimate declared" without it. Read before the series runs (the
87
+ * plan lines {@link MigrationRunner.runPendingMigrations} logs before its first run) and by the
88
+ * dry run that applies nothing ({@link MigrationRunner.planPendingMigrations}). Never writes.
89
+ */
90
+ estimate?: () => Promise<MigrationEstimate>;
64
91
  }
65
92
 
66
93
  export class MigrationTable extends Table<Migration> {