@proteinjs/db 1.34.3 → 1.35.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 (43) hide show
  1. package/CHANGELOG.md +22 -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/src/Db.d.ts +10 -0
  8. package/dist/src/Db.d.ts.map +1 -1
  9. package/dist/src/Db.js +42 -2
  10. package/dist/src/Db.js.map +1 -1
  11. package/dist/src/MigrationRunner.d.ts +26 -0
  12. package/dist/src/MigrationRunner.d.ts.map +1 -1
  13. package/dist/src/MigrationRunner.js +82 -0
  14. package/dist/src/MigrationRunner.js.map +1 -1
  15. package/dist/src/source/SourceRecord.d.ts +35 -2
  16. package/dist/src/source/SourceRecord.d.ts.map +1 -1
  17. package/dist/src/source/SourceRecord.js +8 -1
  18. package/dist/src/source/SourceRecord.js.map +1 -1
  19. package/dist/src/source/SourceRecordLoader.d.ts +105 -7
  20. package/dist/src/source/SourceRecordLoader.d.ts.map +1 -1
  21. package/dist/src/source/SourceRecordLoader.js +358 -94
  22. package/dist/src/source/SourceRecordLoader.js.map +1 -1
  23. package/dist/src/tables/MigrationTable.d.ts +26 -0
  24. package/dist/src/tables/MigrationTable.d.ts.map +1 -1
  25. package/dist/src/tables/MigrationTable.js +1 -0
  26. package/dist/src/tables/MigrationTable.js.map +1 -1
  27. package/dist/test/reusable/SourceRecordSyncTests.d.ts.map +1 -1
  28. package/dist/test/reusable/SourceRecordSyncTests.js +803 -27
  29. package/dist/test/reusable/SourceRecordSyncTests.js.map +1 -1
  30. package/dist/test/util/tables/sourceRecordSyncTestTables.d.ts +16 -0
  31. package/dist/test/util/tables/sourceRecordSyncTestTables.d.ts.map +1 -1
  32. package/dist/test/util/tables/sourceRecordSyncTestTables.js +23 -1
  33. package/dist/test/util/tables/sourceRecordSyncTestTables.js.map +1 -1
  34. package/generated/index.ts +1 -1
  35. package/generated/test/index.ts +3 -1
  36. package/package.json +4 -4
  37. package/src/Db.ts +19 -0
  38. package/src/MigrationRunner.ts +62 -0
  39. package/src/source/SourceRecord.ts +42 -4
  40. package/src/source/SourceRecordLoader.ts +342 -48
  41. package/src/tables/MigrationTable.ts +24 -0
  42. package/test/reusable/SourceRecordSyncTests.ts +462 -11
  43. package/test/util/tables/sourceRecordSyncTestTables.ts +20 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proteinjs/db",
3
- "version": "1.34.3",
3
+ "version": "1.35.0",
4
4
  "main": "./dist/generated/index.js",
5
5
  "types": "./dist/generated/index.d.ts",
6
6
  "exports": {
@@ -41,9 +41,9 @@
41
41
  "test": "jest --passWithNoTests"
42
42
  },
43
43
  "dependencies": {
44
- "@proteinjs/db-query": "^1.7.1",
44
+ "@proteinjs/db-query": "^1.7.2",
45
45
  "@proteinjs/logger": "^1.0.21",
46
- "@proteinjs/reflection": "^1.1.14",
46
+ "@proteinjs/reflection": "^1.2.0",
47
47
  "@proteinjs/serializer": "^1.1.10",
48
48
  "@proteinjs/server-api": "^3.0.11",
49
49
  "@proteinjs/service": "^1.5.1",
@@ -66,5 +66,5 @@
66
66
  "ts-jest": "29.1.1",
67
67
  "typescript": "5.2.2"
68
68
  },
69
- "gitHead": "d3a98c4cf7140cc2d93e588d89011037b3b8987c"
69
+ "gitHead": "521d5d1ca2f86f0ed2bef0309b0dde30506f132b"
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
@@ -1,10 +1,24 @@
1
1
  import { Loadable, SourceRepository } from '@proteinjs/reflection';
2
2
  import { Columns, Table, getTables } from '../Table';
3
3
  import { Record as DbRecord, withRecordColumns } from '../Record';
4
- import { BooleanColumn } from '../Columns';
4
+ import { BooleanColumn, StringColumn } from '../Columns';
5
5
 
6
- export const getSourceRecordLoaders = <T extends SourceRecord = SourceRecord>() =>
7
- SourceRepository.get().objects<SourceRecordLoader<T>>('@proteinjs/db/SourceRecordLoader');
6
+ /**
7
+ * A source record declaration paired with its owning source: the package that compiled the
8
+ * declaration into this build (from the declaration's reflection qualified name). The source is
9
+ * the ownership grain of the sync — {@link SourceRecordLoader} stamps it on every row it writes
10
+ * and prunes only within it, so servers running different builds against one shared database
11
+ * never delete each other's rows.
12
+ */
13
+ export type SourceRecordLoaderDeclaration<T extends SourceRecord = SourceRecord> = {
14
+ source: string;
15
+ loader: SourceRecordLoader<T>;
16
+ };
17
+
18
+ export const getSourceRecordLoaders = <T extends SourceRecord = SourceRecord>(): SourceRecordLoaderDeclaration<T>[] =>
19
+ SourceRepository.get()
20
+ .objectsWithNames<SourceRecordLoader<T>>('@proteinjs/db/SourceRecordLoader')
21
+ .map(({ packageName, object }) => ({ source: packageName, loader: object }));
8
22
 
9
23
  export function getSourceRecordTables() {
10
24
  const tables = getTables();
@@ -31,11 +45,31 @@ export function isSourceRecordTable(table: Table<any>) {
31
45
 
32
46
  export interface SourceRecord extends DbRecord {
33
47
  isLoadedFromSource?: boolean;
48
+ /**
49
+ * The package whose declaration owns this row (the declaring loader's package, from its
50
+ * reflection qualified name). Stamped by {@link SourceRecordLoader} on every row it writes;
51
+ * the removed-reconcile prunes only rows whose `sourcePackage` matches a package the running
52
+ * build actually declares from — so a build never deletes rows owned by a package it does not
53
+ * carry (e.g. another server's types on a shared database). Rows written before this column
54
+ * existed carry NULL until their owning package's next boot adopts and stamps them.
55
+ */
56
+ sourcePackage?: string;
57
+ /**
58
+ * The declaring package's version at the time this row was last stamped (from the package's
59
+ * own package.json, resolved at runtime). This is the ordering WITHIN a package that makes
60
+ * version skew safe on a shared database: a boot never prunes, flags, or rewrites a row stamped
61
+ * by a strictly NEWER version of the same package, so an older build cannot delete the types a
62
+ * newer build added or churn the ones it redefined. NULL (legacy rows, or builds whose package
63
+ * version could not be resolved) carries no ordering and keeps the last-writer-wins semantics.
64
+ */
65
+ sourcePackageVersion?: string;
34
66
  }
35
67
 
36
68
  const getSourceRecordColumns = (hideFromUi = true) => {
37
69
  return {
38
70
  isLoadedFromSource: new BooleanColumn('is_loaded_from_source', { ui: { hidden: hideFromUi } }),
71
+ sourcePackage: new StringColumn('source_package', { ui: { hidden: hideFromUi } }),
72
+ sourcePackageVersion: new StringColumn('source_package_version', { ui: { hidden: hideFromUi } }),
39
73
  };
40
74
  };
41
75
 
@@ -78,7 +112,11 @@ type OptionalProperties<T> = Pick<
78
112
  *
79
113
  * On Db.init, the record will be inserted if it doesn't exist, and updated if it does exist to mirror what is in source.
80
114
  *
81
- * If the SourceRecordLoader is deleted from source, the record will be deleted from the db on server startup. This will also be the behavior if id is changed - the record with the old id will be deleted.
115
+ * If the SourceRecordLoader is deleted from source, the record will be deleted from the db on server startup (per the
116
+ * table's `onSourceRemoved` policy) — by the next boot of a build that still carries the declaring package at its
117
+ * version or newer. Ownership is per package: a boot only reconciles rows owned by packages it carries, and never
118
+ * rows a newer version of the same package stamped; a package removed from every build leaves its rows behind.
119
+ * This will also be the behavior if id is changed - the record with the old id will be deleted.
82
120
  */
83
121
  export interface SourceRecordLoader<T extends SourceRecord> extends Loadable {
84
122
  table: Table<T>;