@proteinjs/db 1.41.0 → 1.43.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 (36) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/generated/index.js +1 -1
  3. package/dist/generated/index.js.map +1 -1
  4. package/dist/generated/test/index.js +1 -1
  5. package/dist/generated/test/index.js.map +1 -1
  6. package/dist/index.d.ts +1 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +1 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/src/DetachedDbOps.d.ts +27 -0
  11. package/dist/src/DetachedDbOps.d.ts.map +1 -0
  12. package/dist/src/DetachedDbOps.js +60 -0
  13. package/dist/src/DetachedDbOps.js.map +1 -0
  14. package/dist/src/Table.d.ts +13 -0
  15. package/dist/src/Table.d.ts.map +1 -1
  16. package/dist/src/source/SourceRecordLoader.d.ts +29 -0
  17. package/dist/src/source/SourceRecordLoader.d.ts.map +1 -1
  18. package/dist/src/source/SourceRecordLoader.js +102 -28
  19. package/dist/src/source/SourceRecordLoader.js.map +1 -1
  20. package/dist/test/DetachedDbOps.test.d.ts +2 -0
  21. package/dist/test/DetachedDbOps.test.d.ts.map +1 -0
  22. package/dist/test/DetachedDbOps.test.js +158 -0
  23. package/dist/test/DetachedDbOps.test.js.map +1 -0
  24. package/dist/test/reusable/SourceRecordSyncTests.d.ts.map +1 -1
  25. package/dist/test/reusable/SourceRecordSyncTests.js +110 -0
  26. package/dist/test/reusable/SourceRecordSyncTests.js.map +1 -1
  27. package/generated/index.ts +1 -1
  28. package/generated/test/index.ts +1 -1
  29. package/index.ts +1 -0
  30. package/jest.config.js +8 -0
  31. package/package.json +2 -2
  32. package/src/DetachedDbOps.ts +47 -0
  33. package/src/Table.ts +13 -0
  34. package/src/source/SourceRecordLoader.ts +80 -1
  35. package/test/DetachedDbOps.test.ts +90 -0
  36. package/test/reusable/SourceRecordSyncTests.ts +69 -0
package/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from './src/Db';
2
+ export * from './src/DetachedDbOps';
2
3
  export * from './src/Table';
3
4
  export * from './src/RecordAccessError';
4
5
  export * from './src/auth/TableAuth';
package/jest.config.js CHANGED
@@ -5,5 +5,13 @@ module.exports = {
5
5
  },
6
6
  testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$',
7
7
  moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
8
+ moduleNameMapper: {
9
+ // One copy per process in symlinked-workspace estates (the second-copy class —
10
+ // user-server/app-ui precedent, TRAIN_MANIFEST_5 §6.15): ServiceAuth and the suites must
11
+ // share ONE UserAuth class object for the static-stub idiom to reach the real gate, and
12
+ // reflection's SourceRepository is a process singleton. CI's npm dedupe makes these no-ops.
13
+ '^@proteinjs/reflection$': '<rootDir>/node_modules/@proteinjs/reflection',
14
+ '^@proteinjs/user-auth$': '<rootDir>/node_modules/@proteinjs/user-auth',
15
+ },
8
16
  testEnvironment: 'node',
9
17
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proteinjs/db",
3
- "version": "1.41.0",
3
+ "version": "1.43.0",
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": "149e538c8a05858b6eb7d3a2dc362e5ff196caf9"
69
+ "gitHead": "b9fcfe6efb85e76f68876f7b1f2c38e5fdab7932"
70
70
  }
@@ -0,0 +1,47 @@
1
+ import { Logger } from '@proteinjs/logger';
2
+
3
+ /**
4
+ * THE detachment idiom for deliberately fire-and-forget db work — one owner.
5
+ *
6
+ * Under node's default unhandled-rejection policy (node 15+ `--unhandled-rejections=throw`;
7
+ * node 24 in production), a detached db operation that REJECTS — e.g. a write that trips the
8
+ * driver's op deadline (`SpannerConfig.opDeadlineMs`, default 60s) — is an unhandled rejection
9
+ * that KILLS THE PROCESS (observed live: a detached write past the 60s driver deadline aborted
10
+ * the server). Detaching is a legitimate, named decision — the caller does not need the outcome
11
+ * to proceed — but the rejection still needs a terminal observer. This class is that observer:
12
+ * every deliberately un-awaited db operation routes through `run`, which invokes the work,
13
+ * terminally catches (synchronous throws included), and logs the failure WITH the caller's
14
+ * context — never process death, never a silent swallow.
15
+ *
16
+ * NOT for outcomes the caller depends on: work whose failure must alter behavior is awaited at
17
+ * the call site. `run` returns void by design, so a detached result cannot be accidentally
18
+ * consumed.
19
+ */
20
+ export class DetachedDbOps {
21
+ private static logger = new Logger({ name: 'DetachedDbOps' });
22
+
23
+ /**
24
+ * Run `work` detached. Failures — a rejected promise or a synchronous throw — are terminally
25
+ * logged under `description` with `context`, never rethrown and never left unhandled.
26
+ */
27
+ static run(description: string, work: () => Promise<unknown>, context?: Record<string, unknown>): void {
28
+ let result: Promise<unknown>;
29
+ try {
30
+ result = work();
31
+ } catch (error) {
32
+ this.observe(description, error, context);
33
+ return;
34
+ }
35
+ result.catch((error) => this.observe(description, error, context));
36
+ }
37
+
38
+ // --- helpers last ---
39
+
40
+ private static observe(description: string, error: unknown, context?: Record<string, unknown>): void {
41
+ this.logger.error({
42
+ message: `Detached db operation failed: ${description}`,
43
+ error,
44
+ ...(context ? { obj: context } : {}),
45
+ });
46
+ }
47
+ }
package/src/Table.ts CHANGED
@@ -355,6 +355,19 @@ export type SourceRecordOptions<T = any> = {
355
355
  * `Db.update`, so table watchers observe the write. Re-declaring the record reverts the
356
356
  * patch via normal drift reversion — removal is reversible in source.
357
357
  *
358
+ * THE SOFT-REMOVAL CONTRACT: a source-record table that soft-removes (keeps rows on removal —
359
+ * the `{ update }` policy) must adopt its soft-removed rows on re-declaration and re-derive
360
+ * their state from the declaration. Under the default (delete) lifecycle, re-declaration is a
361
+ * plain re-insert and naturally idempotent; a kept row instead still holds the declared id, so
362
+ * a re-declaration whose natural key no longer matches it (renamed while removed) would INSERT
363
+ * into a primary-key collision and fail the boot. The sync therefore widens adoption for these
364
+ * tables: a declaration whose natural key matches nothing re-claims the soft-removed row
365
+ * holding its declared id (same owning package only), reactivating it by re-deriving every
366
+ * declared field — declaration is the source of truth, so no removed-era state (grants,
367
+ * status, natural key) survives re-adoption, while runtime-owned fields the declaration never
368
+ * emits are preserved. Any future soft-removal table inherits this requirement; machine
369
+ * accounts (the `@proteinjs/user` user table) are the first implementer.
370
+ *
358
371
  * Rows never loaded from source are structurally untouchable by every policy: the removed
359
372
  * reconcile only ever matches `is_loaded_from_source = true`.
360
373
  */
@@ -106,7 +106,20 @@ export class SourceRecordLoader {
106
106
  if (sourceVersion !== undefined) {
107
107
  sourceRecord.sourcePackageVersion = sourceVersion;
108
108
  }
109
- const existingRecord = await db.get(table, { [keyProperty]: (sourceRecord as any)[keyProperty] });
109
+ let existingRecord = await db.get(table, { [keyProperty]: (sourceRecord as any)[keyProperty] });
110
+ let reclaimedSoftRemoved = false;
111
+ if (!existingRecord) {
112
+ existingRecord = await this.softRemovedRecordHoldingDeclaredId(
113
+ db,
114
+ table,
115
+ keyProperty,
116
+ sourceRecord.id,
117
+ source,
118
+ allDeclaredKeys
119
+ );
120
+ reclaimedSoftRemoved = existingRecord !== undefined;
121
+ }
122
+
110
123
  if (existingRecord) {
111
124
  if (
112
125
  existingRecord.sourcePackage === source &&
@@ -133,6 +146,18 @@ export class SourceRecordLoader {
133
146
  message: `(${table.name}) Adopting existing record into source ownership`,
134
147
  obj: { [keyProperty]: (sourceRecord as any)[keyProperty], id: existingRecord.id },
135
148
  });
149
+ } else if (reclaimedSoftRemoved) {
150
+ // A soft-removed row re-claimed by its declaration under a changed natural key:
151
+ // adopted in place and reactivated from the declaration by the update below.
152
+ adoptedCount += 1;
153
+ this.logger.info({
154
+ message: `(${table.name}) Adopting soft-removed record re-declared under a changed ${keyProperty}`,
155
+ obj: {
156
+ id: existingRecord.id,
157
+ [`removed_${keyProperty}`]: (existingRecord as any)[keyProperty],
158
+ [keyProperty]: (sourceRecord as any)[keyProperty],
159
+ },
160
+ });
136
161
  }
137
162
 
138
163
  if (await this.hasChanges(table, sourceRecord, existingRecord)) {
@@ -240,6 +265,60 @@ export class SourceRecordLoader {
240
265
  return { deleteCount: 0, removedUpdateCount, skippedNewer };
241
266
  }
242
267
 
268
+ /**
269
+ * The widened adoption match for tables that SOFT-remove (keep rows on removal via the
270
+ * `onSourceRemoved` update policy): a declaration whose natural key matched nothing may still
271
+ * be a RE-declaration of a removed-and-kept row — the same stable declared id under a changed
272
+ * natural key (e.g. a machine account renamed while decommissioned). Without this, the insert
273
+ * leg collides with the kept row's primary key and the boot fails.
274
+ *
275
+ * Returns the soft-removed row the declaration re-claims, or undefined. The match is
276
+ * deliberately strict — all of:
277
+ * - the table keeps removed rows (`onSourceRemoved` is an update patch — soft removal);
278
+ * - a row holds the DECLARED id;
279
+ * - that row is source-owned (`is_loaded_from_source = true`) by THIS declaration's package
280
+ * (never adopt a human/runtime row, or another package's row, by id);
281
+ * - no declaration in this build claims the row by natural key (it is a removed row, not a
282
+ * duplicate-id declaration — that case still fails the boot loudly at insert).
283
+ *
284
+ * The caller then reactivates the row by re-deriving every declared field from the current
285
+ * declaration (natural key included) through the normal drift-reversion update — declaration
286
+ * is the source of truth, so nothing removed-era (grants, status, natural key) survives
287
+ * re-adoption; runtime-owned fields the declaration never emits are untouched. The version
288
+ * guard is the caller's existing stamped-newer branch, which applies to the returned row
289
+ * verbatim (same package by construction).
290
+ *
291
+ * This is the one-off form of a future generic `softRemoval` hook (adopt-including-removed +
292
+ * reactivate-from-declaration): graduating it means lifting this method behind a table-level
293
+ * seam, not rewriting the semantics. The contract lives on
294
+ * {@link SourceRecordOptions.onSourceRemoved}.
295
+ */
296
+ private async softRemovedRecordHoldingDeclaredId(
297
+ db: Db,
298
+ table: Table<any>,
299
+ keyProperty: string,
300
+ declaredId: string,
301
+ source: string,
302
+ allDeclaredKeys: unknown[]
303
+ ): Promise<SourceRecord | undefined> {
304
+ const policy = table.sourceRecordOptions.onSourceRemoved;
305
+ if (typeof policy !== 'object') {
306
+ return undefined;
307
+ }
308
+
309
+ const holder: SourceRecord | undefined = await db.get(table, { id: declaredId });
310
+ if (
311
+ !holder ||
312
+ holder.isLoadedFromSource !== true ||
313
+ holder.sourcePackage !== source ||
314
+ allDeclaredKeys.includes((holder as any)[keyProperty])
315
+ ) {
316
+ return undefined;
317
+ }
318
+
319
+ return holder;
320
+ }
321
+
243
322
  /**
244
323
  * Source-loaded rows with no owner stamp that no declaration in this build claimed on this
245
324
  * boot (the stamp leg has already run). They predate `source_package`, or their declaring
@@ -0,0 +1,90 @@
1
+ import { Logger } from '@proteinjs/logger';
2
+ import { DetachedDbOps } from '../src/DetachedDbOps';
3
+
4
+ /**
5
+ * The detachment idiom's contract (the node-24 crash class): a deliberately fire-and-forget db
6
+ * operation that REJECTS — e.g. a write that trips the driver's 60s op deadline — must be
7
+ * terminally observed (logged with the caller's context), never left as an unhandled rejection
8
+ * for node's default policy to kill the process over. These tests run with no emulator: the
9
+ * simulated driver rejection IS the deadline failure's shape.
10
+ *
11
+ * Note: jest itself surfaces unhandled rejections as failures — so each green run here doubles
12
+ * as the no-escape assertion (a dropped catch turns the log pins red AND leaks the rejection).
13
+ */
14
+
15
+ const flushDetached = async () => {
16
+ // The terminal catch attaches synchronously; the rejection lands on the microtask queue.
17
+ await Promise.resolve();
18
+ await new Promise((resolve) => setImmediate(resolve));
19
+ };
20
+
21
+ describe('DetachedDbOps', () => {
22
+ let errorLogs: Array<{ message: string; obj?: Record<string, unknown>; error?: unknown }>;
23
+ let errorSpy: jest.SpyInstance;
24
+
25
+ beforeEach(() => {
26
+ errorLogs = [];
27
+ errorSpy = jest.spyOn(Logger.prototype, 'error').mockImplementation(function (log: any) {
28
+ errorLogs.push(log);
29
+ });
30
+ });
31
+
32
+ afterEach(() => {
33
+ errorSpy.mockRestore();
34
+ });
35
+
36
+ test('a rejecting detached operation is terminally observed — logged with the caller context, no escaping rejection', async () => {
37
+ const deadlineRejection = new Error(
38
+ '4 DEADLINE_EXCEEDED: spanner dml transaction (runTransactionAsync) exceeded the 60000ms op deadline'
39
+ );
40
+ DetachedDbOps.run('write flow-run heartbeat', () => Promise.reject(deadlineRejection), {
41
+ flowRunId: 'run-1',
42
+ table: 'flow_run',
43
+ });
44
+ await flushDetached();
45
+
46
+ expect(errorLogs).toHaveLength(1);
47
+ expect(errorLogs[0].message).toContain('Detached db operation failed');
48
+ expect(errorLogs[0].message).toContain('write flow-run heartbeat');
49
+ expect(errorLogs[0].obj).toEqual({ flowRunId: 'run-1', table: 'flow_run' });
50
+ expect(errorLogs[0].error).toBe(deadlineRejection);
51
+ });
52
+
53
+ test('a synchronously-throwing work function is contained the same way — the caller never sees the throw', async () => {
54
+ const boom = new Error('driver construction failed before any promise existed');
55
+ expect(() =>
56
+ DetachedDbOps.run(
57
+ 'sync-throwing detached op',
58
+ () => {
59
+ throw boom;
60
+ },
61
+ { site: 'test' }
62
+ )
63
+ ).not.toThrow();
64
+ await flushDetached();
65
+
66
+ expect(errorLogs).toHaveLength(1);
67
+ expect(errorLogs[0].message).toContain('sync-throwing detached op');
68
+ expect(errorLogs[0].error).toBe(boom);
69
+ });
70
+
71
+ test('a resolving detached operation logs nothing', async () => {
72
+ let ran = false;
73
+ DetachedDbOps.run('healthy detached op', async () => {
74
+ ran = true;
75
+ return 'ok';
76
+ });
77
+ await flushDetached();
78
+
79
+ expect(ran).toBe(true);
80
+ expect(errorLogs).toHaveLength(0);
81
+ });
82
+
83
+ test('context is optional — a rejection without context still logs the description', async () => {
84
+ DetachedDbOps.run('context-free detached op', () => Promise.reject(new Error('deadline')));
85
+ await flushDetached();
86
+
87
+ expect(errorLogs).toHaveLength(1);
88
+ expect(errorLogs[0].message).toContain('context-free detached op');
89
+ });
90
+ });
@@ -266,6 +266,51 @@ export const sourceRecordSyncTests = (
266
266
  expect((await db.get(machineTable, { id: 'machine-1' })).status).toBe('active');
267
267
  });
268
268
 
269
+ test('soft removal + re-declaration under a renamed natural key: the kept row is adopted by its declared id — reactivated and re-derived, never collided into', async () => {
270
+ const db = getDbAsSystem();
271
+ const keeper = machineDeclaration({ id: 'machine-keep', email: 'keeper@test.local' });
272
+ await boot([machineDeclaration({ id: 'machine-rn', email: 'rn-old@test.local', displayName: 'Ops' }), keeper]);
273
+ // Runtime-owned state the adoption must preserve (the credential stand-in).
274
+ await db.update(machineTable, { id: 'machine-rn', runtimeNote: 'provisioned' });
275
+
276
+ // The declaration is removed: the update policy KEEPS the row (soft removal).
277
+ await boot([keeper]);
278
+ expect((await db.get(machineTable, { id: 'machine-rn' })).status).toBe('deactivated');
279
+
280
+ // Re-declared under a NEW email: the natural-key match misses, but the soft-removed row
281
+ // still holds the declared id. The sync must adopt that row in place — pre-fix this boot
282
+ // crashed on a PK collision (insert of an id the kept row still occupies).
283
+ RecordingMachineAccountWatcher.updates = [];
284
+ const summary = await boot([
285
+ machineDeclaration({ id: 'machine-rn', email: 'rn-new@test.local', displayName: 'Ops' }),
286
+ keeper,
287
+ ]);
288
+
289
+ expect(summary[machineTable.name].adopted).toBe(1);
290
+ expect(await db.get(machineTable, { email: 'rn-old@test.local' })).toBeUndefined();
291
+ const adopted = await db.get(machineTable, { id: 'machine-rn' });
292
+ // Reactivated and re-derived from the declaration; runtime-owned state preserved.
293
+ expect(adopted).toMatchObject({
294
+ email: 'rn-new@test.local',
295
+ status: 'active',
296
+ runtimeNote: 'provisioned',
297
+ isLoadedFromSource: true,
298
+ });
299
+ // No duplicate row was minted: the account's history stays on the one row.
300
+ expect(await machineRows()).toHaveLength(2);
301
+ // The reactivation went through Db.update — watchers observed it.
302
+ const reactivations = RecordingMachineAccountWatcher.updates.filter(
303
+ (update) => update.id === 'machine-rn' && update.status === 'active'
304
+ );
305
+ expect(reactivations).toHaveLength(1);
306
+ expect(reactivations[0]).toMatchObject({ email: 'rn-new@test.local' });
307
+
308
+ // Adoption converges: the next boot writes nothing.
309
+ const stampBefore = adopted.updated.valueOf();
310
+ await boot([machineDeclaration({ id: 'machine-rn', email: 'rn-new@test.local', displayName: 'Ops' }), keeper]);
311
+ expect((await db.get(machineTable, { id: 'machine-rn' })).updated.valueOf()).toBe(stampBefore);
312
+ });
313
+
269
314
  test(`onSourceRemoved default: removed source rows are deleted; human rows survive`, async () => {
270
315
  const db = getDbAsSystem();
271
316
  const human = await db.insert(defaultPolicyTable, { email: 'human@test.local' });
@@ -279,6 +324,30 @@ export const sourceRecordSyncTests = (
279
324
  expect(await db.get(defaultPolicyTable, { id: human.id })).toBeDefined();
280
325
  });
281
326
 
327
+ test('the default lifecycle is rollback-idempotent: declare → remove (prune) → re-declare is a clean re-insert, never a collision', async () => {
328
+ const db = getDbAsSystem();
329
+ const keeper = { table: defaultPolicyTable, record: { id: 'default-keep', email: 'keeper@test.local' } };
330
+ const declared = { table: defaultPolicyTable, record: { id: 'rb-1', email: 'rb1@test.local' } };
331
+ await boot([declared, keeper]);
332
+ expect(await db.get(defaultPolicyTable, { id: 'rb-1' })).toBeDefined();
333
+
334
+ // Rollback: the declaration is removed — the default policy hard-deletes the row...
335
+ await boot([keeper]);
336
+ expect(await db.get(defaultPolicyTable, { id: 'rb-1' })).toBeUndefined();
337
+
338
+ // ...so rolling forward again is a plain re-insert under the declared id — no PK
339
+ // collision, no adoption needed. Tables that soft-remove (keep rows on removal) do not
340
+ // get this for free: they must adopt the kept row instead (pinned above on the machine
341
+ // table).
342
+ const summary = await boot([declared, keeper]);
343
+ expect(summary[defaultPolicyTable.name].inserts).toBe(1);
344
+ expect(await db.get(defaultPolicyTable, { id: 'rb-1' })).toMatchObject({
345
+ email: 'rb1@test.local',
346
+ isLoadedFromSource: true,
347
+ sourcePackage: DEFAULT_TEST_SOURCE,
348
+ });
349
+ });
350
+
282
351
  /**
283
352
  * Source-scoped pruning: multiple servers running DIFFERENT builds against ONE shared
284
353
  * database (the thought_type dev topology — two dev servers on one Spanner database).