@proteinjs/db 1.34.0 → 1.34.2

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.0",
3
+ "version": "1.34.2",
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": "7a301275151aee8040fd52e15e3fe950a6863783"
69
+ "gitHead": "ead198c6ceceff2a37853c6211895b444575295c"
70
70
  }
package/src/Db.ts CHANGED
@@ -41,6 +41,19 @@ export const getDbAsSystem = <R extends Record = Record>() => new Db<R>(undefine
41
41
  const getEnvVar = (key: string): string | undefined =>
42
42
  typeof process !== 'undefined' && process.env ? process.env[key] : undefined;
43
43
 
44
+ /**
45
+ * The lazily-resolved default driver lives on the global object (window in browser, globalThis
46
+ * elsewhere) rather than on a class static: per-package installs can put multiple live copies of
47
+ * @proteinjs/db in one process (each sibling package's nested node_modules hosts its own copy),
48
+ * and a per-copy static means every copy asks the DefaultDbDriverFactory for its own driver —
49
+ * multiple live db clients per process (two Spanner clients holding gRPC channels open was the
50
+ * 2026-08-14 thought-ui jest-never-exits incident). Anchoring on the global gives every copy the
51
+ * one driver — same pattern as reflection's SourceRepository and this package's ReferenceCache.
52
+ */
53
+ const DEFAULT_DB_DRIVER_GLOBAL_KEY = '__proteinjs_db_defaultDbDriver';
54
+
55
+ const getGlobal = (): any => (typeof window !== 'undefined' ? window : globalThis);
56
+
44
57
  export type DbDriverQueryStatementConfig = ParameterizationConfig & {
45
58
  prefixTablesWithDb?: boolean;
46
59
  getDriverColumnType?: (tableName: string, columnName: string) => string;
@@ -79,7 +92,6 @@ export interface DbDriver {
79
92
  }
80
93
 
81
94
  export class Db<R extends Record = Record> implements DbService<R> {
82
- private static defaultDbDriver: DbDriver;
83
95
  private dbDriver: DbDriver;
84
96
  private getTable: (tableName: string) => Table<any>;
85
97
  private logger = new Logger({ name: this.constructor.name, logLevel: getEnvVar('DB_LOG_LEVEL') as any });
@@ -108,7 +120,7 @@ export class Db<R extends Record = Record> implements DbService<R> {
108
120
  }
109
121
 
110
122
  static getDefaultDbDriver(): DbDriver {
111
- if (!Db.defaultDbDriver) {
123
+ if (!getGlobal()[DEFAULT_DB_DRIVER_GLOBAL_KEY]) {
112
124
  const defaultDbDriverFactory = SourceRepository.get().object<DefaultDbDriverFactory>(
113
125
  '@proteinjs/db/DefaultDbDriverFactory'
114
126
  );
@@ -118,10 +130,10 @@ export class Db<R extends Record = Record> implements DbService<R> {
118
130
  );
119
131
  }
120
132
 
121
- Db.defaultDbDriver = defaultDbDriverFactory.getDbDriver();
133
+ getGlobal()[DEFAULT_DB_DRIVER_GLOBAL_KEY] = defaultDbDriverFactory.getDbDriver();
122
134
  }
123
135
 
124
- return Db.defaultDbDriver;
136
+ return getGlobal()[DEFAULT_DB_DRIVER_GLOBAL_KEY];
125
137
  }
126
138
 
127
139
  private getDefaultTransactionContextFactory(): DefaultTransactionContextFactory {
@@ -206,7 +206,7 @@ export class MigrationRunner implements MigrationRunnerService {
206
206
  const db = getRunDb();
207
207
  migration.status = 'running';
208
208
  migration.startTime = moment();
209
- await db.update(migrationTable, migration);
209
+ await db.update(migrationTable, this.definedFields(migration));
210
210
  this.logger.info({ message: `Running migration (${migration.id}) ${migration.description}` });
211
211
  try {
212
212
  migration.output = await migration.run();
@@ -222,12 +222,31 @@ export class MigrationRunner implements MigrationRunnerService {
222
222
  migration.endTime = moment();
223
223
  }
224
224
  migration.duration = this.duration(migration.startTime, migration.endTime);
225
- await db.update(migrationTable, migration);
225
+ await db.update(migrationTable, this.definedFields(migration));
226
226
  this.logger.info({
227
227
  message: `[${migration.status}] (${migration.duration}) Finished running migration (${migration.id}) ${migration.description}`,
228
228
  });
229
229
  }
230
230
 
231
+ /**
232
+ * The run-state payload with `undefined`-valued fields OMITTED. Several of the record's fields
233
+ * are legitimately absent depending on the run (`output` for a void `run()`, `failureMessage`/
234
+ * `failureStack` for a non-Error throw), but the migration object carries them as explicit
235
+ * `undefined` assignments — and `RecordSerializer` rejects any payload field holding `undefined`
236
+ * (never a partial write), which would strand the row at 'running' status with the run's real
237
+ * outcome lost. Absent means omitted, never undefined — for EVERY optional field of the payload,
238
+ * not per-field.
239
+ */
240
+ private definedFields(migration: Migration): Partial<Migration> {
241
+ const payload: Partial<Migration> = {};
242
+ for (const [field, value] of Object.entries(migration)) {
243
+ if (value !== undefined) {
244
+ (payload as any)[field] = value;
245
+ }
246
+ }
247
+ return payload;
248
+ }
249
+
231
250
  private resolveMigration(migrationTable: Table<Migration>, id: string): Migration {
232
251
  const migration = new SourceRecordRepo().getSourceRecord<Migration>(migrationTable.name, id);
233
252
  if (!migration) {
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Duplicate-module-instance guard for the default db driver (task #36).
3
+ *
4
+ * Per-package installs can put two live copies of @proteinjs/db in one process (each sibling
5
+ * package's nested node_modules hosts its own registry copy). `Db.defaultDbDriver` as a class
6
+ * static then splits per copy: every copy that lazily initializes asks the (globally converged,
7
+ * reflection-routed) DefaultDbDriverFactory for a driver, and the factory mints a fresh one per
8
+ * call — two live Spanner clients holding two gRPC channel sets. That is the 2026-08-14
9
+ * thought-ui incident: jest finished green and then never exited, the second universe's Spanner
10
+ * client keeping the process alive.
11
+ *
12
+ * `jest.isolateModules` reproduces the split semantics exactly: each isolated registry gets its
13
+ * own module instance of Db with its own statics, the same way two nested install paths do. The
14
+ * guard asserts every live copy resolves the SAME driver instance and the factory is consulted
15
+ * once — which only holds when the resolved driver is anchored on the process global, not on a
16
+ * per-copy class static.
17
+ */
18
+ import { SourceRepository } from '@proteinjs/reflection';
19
+
20
+ type DbModule = typeof import('../src/Db');
21
+
22
+ const loadIsolatedCopy = (): DbModule => {
23
+ let copy: DbModule | undefined;
24
+ jest.isolateModules(() => {
25
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
26
+ copy = require('../src/Db');
27
+ });
28
+
29
+ if (!copy) {
30
+ throw new Error('Failed to load an isolated copy of Db');
31
+ }
32
+
33
+ return copy;
34
+ };
35
+
36
+ describe('Db default driver under duplicate module instances', () => {
37
+ let driversMinted: number;
38
+
39
+ beforeEach(() => {
40
+ driversMinted = 0;
41
+ // House test pattern: seed the (globalThis-anchored, therefore converged) SourceRepository
42
+ // object cache directly. The factory mints a fresh driver object per call, mirroring real
43
+ // DefaultDbDriverFactory implementations (e.g. the app's SpannerDriver factory).
44
+ (SourceRepository.get() as any).objectCache['@proteinjs/db/DefaultDbDriverFactory'] = [
45
+ {
46
+ getDbDriver: () => {
47
+ driversMinted++;
48
+ return { minted: driversMinted };
49
+ },
50
+ },
51
+ ];
52
+ });
53
+
54
+ afterEach(() => {
55
+ delete (SourceRepository.get() as any).objectCache['@proteinjs/db/DefaultDbDriverFactory'];
56
+ delete (globalThis as any).__proteinjs_db_defaultDbDriver;
57
+ });
58
+
59
+ test('every live copy of Db resolves the same default driver instance', () => {
60
+ const copyA = loadIsolatedCopy();
61
+ const copyB = loadIsolatedCopy();
62
+ expect(copyA).not.toBe(copyB); // two distinct module instances, as under per-package installs
63
+
64
+ const driverA = copyA.Db.getDefaultDbDriver();
65
+ const driverB = copyB.Db.getDefaultDbDriver();
66
+
67
+ expect(driverB).toBe(driverA); // one process, one driver — one live client
68
+ expect(driversMinted).toBe(1); // the factory is consulted exactly once per process
69
+ });
70
+ });