@proteinjs/db 1.42.0 → 1.44.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 +29 -0
- package/dist/generated/index.js +1 -1
- package/dist/generated/index.js.map +1 -1
- package/dist/generated/test/index.d.ts.map +1 -1
- package/dist/generated/test/index.js +3 -1
- package/dist/generated/test/index.js.map +1 -1
- package/dist/src/Table.d.ts +50 -1
- package/dist/src/Table.d.ts.map +1 -1
- package/dist/src/Table.js.map +1 -1
- package/dist/src/source/SourceRecord.d.ts +2 -3
- package/dist/src/source/SourceRecord.d.ts.map +1 -1
- package/dist/src/source/SourceRecord.js +16 -2
- package/dist/src/source/SourceRecord.js.map +1 -1
- package/dist/src/source/SourceRecordLoader.d.ts +41 -0
- package/dist/src/source/SourceRecordLoader.d.ts.map +1 -1
- package/dist/src/source/SourceRecordLoader.js +153 -57
- package/dist/src/source/SourceRecordLoader.js.map +1 -1
- package/dist/src/tables/MigrationTable.d.ts +22 -4
- package/dist/src/tables/MigrationTable.d.ts.map +1 -1
- package/dist/src/tables/MigrationTable.js +38 -16
- package/dist/src/tables/MigrationTable.js.map +1 -1
- package/dist/test/SourceRecordLoader.test.js +151 -0
- package/dist/test/SourceRecordLoader.test.js.map +1 -1
- package/dist/test/reusable/SourceRecordSyncTests.d.ts.map +1 -1
- package/dist/test/reusable/SourceRecordSyncTests.js +234 -0
- package/dist/test/reusable/SourceRecordSyncTests.js.map +1 -1
- package/dist/test/util/tables/sourceRecordSyncTestTables.d.ts +17 -0
- package/dist/test/util/tables/sourceRecordSyncTestTables.d.ts.map +1 -1
- package/dist/test/util/tables/sourceRecordSyncTestTables.js +26 -1
- package/dist/test/util/tables/sourceRecordSyncTestTables.js.map +1 -1
- package/generated/index.ts +1 -1
- package/generated/test/index.ts +3 -1
- package/jest.config.js +8 -0
- package/package.json +2 -2
- package/src/Table.ts +51 -1
- package/src/source/SourceRecord.ts +17 -4
- package/src/source/SourceRecordLoader.ts +110 -4
- package/src/tables/MigrationTable.ts +43 -16
- package/test/SourceRecordLoader.test.ts +90 -0
- package/test/reusable/SourceRecordSyncTests.ts +141 -0
- package/test/util/tables/sourceRecordSyncTestTables.ts +25 -0
|
@@ -149,6 +149,7 @@ export const sourceRecordSyncTests = (
|
|
|
149
149
|
await db.delete(machineTable, {});
|
|
150
150
|
await db.delete(defaultPolicyTable, {});
|
|
151
151
|
await db.delete(sourceRecordSyncTestTables.InheritedStamp, {});
|
|
152
|
+
await db.delete(sourceRecordSyncTestTables.SyncDerivedName, {});
|
|
152
153
|
RecordingMachineAccountWatcher.updates = [];
|
|
153
154
|
});
|
|
154
155
|
|
|
@@ -266,6 +267,51 @@ export const sourceRecordSyncTests = (
|
|
|
266
267
|
expect((await db.get(machineTable, { id: 'machine-1' })).status).toBe('active');
|
|
267
268
|
});
|
|
268
269
|
|
|
270
|
+
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 () => {
|
|
271
|
+
const db = getDbAsSystem();
|
|
272
|
+
const keeper = machineDeclaration({ id: 'machine-keep', email: 'keeper@test.local' });
|
|
273
|
+
await boot([machineDeclaration({ id: 'machine-rn', email: 'rn-old@test.local', displayName: 'Ops' }), keeper]);
|
|
274
|
+
// Runtime-owned state the adoption must preserve (the credential stand-in).
|
|
275
|
+
await db.update(machineTable, { id: 'machine-rn', runtimeNote: 'provisioned' });
|
|
276
|
+
|
|
277
|
+
// The declaration is removed: the update policy KEEPS the row (soft removal).
|
|
278
|
+
await boot([keeper]);
|
|
279
|
+
expect((await db.get(machineTable, { id: 'machine-rn' })).status).toBe('deactivated');
|
|
280
|
+
|
|
281
|
+
// Re-declared under a NEW email: the natural-key match misses, but the soft-removed row
|
|
282
|
+
// still holds the declared id. The sync must adopt that row in place — pre-fix this boot
|
|
283
|
+
// crashed on a PK collision (insert of an id the kept row still occupies).
|
|
284
|
+
RecordingMachineAccountWatcher.updates = [];
|
|
285
|
+
const summary = await boot([
|
|
286
|
+
machineDeclaration({ id: 'machine-rn', email: 'rn-new@test.local', displayName: 'Ops' }),
|
|
287
|
+
keeper,
|
|
288
|
+
]);
|
|
289
|
+
|
|
290
|
+
expect(summary[machineTable.name].adopted).toBe(1);
|
|
291
|
+
expect(await db.get(machineTable, { email: 'rn-old@test.local' })).toBeUndefined();
|
|
292
|
+
const adopted = await db.get(machineTable, { id: 'machine-rn' });
|
|
293
|
+
// Reactivated and re-derived from the declaration; runtime-owned state preserved.
|
|
294
|
+
expect(adopted).toMatchObject({
|
|
295
|
+
email: 'rn-new@test.local',
|
|
296
|
+
status: 'active',
|
|
297
|
+
runtimeNote: 'provisioned',
|
|
298
|
+
isLoadedFromSource: true,
|
|
299
|
+
});
|
|
300
|
+
// No duplicate row was minted: the account's history stays on the one row.
|
|
301
|
+
expect(await machineRows()).toHaveLength(2);
|
|
302
|
+
// The reactivation went through Db.update — watchers observed it.
|
|
303
|
+
const reactivations = RecordingMachineAccountWatcher.updates.filter(
|
|
304
|
+
(update) => update.id === 'machine-rn' && update.status === 'active'
|
|
305
|
+
);
|
|
306
|
+
expect(reactivations).toHaveLength(1);
|
|
307
|
+
expect(reactivations[0]).toMatchObject({ email: 'rn-new@test.local' });
|
|
308
|
+
|
|
309
|
+
// Adoption converges: the next boot writes nothing.
|
|
310
|
+
const stampBefore = adopted.updated.valueOf();
|
|
311
|
+
await boot([machineDeclaration({ id: 'machine-rn', email: 'rn-new@test.local', displayName: 'Ops' }), keeper]);
|
|
312
|
+
expect((await db.get(machineTable, { id: 'machine-rn' })).updated.valueOf()).toBe(stampBefore);
|
|
313
|
+
});
|
|
314
|
+
|
|
269
315
|
test(`onSourceRemoved default: removed source rows are deleted; human rows survive`, async () => {
|
|
270
316
|
const db = getDbAsSystem();
|
|
271
317
|
const human = await db.insert(defaultPolicyTable, { email: 'human@test.local' });
|
|
@@ -279,6 +325,30 @@ export const sourceRecordSyncTests = (
|
|
|
279
325
|
expect(await db.get(defaultPolicyTable, { id: human.id })).toBeDefined();
|
|
280
326
|
});
|
|
281
327
|
|
|
328
|
+
test('the default lifecycle is rollback-idempotent: declare → remove (prune) → re-declare is a clean re-insert, never a collision', async () => {
|
|
329
|
+
const db = getDbAsSystem();
|
|
330
|
+
const keeper = { table: defaultPolicyTable, record: { id: 'default-keep', email: 'keeper@test.local' } };
|
|
331
|
+
const declared = { table: defaultPolicyTable, record: { id: 'rb-1', email: 'rb1@test.local' } };
|
|
332
|
+
await boot([declared, keeper]);
|
|
333
|
+
expect(await db.get(defaultPolicyTable, { id: 'rb-1' })).toBeDefined();
|
|
334
|
+
|
|
335
|
+
// Rollback: the declaration is removed — the default policy hard-deletes the row...
|
|
336
|
+
await boot([keeper]);
|
|
337
|
+
expect(await db.get(defaultPolicyTable, { id: 'rb-1' })).toBeUndefined();
|
|
338
|
+
|
|
339
|
+
// ...so rolling forward again is a plain re-insert under the declared id — no PK
|
|
340
|
+
// collision, no adoption needed. Tables that soft-remove (keep rows on removal) do not
|
|
341
|
+
// get this for free: they must adopt the kept row instead (pinned above on the machine
|
|
342
|
+
// table).
|
|
343
|
+
const summary = await boot([declared, keeper]);
|
|
344
|
+
expect(summary[defaultPolicyTable.name].inserts).toBe(1);
|
|
345
|
+
expect(await db.get(defaultPolicyTable, { id: 'rb-1' })).toMatchObject({
|
|
346
|
+
email: 'rb1@test.local',
|
|
347
|
+
isLoadedFromSource: true,
|
|
348
|
+
sourcePackage: DEFAULT_TEST_SOURCE,
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
|
|
282
352
|
/**
|
|
283
353
|
* Source-scoped pruning: multiple servers running DIFFERENT builds against ONE shared
|
|
284
354
|
* database (the thought_type dev topology — two dev servers on one Spanner database).
|
|
@@ -660,6 +730,77 @@ export const sourceRecordSyncTests = (
|
|
|
660
730
|
});
|
|
661
731
|
});
|
|
662
732
|
|
|
733
|
+
describe('stamp hygiene: an unchanged declaration is never rewritten', () => {
|
|
734
|
+
const versioned = (displayName: string) => ({
|
|
735
|
+
table: defaultPolicyTable,
|
|
736
|
+
record: { id: 'v-1', email: 'v1@test.local', displayName },
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
test('a version bump alone re-stamps nothing: the next release leaves unchanged rows (and their updated stamp) alone', async () => {
|
|
740
|
+
const db = getDbAsSystem();
|
|
741
|
+
await bootAsSource('@test/pkg-a', [versioned('same')], '1.23.0');
|
|
742
|
+
const stamped = await db.get(defaultPolicyTable, { id: 'v-1' });
|
|
743
|
+
expect(stamped.sourcePackageVersion).toBe('1.23.0');
|
|
744
|
+
|
|
745
|
+
// The next release: the SAME declaration under a newer package version (every release
|
|
746
|
+
// bumps the declaring package). Pre-fix this counted the version stamp as drift and
|
|
747
|
+
// rewrote every row on every boot — `updated` churned on rows nobody changed.
|
|
748
|
+
const summary = await bootAsSource('@test/pkg-a', [versioned('same')], '1.24.0');
|
|
749
|
+
expect(summary[defaultPolicyTable.name]).toMatchObject({ updates: 0, unchanged: 1 });
|
|
750
|
+
const idle = await db.get(defaultPolicyTable, { id: 'v-1' });
|
|
751
|
+
expect(idle.updated.valueOf()).toBe(stamped.updated.valueOf());
|
|
752
|
+
// The stamp records the version that last WROTE the row's definition.
|
|
753
|
+
expect(idle.sourcePackageVersion).toBe('1.23.0');
|
|
754
|
+
|
|
755
|
+
// A genuine redefinition still lands, and moves the stamp with it.
|
|
756
|
+
const redefined = await bootAsSource('@test/pkg-a', [versioned('changed')], '1.24.0');
|
|
757
|
+
expect(redefined[defaultPolicyTable.name].updates).toBe(1);
|
|
758
|
+
const rewritten = await db.get(defaultPolicyTable, { id: 'v-1' });
|
|
759
|
+
expect(rewritten).toMatchObject({ displayName: 'changed', sourcePackageVersion: '1.24.0' });
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
test('a row with no version stamp is stamped exactly once', async () => {
|
|
763
|
+
const db = getDbAsSystem();
|
|
764
|
+
// Written before versions existed (or by a build that could not resolve its version).
|
|
765
|
+
await bootAsSource('@test/pkg-a', [versioned('same')]);
|
|
766
|
+
expect((await db.get(defaultPolicyTable, { id: 'v-1' })).sourcePackageVersion).toBeFalsy();
|
|
767
|
+
|
|
768
|
+
const first = await bootAsSource('@test/pkg-a', [versioned('same')], '2.0.0');
|
|
769
|
+
expect(first[defaultPolicyTable.name].updates).toBe(1);
|
|
770
|
+
expect((await db.get(defaultPolicyTable, { id: 'v-1' })).sourcePackageVersion).toBe('2.0.0');
|
|
771
|
+
|
|
772
|
+
const second = await bootAsSource('@test/pkg-a', [versioned('same')], '2.0.0');
|
|
773
|
+
expect(second[defaultPolicyTable.name]).toMatchObject({ updates: 0, unchanged: 1 });
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
test('fromDeclaration: derived fields land on insert, backfill existing rows exactly once, and never re-stamp', async () => {
|
|
777
|
+
const db = getDbAsSystem();
|
|
778
|
+
const derivedTable = sourceRecordSyncTestTables.SyncDerivedName;
|
|
779
|
+
// bootBuild names each seeded loader `${source}/SeededLoader${i}` — the declaration's
|
|
780
|
+
// own name is the part after the package, exactly what a migration's class name is.
|
|
781
|
+
const declaration = { table: derivedTable, record: { id: 'd-1', email: 'd1@test.local' } };
|
|
782
|
+
|
|
783
|
+
await boot([declaration]);
|
|
784
|
+
expect(await db.get(derivedTable, { id: 'd-1' })).toMatchObject({ name: 'SeededLoader0' });
|
|
785
|
+
|
|
786
|
+
// A row written before the derived field existed: backfilled ONCE by the next boot…
|
|
787
|
+
await db.update(derivedTable, { id: 'd-1', name: null });
|
|
788
|
+
const backfill = await boot([declaration]);
|
|
789
|
+
expect(backfill[derivedTable.name].updates).toBe(1);
|
|
790
|
+
const backfilled = await db.get(derivedTable, { id: 'd-1' });
|
|
791
|
+
expect(backfilled.name).toBe('SeededLoader0');
|
|
792
|
+
|
|
793
|
+
// …and never again: the derived value is part of the drift comparison like any field.
|
|
794
|
+
const idle = await boot([declaration]);
|
|
795
|
+
expect(idle[derivedTable.name]).toMatchObject({ updates: 0, unchanged: 1 });
|
|
796
|
+
expect((await db.get(derivedTable, { id: 'd-1' })).updated.valueOf()).toBe(backfilled.updated.valueOf());
|
|
797
|
+
|
|
798
|
+
// The derivation owns the field: a value typed into the record does not survive it.
|
|
799
|
+
await boot([{ table: derivedTable, record: { id: 'd-1', email: 'd1@test.local', name: 'typed-by-hand' } }]);
|
|
800
|
+
expect((await db.get(derivedTable, { id: 'd-1' })).name).toBe('SeededLoader0');
|
|
801
|
+
});
|
|
802
|
+
});
|
|
803
|
+
|
|
663
804
|
test('a natural key without a unique guarantee fails boot loudly', async () => {
|
|
664
805
|
// Local (unregistered, never created) on purpose: a registered table with an invalid
|
|
665
806
|
// naturalKey would poison every loader run in the process — validation fires before any
|
|
@@ -97,8 +97,33 @@ export class InheritedStampTable extends Table<InheritedStampRecord> {
|
|
|
97
97
|
});
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
export interface SyncDerivedNameRecord extends SourceRecord {
|
|
101
|
+
/** Derived by the sync from the DECLARATION (its loader's name) — never typed into the record. */
|
|
102
|
+
name?: string | null;
|
|
103
|
+
email: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The migration-ledger shape: a `name` column the sync fills from the declaration itself
|
|
108
|
+
* (`sourceRecordOptions.fromDeclaration` — the declaring loader's class name), kept on removal
|
|
109
|
+
* like the ledger. The options literal is cast so the suite compiles red-first against a
|
|
110
|
+
* loader that does not yet know the option.
|
|
111
|
+
*/
|
|
112
|
+
export class SyncDerivedNameTable extends Table<SyncDerivedNameRecord> {
|
|
113
|
+
name = 'db_test_sync_derived_name';
|
|
114
|
+
columns: Table<SyncDerivedNameRecord>['columns'] = withSourceRecordColumns<SyncDerivedNameRecord>({
|
|
115
|
+
name: new StringColumn('name'),
|
|
116
|
+
email: new StringColumn('email'),
|
|
117
|
+
});
|
|
118
|
+
sourceRecordOptions = {
|
|
119
|
+
onSourceRemoved: 'keep',
|
|
120
|
+
fromDeclaration: (declaration: { name: string }) => ({ name: declaration.name }),
|
|
121
|
+
} as Table<SyncDerivedNameRecord>['sourceRecordOptions'];
|
|
122
|
+
}
|
|
123
|
+
|
|
100
124
|
export const sourceRecordSyncTestTables = {
|
|
101
125
|
SyncMachineAccount: new SyncMachineAccountTable() as Table<SyncMachineAccount>,
|
|
102
126
|
SyncDefaultPolicy: new SyncDefaultPolicyTable() as Table<SyncDefaultPolicyRecord>,
|
|
103
127
|
InheritedStamp: new InheritedStampTable() as Table<InheritedStampRecord>,
|
|
128
|
+
SyncDerivedName: new SyncDerivedNameTable() as Table<SyncDerivedNameRecord>,
|
|
104
129
|
};
|