@proteinjs/db 1.34.3 → 1.35.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 +22 -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/Db.d.ts +10 -0
- package/dist/src/Db.d.ts.map +1 -1
- package/dist/src/Db.js +42 -2
- package/dist/src/Db.js.map +1 -1
- package/dist/src/MigrationRunner.d.ts +26 -0
- package/dist/src/MigrationRunner.d.ts.map +1 -1
- package/dist/src/MigrationRunner.js +82 -0
- package/dist/src/MigrationRunner.js.map +1 -1
- package/dist/src/source/SourceRecord.d.ts +35 -2
- package/dist/src/source/SourceRecord.d.ts.map +1 -1
- package/dist/src/source/SourceRecord.js +8 -1
- package/dist/src/source/SourceRecord.js.map +1 -1
- package/dist/src/source/SourceRecordLoader.d.ts +105 -7
- package/dist/src/source/SourceRecordLoader.d.ts.map +1 -1
- package/dist/src/source/SourceRecordLoader.js +358 -94
- package/dist/src/source/SourceRecordLoader.js.map +1 -1
- package/dist/src/tables/MigrationTable.d.ts +26 -0
- package/dist/src/tables/MigrationTable.d.ts.map +1 -1
- package/dist/src/tables/MigrationTable.js +1 -0
- package/dist/src/tables/MigrationTable.js.map +1 -1
- package/dist/test/reusable/SourceRecordSyncTests.d.ts.map +1 -1
- package/dist/test/reusable/SourceRecordSyncTests.js +803 -27
- package/dist/test/reusable/SourceRecordSyncTests.js.map +1 -1
- package/dist/test/util/tables/sourceRecordSyncTestTables.d.ts +16 -0
- package/dist/test/util/tables/sourceRecordSyncTestTables.d.ts.map +1 -1
- package/dist/test/util/tables/sourceRecordSyncTestTables.js +23 -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/package.json +4 -4
- package/src/Db.ts +19 -0
- package/src/MigrationRunner.ts +62 -0
- package/src/source/SourceRecord.ts +42 -4
- package/src/source/SourceRecordLoader.ts +342 -48
- package/src/tables/MigrationTable.ts +24 -0
- package/test/reusable/SourceRecordSyncTests.ts +462 -11
- package/test/util/tables/sourceRecordSyncTestTables.ts +20 -0
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
Table,
|
|
10
10
|
TableWatcher,
|
|
11
11
|
getDbAsSystem,
|
|
12
|
+
isSourceRecordTable,
|
|
12
13
|
withSourceRecordColumns,
|
|
13
14
|
} from '@proteinjs/db';
|
|
14
15
|
import type { DefaultTransactionContextFactory } from '@proteinjs/db';
|
|
@@ -24,7 +25,11 @@ import {
|
|
|
24
25
|
sourceRecordSyncTestTables,
|
|
25
26
|
} from '../util/tables/sourceRecordSyncTestTables';
|
|
26
27
|
|
|
27
|
-
type SourceRepositoryInternals = {
|
|
28
|
+
type SourceRepositoryInternals = {
|
|
29
|
+
objectCache: { [qualifiedName: string]: unknown[] };
|
|
30
|
+
namedObjectCache: { [qualifiedName: string]: { qualifiedName: string; packageName: string; object: unknown }[] };
|
|
31
|
+
};
|
|
32
|
+
type LoaderInternals = { resolveSourceVersion: (source: string) => string | undefined };
|
|
28
33
|
type DbStatics = { defaultDbDriver?: DbDriver };
|
|
29
34
|
type TableWatcherRunnerStatics = { tableWatcherMap?: unknown };
|
|
30
35
|
|
|
@@ -62,14 +67,45 @@ export const sourceRecordSyncTests = (
|
|
|
62
67
|
const machineTable = sourceRecordSyncTestTables.SyncMachineAccount;
|
|
63
68
|
const defaultPolicyTable = sourceRecordSyncTestTables.SyncDefaultPolicy;
|
|
64
69
|
const objectCache = () => (SourceRepository.get() as unknown as SourceRepositoryInternals).objectCache;
|
|
70
|
+
const namedObjectCache = () => (SourceRepository.get() as unknown as SourceRepositoryInternals).namedObjectCache;
|
|
65
71
|
let originalWatchers: unknown[] | undefined;
|
|
66
72
|
|
|
67
|
-
/**
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
73
|
+
/** The single source most of the suite boots as (multi-source tests name their own). */
|
|
74
|
+
const DEFAULT_TEST_SOURCE = '@test/source-a';
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* One boot of the loader leg as a BUILD: each declaration owned by its declaring package
|
|
78
|
+
* (`source`), seeded through the named-object cache the loader resolves declarations (and
|
|
79
|
+
* their owning packages) from. `versions` pins each package's version for the boot (the
|
|
80
|
+
* loader's version resolution is overridden — a package absent from the map is versionless,
|
|
81
|
+
* matching a build whose package.json could not be resolved).
|
|
82
|
+
*/
|
|
83
|
+
const bootBuild = async (
|
|
84
|
+
declarations: { source: string; table: Table<any>; record: any }[],
|
|
85
|
+
versions: { [source: string]: string | undefined } = {}
|
|
86
|
+
) => {
|
|
87
|
+
namedObjectCache()['@proteinjs/db/SourceRecordLoader'] = declarations.map((declaration, i) => ({
|
|
88
|
+
qualifiedName: `${declaration.source}/SeededLoader${i}`,
|
|
89
|
+
packageName: declaration.source,
|
|
90
|
+
object: { table: declaration.table, record: declaration.record },
|
|
91
|
+
}));
|
|
92
|
+
const loader = new SourceRecordLoader();
|
|
93
|
+
(loader as unknown as LoaderInternals).resolveSourceVersion = (source) =>
|
|
94
|
+
Object.prototype.hasOwnProperty.call(versions, source) ? versions[source] : undefined;
|
|
95
|
+
return await loader.load();
|
|
71
96
|
};
|
|
72
97
|
|
|
98
|
+
/** One boot of the loader leg as a build declaring everything from a single package. */
|
|
99
|
+
const bootAsSource = async (source: string, declarations: { table: Table<any>; record: any }[], version?: string) =>
|
|
100
|
+
await bootBuild(
|
|
101
|
+
declarations.map((declaration) => ({ source, ...declaration })),
|
|
102
|
+
version === undefined ? {} : { [source]: version }
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
/** Seed the boot declarations and run the sync — one boot of `Db.init`'s loader leg. */
|
|
106
|
+
const boot = async (declarations: { table: Table<any>; record: any }[]) =>
|
|
107
|
+
await bootAsSource(DEFAULT_TEST_SOURCE, declarations);
|
|
108
|
+
|
|
73
109
|
const machineDeclaration = (record: Partial<SyncMachineAccount> & { id: string; email: string }) => ({
|
|
74
110
|
table: machineTable,
|
|
75
111
|
record: { status: 'active', ...record },
|
|
@@ -95,6 +131,7 @@ export const sourceRecordSyncTests = (
|
|
|
95
131
|
|
|
96
132
|
afterAll(async () => {
|
|
97
133
|
await testEnv.afterAll();
|
|
134
|
+
delete namedObjectCache()['@proteinjs/db/SourceRecordLoader'];
|
|
98
135
|
delete objectCache()['@proteinjs/db/SourceRecordLoader'];
|
|
99
136
|
delete objectCache()['@proteinjs/db/DefaultDbDriverFactory'];
|
|
100
137
|
delete objectCache()['@proteinjs/db/DefaultTransactionContextFactory'];
|
|
@@ -111,6 +148,7 @@ export const sourceRecordSyncTests = (
|
|
|
111
148
|
const db = getDbAsSystem();
|
|
112
149
|
await db.delete(machineTable, {});
|
|
113
150
|
await db.delete(defaultPolicyTable, {});
|
|
151
|
+
await db.delete(sourceRecordSyncTestTables.InheritedStamp, {});
|
|
114
152
|
RecordingMachineAccountWatcher.updates = [];
|
|
115
153
|
});
|
|
116
154
|
|
|
@@ -200,10 +238,13 @@ export const sourceRecordSyncTests = (
|
|
|
200
238
|
test('onSourceRemoved update: removed rows are flagged through Db.update (watchers fire), never deleted; re-declaring reverts', async () => {
|
|
201
239
|
const db = getDbAsSystem();
|
|
202
240
|
const human = await db.insert(machineTable, { email: 'human@test.local', displayName: 'A human' });
|
|
203
|
-
|
|
241
|
+
const keeper = machineDeclaration({ id: 'machine-keep', email: 'keeper@test.local' });
|
|
242
|
+
await boot([machineDeclaration({ id: 'machine-1', email: 'machine@test.local' }), keeper]);
|
|
204
243
|
|
|
244
|
+
// The source's next build drops machine-1 (while still declaring from this table —
|
|
245
|
+
// ownership is per source, and a source only reconciles what it still speaks for).
|
|
205
246
|
RecordingMachineAccountWatcher.updates = [];
|
|
206
|
-
await boot([]);
|
|
247
|
+
await boot([keeper]);
|
|
207
248
|
|
|
208
249
|
const removed = await db.get(machineTable, { id: 'machine-1' });
|
|
209
250
|
expect(removed).toBeDefined();
|
|
@@ -217,25 +258,408 @@ export const sourceRecordSyncTests = (
|
|
|
217
258
|
|
|
218
259
|
// Idempotent: an already-flagged row is not re-written on the next boot.
|
|
219
260
|
RecordingMachineAccountWatcher.updates = [];
|
|
220
|
-
await boot([]);
|
|
261
|
+
await boot([keeper]);
|
|
221
262
|
expect(RecordingMachineAccountWatcher.updates).toHaveLength(0);
|
|
222
263
|
|
|
223
264
|
// Removal is reversible in source: re-declaring reverts the patch via drift reversion.
|
|
224
|
-
await boot([machineDeclaration({ id: 'machine-1', email: 'machine@test.local' })]);
|
|
265
|
+
await boot([machineDeclaration({ id: 'machine-1', email: 'machine@test.local' }), keeper]);
|
|
225
266
|
expect((await db.get(machineTable, { id: 'machine-1' })).status).toBe('active');
|
|
226
267
|
});
|
|
227
268
|
|
|
228
269
|
test(`onSourceRemoved default: removed source rows are deleted; human rows survive`, async () => {
|
|
229
270
|
const db = getDbAsSystem();
|
|
230
271
|
const human = await db.insert(defaultPolicyTable, { email: 'human@test.local' });
|
|
231
|
-
|
|
272
|
+
const keeper = { table: defaultPolicyTable, record: { id: 'default-keep', email: 'keeper@test.local' } };
|
|
273
|
+
await boot([{ table: defaultPolicyTable, record: { id: 'default-1', email: 'temp@test.local' } }, keeper]);
|
|
232
274
|
expect(await db.get(defaultPolicyTable, { id: 'default-1' })).toBeDefined();
|
|
233
275
|
|
|
234
|
-
await boot([]);
|
|
276
|
+
await boot([keeper]);
|
|
235
277
|
expect(await db.get(defaultPolicyTable, { id: 'default-1' })).toBeUndefined();
|
|
278
|
+
expect(await db.get(defaultPolicyTable, { id: 'default-keep' })).toBeDefined();
|
|
236
279
|
expect(await db.get(defaultPolicyTable, { id: human.id })).toBeDefined();
|
|
237
280
|
});
|
|
238
281
|
|
|
282
|
+
/**
|
|
283
|
+
* Source-scoped pruning: multiple servers running DIFFERENT builds against ONE shared
|
|
284
|
+
* database (the thought_type dev topology — two dev servers on one Spanner database).
|
|
285
|
+
* Ownership grain is the declaring package: each declaration's source is the package that
|
|
286
|
+
* compiled it into the build. The removed-reconcile must stay authoritative WITHIN a source
|
|
287
|
+
* (a source still deletes declarations it itself removed) and be a strict no-op ACROSS
|
|
288
|
+
* sources (a boot never deletes rows owned by a package absent from its build).
|
|
289
|
+
*/
|
|
290
|
+
describe('source-scoped pruning (shared DB, per-package ownership)', () => {
|
|
291
|
+
const decl = (id: string, email: string) => ({ table: defaultPolicyTable, record: { id, email } });
|
|
292
|
+
|
|
293
|
+
test("one source's sync never deletes rows owned by a different source", async () => {
|
|
294
|
+
const db = getDbAsSystem();
|
|
295
|
+
|
|
296
|
+
// Build A (e.g. this checkout's flow types) declares {a-1, a-2}; build B (the other
|
|
297
|
+
// checkout's media types) declares a disjoint set {b-1}.
|
|
298
|
+
await bootAsSource('@test/pkg-a', [decl('a-1', 'a1@test.local'), decl('a-2', 'a2@test.local')]);
|
|
299
|
+
await bootAsSource('@test/pkg-b', [decl('b-1', 'b1@test.local')]);
|
|
300
|
+
|
|
301
|
+
// B's boot must NOT have pruned A's rows — this is the cross-server nuke.
|
|
302
|
+
expect(await db.get(defaultPolicyTable, { id: 'a-1' })).toBeDefined();
|
|
303
|
+
expect(await db.get(defaultPolicyTable, { id: 'a-2' })).toBeDefined();
|
|
304
|
+
expect(await db.get(defaultPolicyTable, { id: 'b-1' })).toBeDefined();
|
|
305
|
+
|
|
306
|
+
// Each row is stamped with its owning package — the scoping the prune runs on.
|
|
307
|
+
expect((await db.get(defaultPolicyTable, { id: 'a-1' })).sourcePackage).toBe('@test/pkg-a');
|
|
308
|
+
expect((await db.get(defaultPolicyTable, { id: 'b-1' })).sourcePackage).toBe('@test/pkg-b');
|
|
309
|
+
|
|
310
|
+
// And symmetrically: A re-booting must not prune B's row.
|
|
311
|
+
await bootAsSource('@test/pkg-a', [decl('a-1', 'a1@test.local'), decl('a-2', 'a2@test.local')]);
|
|
312
|
+
expect(await db.get(defaultPolicyTable, { id: 'b-1' })).toBeDefined();
|
|
313
|
+
expect(await db.get(defaultPolicyTable, { id: 'a-1' })).toBeDefined();
|
|
314
|
+
expect(await db.get(defaultPolicyTable, { id: 'a-2' })).toBeDefined();
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test('a source still prunes its OWN removed declarations without touching other sources', async () => {
|
|
318
|
+
const db = getDbAsSystem();
|
|
319
|
+
|
|
320
|
+
await bootAsSource('@test/pkg-a', [decl('a-1', 'a1@test.local'), decl('a-2', 'a2@test.local')]);
|
|
321
|
+
await bootAsSource('@test/pkg-b', [decl('b-1', 'b1@test.local')]);
|
|
322
|
+
|
|
323
|
+
// A's next build drops a-2: A's own removed declaration is pruned...
|
|
324
|
+
await bootAsSource('@test/pkg-a', [decl('a-1', 'a1@test.local')]);
|
|
325
|
+
expect(await db.get(defaultPolicyTable, { id: 'a-2' })).toBeUndefined();
|
|
326
|
+
// ...while A's still-declared row and B's row both survive.
|
|
327
|
+
expect(await db.get(defaultPolicyTable, { id: 'a-1' })).toBeDefined();
|
|
328
|
+
expect(await db.get(defaultPolicyTable, { id: 'b-1' })).toBeDefined();
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test('a build declaring nothing for a table prunes nothing', async () => {
|
|
332
|
+
const db = getDbAsSystem();
|
|
333
|
+
|
|
334
|
+
await bootAsSource('@test/pkg-a', [decl('a-1', 'a1@test.local')]);
|
|
335
|
+
// A build whose declaring packages are all absent (or declare nothing for this table)
|
|
336
|
+
// makes no claim about it — pre-fix this boot deleted EVERY source-loaded row.
|
|
337
|
+
await bootAsSource('@test/pkg-b', []);
|
|
338
|
+
expect(await db.get(defaultPolicyTable, { id: 'a-1' })).toBeDefined();
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test("onSourceRemoved update is source-scoped too: a foreign boot does not flag another source's rows", async () => {
|
|
342
|
+
const db = getDbAsSystem();
|
|
343
|
+
|
|
344
|
+
await bootAsSource('@test/pkg-a', [
|
|
345
|
+
{ table: machineTable, record: { id: 'm-a', email: 'ma@test.local', status: 'active' } },
|
|
346
|
+
]);
|
|
347
|
+
await bootAsSource('@test/pkg-b', [
|
|
348
|
+
{ table: machineTable, record: { id: 'm-b', email: 'mb@test.local', status: 'active' } },
|
|
349
|
+
]);
|
|
350
|
+
|
|
351
|
+
// B's boot must not have treated A's machine account as removed.
|
|
352
|
+
expect((await db.get(machineTable, { id: 'm-a' })).status).toBe('active');
|
|
353
|
+
expect((await db.get(machineTable, { id: 'm-b' })).status).toBe('active');
|
|
354
|
+
|
|
355
|
+
// A dropping its own declaration still deactivates it — and leaves B's untouched.
|
|
356
|
+
await bootAsSource('@test/pkg-a', [
|
|
357
|
+
{ table: machineTable, record: { id: 'm-a2', email: 'ma2@test.local', status: 'active' } },
|
|
358
|
+
]);
|
|
359
|
+
expect((await db.get(machineTable, { id: 'm-a' })).status).toBe('deactivated');
|
|
360
|
+
expect((await db.get(machineTable, { id: 'm-b' })).status).toBe('active');
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
test('a declaration migrating to a new source is re-owned, not pruned by its old source', async () => {
|
|
364
|
+
const db = getDbAsSystem();
|
|
365
|
+
|
|
366
|
+
// k-1 starts life declared by pkg-a, then moves to pkg-b (package rename/move).
|
|
367
|
+
await bootAsSource('@test/pkg-a', [decl('k-1', 'k1@test.local')]);
|
|
368
|
+
await bootAsSource('@test/pkg-b', [decl('k-1', 'k1@test.local')]);
|
|
369
|
+
// The later declaration re-owns the row in place (adoption keeps the id).
|
|
370
|
+
expect((await db.get(defaultPolicyTable, { id: 'k-1' })).sourcePackage).toBe('@test/pkg-b');
|
|
371
|
+
|
|
372
|
+
// pkg-a's next build no longer carries k-1 — but the row now belongs to pkg-b.
|
|
373
|
+
await bootAsSource('@test/pkg-a', [decl('a-x', 'ax@test.local')]);
|
|
374
|
+
expect(await db.get(defaultPolicyTable, { id: 'k-1' })).toBeDefined();
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test('legacy rows (source_package NULL) are never pruned; still-declared ones are adopted and stamped', async () => {
|
|
378
|
+
const db = getDbAsSystem();
|
|
379
|
+
// Rows written before source_package existed: loaded from source, no ownership stamp.
|
|
380
|
+
const legacyDeclared = await db.insert(defaultPolicyTable, {
|
|
381
|
+
email: 'l1@test.local',
|
|
382
|
+
isLoadedFromSource: true,
|
|
383
|
+
});
|
|
384
|
+
const legacyForeign = await db.insert(defaultPolicyTable, {
|
|
385
|
+
email: 'l2@test.local',
|
|
386
|
+
isLoadedFromSource: true,
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
await bootAsSource('@test/pkg-a', [decl(legacyDeclared.id, 'l1@test.local')]);
|
|
390
|
+
|
|
391
|
+
// The declared legacy row converges: adopted by its declaring package and stamped.
|
|
392
|
+
expect((await db.get(defaultPolicyTable, { id: legacyDeclared.id })).sourcePackage).toBe('@test/pkg-a');
|
|
393
|
+
// The undeclared legacy row is out of prune scope — it may belong to a build that has
|
|
394
|
+
// not rebooted onto the stamped world yet; its owner's next boot will claim it.
|
|
395
|
+
expect(await db.get(defaultPolicyTable, { id: legacyForeign.id })).toBeDefined();
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
test('a declaration moving between packages WITHIN one build is re-owned in place — never deleted and re-inserted', async () => {
|
|
399
|
+
const db = getDbAsSystem();
|
|
400
|
+
|
|
401
|
+
// k-1 and a-other both start under pkg-a.
|
|
402
|
+
await bootAsSource('@test/pkg-a', [decl('k-1', 'k1@test.local'), decl('a-other', 'aother@test.local')]);
|
|
403
|
+
const before = await db.get(defaultPolicyTable, { id: 'k-1' });
|
|
404
|
+
|
|
405
|
+
// The next build moves k-1 to pkg-b while pkg-a still declares a-other — ONE boot.
|
|
406
|
+
// pkg-a's prune scope must not treat k-1 as removed (it is declared elsewhere in the
|
|
407
|
+
// build): the row survives in place, then the stamp leg re-owns it.
|
|
408
|
+
await bootBuild([
|
|
409
|
+
{ source: '@test/pkg-a', ...decl('a-other', 'aother@test.local') },
|
|
410
|
+
{ source: '@test/pkg-b', ...decl('k-1', 'k1@test.local') },
|
|
411
|
+
]);
|
|
412
|
+
|
|
413
|
+
const after = await db.get(defaultPolicyTable, { id: 'k-1' });
|
|
414
|
+
expect(after).toBeDefined();
|
|
415
|
+
// Row continuity is the outcome: created unchanged means no delete+re-insert cycle
|
|
416
|
+
// (a re-insert restamps created), so watchers saw no delete and references held.
|
|
417
|
+
expect(after.created.valueOf()).toBe(before.created.valueOf());
|
|
418
|
+
expect(after.sourcePackage).toBe('@test/pkg-b');
|
|
419
|
+
expect(await db.get(defaultPolicyTable, { id: 'a-other' })).toBeDefined();
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
test('a within-build package move on an update-policy table never passes through the removed patch', async () => {
|
|
423
|
+
const db = getDbAsSystem();
|
|
424
|
+
|
|
425
|
+
await bootAsSource('@test/pkg-a', [
|
|
426
|
+
{ table: machineTable, record: { id: 'm-move', email: 'mmove@test.local', status: 'active' } },
|
|
427
|
+
{ table: machineTable, record: { id: 'm-stay', email: 'mstay@test.local', status: 'active' } },
|
|
428
|
+
]);
|
|
429
|
+
// Runtime-owned state that must survive the move untouched (the credential stand-in).
|
|
430
|
+
await db.update(machineTable, { id: 'm-move', runtimeNote: 'provisioned' });
|
|
431
|
+
|
|
432
|
+
RecordingMachineAccountWatcher.updates = [];
|
|
433
|
+
await bootBuild([
|
|
434
|
+
{
|
|
435
|
+
source: '@test/pkg-a',
|
|
436
|
+
table: machineTable,
|
|
437
|
+
record: { id: 'm-stay', email: 'mstay@test.local', status: 'active' },
|
|
438
|
+
},
|
|
439
|
+
{
|
|
440
|
+
source: '@test/pkg-b',
|
|
441
|
+
table: machineTable,
|
|
442
|
+
record: { id: 'm-move', email: 'mmove@test.local', status: 'active' },
|
|
443
|
+
},
|
|
444
|
+
]);
|
|
445
|
+
|
|
446
|
+
const moved = await db.get(machineTable, { id: 'm-move' });
|
|
447
|
+
expect(moved).toMatchObject({ status: 'active', runtimeNote: 'provisioned', sourcePackage: '@test/pkg-b' });
|
|
448
|
+
// The row never transited 'deactivated': no watcher observed the removed patch.
|
|
449
|
+
const deactivations = RecordingMachineAccountWatcher.updates.filter((u) => u.status === 'deactivated');
|
|
450
|
+
expect(deactivations).toHaveLength(0);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
test('cross-source boots leave a natural-key-adopted row in place — adopted id preserved', async () => {
|
|
454
|
+
const db = getDbAsSystem();
|
|
455
|
+
// Hand-made row with an environment-random id, adopted by pkg-b via natural key.
|
|
456
|
+
const handMade = await db.insert(machineTable, { email: 'bridge2@test.local', runtimeNote: 'cred' });
|
|
457
|
+
await bootAsSource('@test/pkg-b', [
|
|
458
|
+
{ table: machineTable, record: { id: 'declared-bridge2', email: 'bridge2@test.local', status: 'active' } },
|
|
459
|
+
]);
|
|
460
|
+
expect((await db.get(machineTable, { email: 'bridge2@test.local' })).id).toBe(handMade.id);
|
|
461
|
+
|
|
462
|
+
// A DIFFERENT source booting (with its own machine declaration) must not prune, flag,
|
|
463
|
+
// or disturb the adopted row — same id, same runtime state, still active.
|
|
464
|
+
RecordingMachineAccountWatcher.updates = [];
|
|
465
|
+
await bootAsSource('@test/pkg-a', [
|
|
466
|
+
{ table: machineTable, record: { id: 'm-a3', email: 'ma3@test.local', status: 'active' } },
|
|
467
|
+
]);
|
|
468
|
+
const adopted = await db.get(machineTable, { email: 'bridge2@test.local' });
|
|
469
|
+
expect(adopted).toMatchObject({ id: handMade.id, status: 'active', runtimeNote: 'cred' });
|
|
470
|
+
const foreignTouches = RecordingMachineAccountWatcher.updates.filter((u) => u.id === handMade.id);
|
|
471
|
+
expect(foreignTouches).toHaveLength(0);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
test('legacy rows (source_package NULL) are excluded from the update-policy leg too', async () => {
|
|
475
|
+
const db = getDbAsSystem();
|
|
476
|
+
// A pre-source_package row on the update-policy table: loaded from source, unstamped.
|
|
477
|
+
const legacy = await db.insert(machineTable, {
|
|
478
|
+
email: 'legacy@test.local',
|
|
479
|
+
status: 'active',
|
|
480
|
+
isLoadedFromSource: true,
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
RecordingMachineAccountWatcher.updates = [];
|
|
484
|
+
await bootAsSource('@test/pkg-a', [
|
|
485
|
+
{ table: machineTable, record: { id: 'm-a4', email: 'ma4@test.local', status: 'active' } },
|
|
486
|
+
]);
|
|
487
|
+
|
|
488
|
+
// NULL-stamped rows are out of BOTH reconcile legs — not deleted (covered above) and
|
|
489
|
+
// not flagged: the legacy machine account stays active, unobserved by watchers.
|
|
490
|
+
expect((await db.get(machineTable, { id: legacy.id })).status).toBe('active');
|
|
491
|
+
const legacyTouches = RecordingMachineAccountWatcher.updates.filter((u) => u.id === legacy.id);
|
|
492
|
+
expect(legacyTouches).toHaveLength(0);
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
test('a package in the build that no longer declares anything for a table prunes its rows there — dropping the last declaration is a removal', async () => {
|
|
496
|
+
const db = getDbAsSystem();
|
|
497
|
+
|
|
498
|
+
// pkg-a declares a-1 on the default-policy table and m-1 on the machine table; pkg-b owns b-1.
|
|
499
|
+
await bootBuild([
|
|
500
|
+
{ source: '@test/pkg-a', ...decl('a-1', 'a1@test.local') },
|
|
501
|
+
{
|
|
502
|
+
source: '@test/pkg-a',
|
|
503
|
+
table: machineTable,
|
|
504
|
+
record: { id: 'm-1', email: 'm1@test.local', status: 'active' },
|
|
505
|
+
},
|
|
506
|
+
{ source: '@test/pkg-b', ...decl('b-1', 'b1@test.local') },
|
|
507
|
+
]);
|
|
508
|
+
|
|
509
|
+
// pkg-a's next build keeps m-1 but drops its LAST default-policy declaration. The package is
|
|
510
|
+
// still in the build, so it is authoritative for its own rows on every table: a-1 is removed.
|
|
511
|
+
await bootBuild([
|
|
512
|
+
{
|
|
513
|
+
source: '@test/pkg-a',
|
|
514
|
+
table: machineTable,
|
|
515
|
+
record: { id: 'm-1', email: 'm1@test.local', status: 'active' },
|
|
516
|
+
},
|
|
517
|
+
]);
|
|
518
|
+
expect(await db.get(defaultPolicyTable, { id: 'a-1' })).toBeUndefined();
|
|
519
|
+
// pkg-b's row on that table is untouched, and pkg-a's still-declared machine row survives.
|
|
520
|
+
expect(await db.get(defaultPolicyTable, { id: 'b-1' })).toBeDefined();
|
|
521
|
+
expect((await db.get(machineTable, { id: 'm-1' })).status).toBe('active');
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
test('unowned legacy rows no declaration in the build claims are counted, never pruned', async () => {
|
|
525
|
+
const db = getDbAsSystem();
|
|
526
|
+
const unowned = await db.insert(defaultPolicyTable, { email: 'u@test.local', isLoadedFromSource: true });
|
|
527
|
+
|
|
528
|
+
// The build declares something else entirely: the legacy row is reported as unowned and left alone.
|
|
529
|
+
const summary = await bootAsSource('@test/pkg-a', [decl('a-1', 'a1@test.local')]);
|
|
530
|
+
expect(summary[defaultPolicyTable.name].unowned).toBe(1);
|
|
531
|
+
expect(await db.get(defaultPolicyTable, { id: unowned.id })).toBeDefined();
|
|
532
|
+
|
|
533
|
+
// Once a declaration claims it, it is owned (stamped) and no longer reported.
|
|
534
|
+
const claimed = await bootAsSource('@test/pkg-a', [
|
|
535
|
+
decl('a-1', 'a1@test.local'),
|
|
536
|
+
decl(unowned.id, 'u@test.local'),
|
|
537
|
+
]);
|
|
538
|
+
expect(claimed[defaultPolicyTable.name].unowned).toBe(0);
|
|
539
|
+
expect((await db.get(defaultPolicyTable, { id: unowned.id })).sourcePackage).toBe('@test/pkg-a');
|
|
540
|
+
});
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Version-scoped pruning WITHIN one package: two servers running different VERSIONS of the
|
|
545
|
+
* same package against one shared database (the actual brent-dev-2 incident — the main app
|
|
546
|
+
* at thought-common 3.29.0 and a feature worktree at 3.32.0, whose added thought types the
|
|
547
|
+
* older build deleted on every boot). Every row carries the declaring package's version;
|
|
548
|
+
* a boot never prunes (or flags) a row stamped by a NEWER version of the same package.
|
|
549
|
+
* Within the same or an older stamped version, the package stays authoritative:
|
|
550
|
+
* removals still land, and equal-version skew is last-writer-wins by design.
|
|
551
|
+
*/
|
|
552
|
+
describe('version-scoped pruning (same package, different builds)', () => {
|
|
553
|
+
const decl = (id: string, email: string) => ({ table: defaultPolicyTable, record: { id, email } });
|
|
554
|
+
|
|
555
|
+
test('an older build of a package never prunes rows a newer build of the same package declared (the incident)', async () => {
|
|
556
|
+
const db = getDbAsSystem();
|
|
557
|
+
|
|
558
|
+
// The newer build (e.g. thought-common 3.32.0) declares an added type k-2.
|
|
559
|
+
await bootAsSource('@test/pkg-a', [decl('k-1', 'k1@test.local'), decl('k-2', 'k2@test.local')], '3.32.0');
|
|
560
|
+
// The older build (3.29.0) boots without k-2 — pre-fix this deleted k-2 every boot.
|
|
561
|
+
await bootAsSource('@test/pkg-a', [decl('k-1', 'k1@test.local')], '3.29.0');
|
|
562
|
+
|
|
563
|
+
expect(await db.get(defaultPolicyTable, { id: 'k-2' })).toBeDefined();
|
|
564
|
+
expect(await db.get(defaultPolicyTable, { id: 'k-1' })).toBeDefined();
|
|
565
|
+
|
|
566
|
+
// The NEWER build remains authoritative for genuine removals: it drops k-2 for real.
|
|
567
|
+
await bootAsSource('@test/pkg-a', [decl('k-1', 'k1@test.local')], '3.32.0');
|
|
568
|
+
expect(await db.get(defaultPolicyTable, { id: 'k-2' })).toBeUndefined();
|
|
569
|
+
expect(await db.get(defaultPolicyTable, { id: 'k-1' })).toBeDefined();
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
test('an older build never writes over a row a newer build of the same package stamped — content and stamp survive', async () => {
|
|
573
|
+
const db = getDbAsSystem();
|
|
574
|
+
const k1 = (displayName: string) => ({
|
|
575
|
+
table: defaultPolicyTable,
|
|
576
|
+
record: { id: 'k-1', email: 'k1@test.local', displayName },
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
// The newer build (3.32.0) declares k-1 with its current definition.
|
|
580
|
+
await bootAsSource('@test/pkg-a', [k1('newer')], '3.32.0');
|
|
581
|
+
// The older build (3.29.0) still declares k-1, with its older definition. Pre-fix this boot
|
|
582
|
+
// rewrote the row (and downgraded its stamp) on every restart — content churn on a shared DB.
|
|
583
|
+
const summary = await bootAsSource('@test/pkg-a', [k1('older')], '3.29.0');
|
|
584
|
+
|
|
585
|
+
const row = await db.get(defaultPolicyTable, { id: 'k-1' });
|
|
586
|
+
expect(row.displayName).toBe('newer');
|
|
587
|
+
expect(row.sourcePackageVersion).toBe('3.32.0');
|
|
588
|
+
expect(summary[defaultPolicyTable.name].skippedNewer).toBe(1);
|
|
589
|
+
expect(summary[defaultPolicyTable.name].updates).toBe(0);
|
|
590
|
+
|
|
591
|
+
// The newer build remains authoritative over its own rows: its next definition lands.
|
|
592
|
+
await bootAsSource('@test/pkg-a', [k1('newest')], '3.32.0');
|
|
593
|
+
expect((await db.get(defaultPolicyTable, { id: 'k-1' })).displayName).toBe('newest');
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
test('version skew on an update-policy table: an older build does not flag newer rows', async () => {
|
|
597
|
+
const db = getDbAsSystem();
|
|
598
|
+
|
|
599
|
+
await bootAsSource(
|
|
600
|
+
'@test/pkg-a',
|
|
601
|
+
[
|
|
602
|
+
{ table: machineTable, record: { id: 'mv-1', email: 'mv1@test.local', status: 'active' } },
|
|
603
|
+
{ table: machineTable, record: { id: 'mv-2', email: 'mv2@test.local', status: 'active' } },
|
|
604
|
+
],
|
|
605
|
+
'2.0.0'
|
|
606
|
+
);
|
|
607
|
+
|
|
608
|
+
RecordingMachineAccountWatcher.updates = [];
|
|
609
|
+
await bootAsSource(
|
|
610
|
+
'@test/pkg-a',
|
|
611
|
+
[{ table: machineTable, record: { id: 'mv-1', email: 'mv1@test.local', status: 'active' } }],
|
|
612
|
+
'1.0.0'
|
|
613
|
+
);
|
|
614
|
+
|
|
615
|
+
// The newer build's machine account is not deactivated by the older build's boot.
|
|
616
|
+
expect((await db.get(machineTable, { id: 'mv-2' })).status).toBe('active');
|
|
617
|
+
const deactivations = RecordingMachineAccountWatcher.updates.filter((u) => u.status === 'deactivated');
|
|
618
|
+
expect(deactivations).toHaveLength(0);
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
test('equal versions with differing sets remain last-writer-wins — the deliberate residual', async () => {
|
|
622
|
+
const db = getDbAsSystem();
|
|
623
|
+
|
|
624
|
+
// Two builds at the SAME version (uncommitted local skew) cannot be ordered; the
|
|
625
|
+
// package stays authoritative within its own version: the later boot's set wins.
|
|
626
|
+
await bootAsSource('@test/pkg-a', [decl('e-1', 'e1@test.local'), decl('e-2', 'e2@test.local')], '1.0.0');
|
|
627
|
+
await bootAsSource('@test/pkg-a', [decl('e-1', 'e1@test.local')], '1.0.0');
|
|
628
|
+
expect(await db.get(defaultPolicyTable, { id: 'e-2' })).toBeUndefined();
|
|
629
|
+
expect(await db.get(defaultPolicyTable, { id: 'e-1' })).toBeDefined();
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
test('a build without a resolvable version never prunes version-stamped rows; unversioned stamps stay last-writer-wins', async () => {
|
|
633
|
+
const db = getDbAsSystem();
|
|
634
|
+
|
|
635
|
+
// Unversioned stamps carry no ordering: prunable by any build of the package —
|
|
636
|
+
// versionless-to-versionless keeps the pre-version last-writer-wins behavior.
|
|
637
|
+
await bootAsSource('@test/pkg-a', [decl('n-1', 'n1@test.local'), decl('n-2', 'n2@test.local')]);
|
|
638
|
+
await bootAsSource('@test/pkg-a', [decl('n-1', 'n1@test.local')]);
|
|
639
|
+
expect(await db.get(defaultPolicyTable, { id: 'n-2' })).toBeUndefined();
|
|
640
|
+
|
|
641
|
+
// Versioned rows survive an unorderable (versionless) build's boot: it cannot place
|
|
642
|
+
// itself before or after the stamp, so it must not delete what might be newer.
|
|
643
|
+
await bootAsSource('@test/pkg-a', [decl('u-1', 'u1@test.local'), decl('u-2', 'u2@test.local')], '1.0.0');
|
|
644
|
+
await bootAsSource('@test/pkg-a', [decl('u-1', 'u1@test.local')]);
|
|
645
|
+
expect(await db.get(defaultPolicyTable, { id: 'u-2' })).toBeDefined();
|
|
646
|
+
|
|
647
|
+
// A versioned build prunes older-or-equal stamps as usual (1.0.0 <= 1.0.1).
|
|
648
|
+
await bootAsSource('@test/pkg-a', [decl('u-1', 'u1@test.local')], '1.0.1');
|
|
649
|
+
expect(await db.get(defaultPolicyTable, { id: 'u-2' })).toBeUndefined();
|
|
650
|
+
expect(await db.get(defaultPolicyTable, { id: 'u-1' })).toBeDefined();
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
test("the version resolver reads the declaring package's real package.json", async () => {
|
|
654
|
+
const internals = new SourceRecordLoader() as unknown as LoaderInternals;
|
|
655
|
+
// A real dependency of this build resolves to its actual version (entry resolution +
|
|
656
|
+
// walk-up works for exports-mapped packages, where `<pkg>/package.json` is not requireable).
|
|
657
|
+
expect(internals.resolveSourceVersion('@proteinjs/db-query')).toMatch(/^\d+\.\d+\.\d+/);
|
|
658
|
+
// Resolution failure degrades to versionless, never throws.
|
|
659
|
+
expect(internals.resolveSourceVersion('@test/no-such-package')).toBeUndefined();
|
|
660
|
+
});
|
|
661
|
+
});
|
|
662
|
+
|
|
239
663
|
test('a natural key without a unique guarantee fails boot loudly', async () => {
|
|
240
664
|
// Local (unregistered, never created) on purpose: a registered table with an invalid
|
|
241
665
|
// naturalKey would poison every loader run in the process — validation fires before any
|
|
@@ -284,5 +708,32 @@ export const sourceRecordSyncTests = (
|
|
|
284
708
|
await dropTable(generationOne);
|
|
285
709
|
}
|
|
286
710
|
});
|
|
711
|
+
|
|
712
|
+
test('a new source-record table inherits the ownership stamp columns from SourceRecord — never declared per table', async () => {
|
|
713
|
+
// The fixture ({@link InheritedStampTable}) declares ONLY its own column. The ownership
|
|
714
|
+
// stamps (source_package, source_package_version) must arrive from withSourceRecordColumns —
|
|
715
|
+
// the single owner of the SourceRecord column set. If tables had to hand-declare the
|
|
716
|
+
// stamps, any table whose author forgot them would silently lose the shared-DB prune
|
|
717
|
+
// scoping this suite exists to guarantee.
|
|
718
|
+
const table = sourceRecordSyncTestTables.InheritedStamp;
|
|
719
|
+
|
|
720
|
+
// Type layer: the minted table carries the stamp columns, at their physical names,
|
|
721
|
+
// without declaring them.
|
|
722
|
+
expect(table.columns.sourcePackage?.name).toBe('source_package');
|
|
723
|
+
expect(table.columns.sourcePackageVersion?.name).toBe('source_package_version');
|
|
724
|
+
expect(isSourceRecordTable(table)).toBe(true);
|
|
725
|
+
|
|
726
|
+
// Schema layer: the harness minted the physical table through TableManager, which derives
|
|
727
|
+
// DDL from the type — so the stamp columns must have been emitted without the fixture
|
|
728
|
+
// declaring them. One boot of the sync stamps a row through them; the write itself fails
|
|
729
|
+
// if the physical columns were not emitted.
|
|
730
|
+
await bootAsSource('@test/pkg-a', [{ table, record: { id: 's-1', email: 's1@test.local' } }], '1.0.0');
|
|
731
|
+
const row = await getDbAsSystem().get(table, { id: 's-1' });
|
|
732
|
+
expect(row).toMatchObject({
|
|
733
|
+
sourcePackage: '@test/pkg-a',
|
|
734
|
+
sourcePackageVersion: '1.0.0',
|
|
735
|
+
isLoadedFromSource: true,
|
|
736
|
+
});
|
|
737
|
+
});
|
|
287
738
|
};
|
|
288
739
|
};
|
|
@@ -78,7 +78,27 @@ export class DupePreflightUniqueEmailTable extends Table<DupePreflightRecord> {
|
|
|
78
78
|
});
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
export interface InheritedStampRecord extends SourceRecord {
|
|
82
|
+
email: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The one-owner guard fixture: declares ONLY its own column. The ownership stamps
|
|
87
|
+
* (source_package, source_package_version) must arrive from withSourceRecordColumns — the single
|
|
88
|
+
* owner of the SourceRecord column set — never by per-table declaration. Its physical table is
|
|
89
|
+
* minted by the harness through the normal schema-sync path (TableManager deriving DDL from the
|
|
90
|
+
* type), so the inheritance guard test proves a NEW table gets the stamp columns without
|
|
91
|
+
* declaring them.
|
|
92
|
+
*/
|
|
93
|
+
export class InheritedStampTable extends Table<InheritedStampRecord> {
|
|
94
|
+
name = 'db_test_sync_inherited_stamp';
|
|
95
|
+
columns: Table<InheritedStampRecord>['columns'] = withSourceRecordColumns<InheritedStampRecord>({
|
|
96
|
+
email: new StringColumn('email'),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
81
100
|
export const sourceRecordSyncTestTables = {
|
|
82
101
|
SyncMachineAccount: new SyncMachineAccountTable() as Table<SyncMachineAccount>,
|
|
83
102
|
SyncDefaultPolicy: new SyncDefaultPolicyTable() as Table<SyncDefaultPolicyRecord>,
|
|
103
|
+
InheritedStamp: new InheritedStampTable() as Table<InheritedStampRecord>,
|
|
84
104
|
};
|