@proteinjs/db 1.34.4 → 1.35.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.34.4",
3
+ "version": "1.35.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": "6fe97aa884e73cdeed9cfc7b4a0c12b396657057"
69
+ "gitHead": "ab6c2ca7ce824c1c1f22fdc7853eb93cb5334172"
70
70
  }
package/src/Db.ts CHANGED
@@ -145,8 +145,27 @@ export class Db<R extends Record = Record> implements DbService<R> {
145
145
  return defaultTransactionContextFactory;
146
146
  }
147
147
 
148
+ /**
149
+ * Boot-time reconciliation, in THE load-bearing order:
150
+ * 1. ensure the database exists;
151
+ * 2. pre-schema-sync migrations ({@link MigrationRunner.runPreSchemaSyncMigrations}) — data
152
+ * repairs new schema invariants depend on (e.g. deduplicating rows before a unique-index
153
+ * backfill, which fails loudly over violating data); an immediate no-op unless a migration
154
+ * declares {@link Migration.preSchemaSync};
155
+ * 3. schema sync — create/alter every registered table (the DDL);
156
+ * 4. source-record sync.
157
+ */
148
158
  async init(): Promise<void> {
149
159
  await this.dbDriver.createDbIfNotExists();
160
+ // Resolved lazily, on purpose: Db sits at the BOTTOM of this package's module graph
161
+ // (Table -> Record -> Columns -> ReferenceArray -> Db), while MigrationRunner sits above it
162
+ // (it loads MigrationTable, whose class `extends Table`). A top-level import here closes
163
+ // that loop downward — an entry module that starts anywhere in the lower chain would then
164
+ // evaluate MigrationTable's `extends Table` while Table.ts is still mid-load (ES5
165
+ // downlevel: "Class extends value undefined is not a constructor"). A call-time import
166
+ // keeps the edge runtime-only, when the whole graph is complete.
167
+ const { MigrationRunner } = await import('./MigrationRunner');
168
+ await new MigrationRunner().runPreSchemaSyncMigrations(this.dbDriver.getTableManager());
150
169
  await this.dbDriver.getTableManager().loadTables();
151
170
  await new SourceRecordLoader().load();
152
171
  }
@@ -2,9 +2,12 @@ import { Moment, moment } from './opt/moment';
2
2
  import { Db, getDb, getDbAsSystem } from './Db';
3
3
  import { Table } from './Table';
4
4
  import { SourceRecordRepo } from './source/SourceRecordRepo';
5
+ import { SourceRecordLoader } from './source/SourceRecordLoader';
6
+ import { getSourceRecordLoaders } from './source/SourceRecord';
5
7
  import { MigrationRunnerService, getMigrationRunnerService } from './services/MigrationRunnerService';
6
8
  import { Migration, MigrationTable } from './tables/MigrationTable';
7
9
  import { QueryBuilderFactory } from './QueryBuilderFactory';
10
+ import { TableManager } from './schema/TableManager';
8
11
  import { Service } from '@proteinjs/service';
9
12
  import { Logger } from '@proteinjs/logger';
10
13
 
@@ -92,6 +95,65 @@ export class MigrationRunner implements MigrationRunnerService {
92
95
  return migration;
93
96
  }
94
97
 
98
+ /**
99
+ * Pre-schema-sync phase — called by {@link Db.init} between database creation and schema sync:
100
+ * runs every source-declared migration flagged {@link Migration.preSchemaSync}, so data repairs
101
+ * a new schema invariant depends on (e.g. deduplicating rows a new unique index would reject —
102
+ * TableManager's unique-index preflight fails loudly over violating data) land BEFORE the DDL
103
+ * that needs them. The ordinary series ({@link runPendingMigrations}) runs after init — too
104
+ * late for this class by construction.
105
+ *
106
+ * ZERO-COST WHEN UNUSED: no flagged migrations -> immediate return (no ledger IO, no DDL) —
107
+ * the common boot pays nothing.
108
+ *
109
+ * Bootstrap: the phase runs before schema sync, so it fronts the two pieces it needs itself —
110
+ * the migration TABLE's own schema (framework-owned, never data-hazardous) and the migration
111
+ * table's source-record sync (ledger rows + SourceRecordRepo registration, which
112
+ * {@link ensureMigrationRun}'s resolveMigration reads). The full loadTables/source sync that
113
+ * follows re-reconciles both idempotently.
114
+ *
115
+ * Runs through {@link ensureMigrationRun} — full ledger semantics: skip on 'success', retry on
116
+ * 'failure'/'running' (a crashed earlier boot). Multiple flagged migrations run in series
117
+ * ordered by id (deterministic; their ledger rows may not exist yet, so created-order cannot
118
+ * apply). A non-success outcome THROWS: Db.init must fail as loudly as the schema sync it
119
+ * protects would have — the boot crash / deploy-Job failure names the migration instead of an
120
+ * opaque index-backfill error, and the recorded failure row retries on the next boot.
121
+ */
122
+ async runPreSchemaSyncMigrations(tableManager: TableManager): Promise<void> {
123
+ const migrationTable: Table<Migration> = new MigrationTable();
124
+ const flagged = getSourceRecordLoaders<Migration>()
125
+ // db >=1.34.4: declarations are { source, loader } pairs — the loader carries table/record.
126
+ .filter(({ loader }) => loader.table.name === migrationTable.name && (loader.record as Migration).preSchemaSync)
127
+ .map(({ loader }) => loader.record as Migration)
128
+ .sort((a, b) => a.id.localeCompare(b.id));
129
+ if (flagged.length === 0) {
130
+ return;
131
+ }
132
+
133
+ const contradictions = flagged.filter((migration) => migration.manual);
134
+ if (contradictions.length > 0) {
135
+ throw new Error(
136
+ `Migration(s) declare both preSchemaSync and manual — a contradiction (the pre-schema-sync phase ` +
137
+ `exists to run unattended before DDL): ${contradictions.map((migration) => migration.id).join(', ')}`
138
+ );
139
+ }
140
+
141
+ await tableManager.loadTable(migrationTable);
142
+ await new SourceRecordLoader().load(migrationTable);
143
+ this.logger.info({
144
+ message: `Running ${flagged.length} pre-schema-sync migration${flagged.length === 1 ? '' : 's'} before schema sync`,
145
+ obj: { ids: flagged.map((migration) => migration.id) },
146
+ });
147
+ for (const migration of flagged) {
148
+ const outcome = await this.ensureMigrationRun(migration.id);
149
+ if (outcome.status !== 'success') {
150
+ throw new Error(
151
+ `Pre-schema-sync migration (${outcome.id}) failed; schema sync not attempted: ${outcome.failureMessage}`
152
+ );
153
+ }
154
+ }
155
+ }
156
+
95
157
  /**
96
158
  * Deploy-path API (plans/POST_RELEASE_QUEUE.md 27f): the deploy pipeline's migration Job calls
97
159
  * this AFTER `new Db().init()` (schema sync + source-record sync — every source-declared
@@ -61,12 +61,21 @@ export class SourceRecordLoader {
61
61
  * boot carries its authority — clean up explicitly); two builds of one package at the SAME
62
62
  * version with differing sets (uncommitted local skew) are last-writer-wins, since versions
63
63
  * cannot order them.
64
+ *
65
+ * With no argument, every source-record table is synced (the `Db.init` full pass). Passing
66
+ * `onlyTable` scopes the sync to that one table — the pre-schema-sync migration phase uses
67
+ * this to land the migration ledger's rows before the full schema sync has run (see
68
+ * {@link MigrationRunner.runPreSchemaSyncMigrations}); the later full pass re-reconciles the
69
+ * same rows idempotently under the same ownership model.
64
70
  */
65
- async load(): Promise<SourceRecordLoadSummary> {
71
+ async load(onlyTable?: Table<any>): Promise<SourceRecordLoadSummary> {
66
72
  const { tables, buildSources } = await this.getDeclarations();
67
73
  const db = getDbAsSystem();
68
74
  const summary: SourceRecordLoadSummary = {};
69
75
  for (const tableName in tables) {
76
+ if (onlyTable && tableName !== onlyTable.name) {
77
+ continue;
78
+ }
70
79
  const { table, records } = tables[tableName];
71
80
  // 'id' unless the table declares a natural key (validated: unique-indexed, present and
72
81
  // unambiguous across declarations).
@@ -14,6 +14,29 @@ export interface Migration extends SourceRecord {
14
14
  * schema, not in deploy-pipeline prose.
15
15
  */
16
16
  manual?: boolean;
17
+ /**
18
+ * Runs during `Db.init()` BEFORE schema sync (the pre-schema-sync phase —
19
+ * {@link MigrationRunner.runPreSchemaSyncMigrations}), instead of after init like the
20
+ * deploy-gated series. This is the class for data repairs a NEW SCHEMA INVARIANT depends on —
21
+ * e.g. deduplicating rows before a unique index lands: schema sync's unique-index preflight
22
+ * fails loudly over violating data ({@link DuplicateValuesForUniqueIndexError}), and the
23
+ * ordinary series (deploy Job, after init) is too late by construction.
24
+ *
25
+ * Contract for this class (stricter than the ordinary automated class):
26
+ * - IDEMPOTENT and tolerant of CONCURRENT duplicate runs: every booting replica and the deploy
27
+ * Job each run init — two actors can observe the row un-applied and both run the body.
28
+ * - TABLE-EXISTENCE tolerant: on a fresh database the body runs before ANY schema sync, so its
29
+ * target tables may not exist yet (check and no-op — a fresh database has nothing to repair).
30
+ * - Never `manual` (a contradiction — the phase exists to run unattended before DDL; declaring
31
+ * both fails init loudly).
32
+ * A failure fails `Db.init()` loudly (recorded on the ledger row, retried next boot) — exactly
33
+ * the failure the schema sync would otherwise hit, but named and retryable.
34
+ *
35
+ * The phase reads the SOURCE declaration (like {@link runPendingMigrations} reads
36
+ * `source.manual`); the column mirrors it into the ledger so the Migrations page shows why a
37
+ * row ran at boot.
38
+ */
39
+ preSchemaSync?: boolean;
17
40
  /**
18
41
  * Ledger-owned state (like `status` — never declared on a source record): stamped `true` by the
19
42
  * deploy-gated series ({@link MigrationRunner.runPendingMigrations}) when the row's source class
@@ -47,6 +70,7 @@ export class MigrationTable extends Table<Migration> {
47
70
  public columns = withSourceRecordColumns<Migration>({
48
71
  description: new StringColumn('description', {}, 4000),
49
72
  manual: new BooleanColumn('manual'),
73
+ preSchemaSync: new BooleanColumn('pre_schema_sync'),
50
74
  retired: new BooleanColumn('retired'),
51
75
  status: new StringColumn('status', { defaultValue: async () => 'proposed' }),
52
76
  failureMessage: new StringColumn('failure_message', {}, 4000),
package/tsconfig.json CHANGED
@@ -23,5 +23,5 @@
23
23
  "@proteinjs/db/test": ["test/index.ts"]
24
24
  }
25
25
  },
26
- "exclude": ["node_modules", "test.js", "test.d.ts"]
26
+ "exclude": ["node_modules", "dist", "test.js", "test.d.ts"]
27
27
  }