@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/CHANGELOG.md +18 -0
- package/dist/generated/index.js +1 -1
- package/dist/generated/index.js.map +1 -1
- package/dist/generated/test/index.js +1 -1
- package/dist/generated/test/index.js.map +1 -1
- package/dist/src/Table.d.ts +13 -0
- package/dist/src/Table.d.ts.map +1 -1
- package/dist/src/source/SourceRecordLoader.d.ts +29 -0
- package/dist/src/source/SourceRecordLoader.d.ts.map +1 -1
- package/dist/src/source/SourceRecordLoader.js +102 -28
- package/dist/src/source/SourceRecordLoader.js.map +1 -1
- package/dist/test/reusable/SourceRecordSyncTests.d.ts.map +1 -1
- package/dist/test/reusable/SourceRecordSyncTests.js +110 -0
- package/dist/test/reusable/SourceRecordSyncTests.js.map +1 -1
- package/generated/index.ts +1 -1
- package/generated/test/index.ts +1 -1
- package/jest.config.js +8 -0
- package/package.json +2 -2
- package/src/Table.ts +13 -0
- package/src/source/SourceRecordLoader.ts +80 -1
- package/test/reusable/SourceRecordSyncTests.ts +69 -0
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.
|
|
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": "
|
|
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
|
-
|
|
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).
|