@proteinjs/db 1.42.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.
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.42.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": "bbbbd77251d6c48bd4dd31331c2bbb8113337597"
69
+ "gitHead": "b9fcfe6efb85e76f68876f7b1f2c38e5fdab7932"
70
70
  }
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
@@ -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).