@proteinjs/db 1.47.2 → 1.47.3
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 +11 -0
- package/dist/generated/test/index.js +1 -1
- package/dist/src/Table.d.ts +12 -5
- package/dist/src/Table.d.ts.map +1 -1
- package/dist/src/source/SourceRecord.d.ts +3 -1
- package/dist/src/source/SourceRecord.d.ts.map +1 -1
- package/dist/src/source/SourceRecordLoader.d.ts +12 -0
- package/dist/src/source/SourceRecordLoader.d.ts.map +1 -1
- package/dist/src/source/SourceRecordLoader.js +50 -25
- package/dist/src/source/SourceRecordLoader.js.map +1 -1
- package/dist/test/reusable/SourceRecordSyncTests.d.ts +5 -4
- package/dist/test/reusable/SourceRecordSyncTests.d.ts.map +1 -1
- package/dist/test/reusable/SourceRecordSyncTests.js +233 -29
- package/dist/test/reusable/SourceRecordSyncTests.js.map +1 -1
- package/dist/test/util/tables/sourceRecordSyncTestTables.d.ts +2 -2
- package/dist/test/util/tables/sourceRecordSyncTestTables.d.ts.map +1 -1
- package/dist/test/util/tables/sourceRecordSyncTestTables.js +1 -1
- package/dist/test/util/tables/sourceRecordSyncTestTables.js.map +1 -1
- package/generated/test/index.ts +1 -1
- package/package.json +2 -2
- package/src/Table.ts +12 -5
- package/src/source/SourceRecord.ts +3 -1
- package/src/source/SourceRecordLoader.ts +35 -4
- package/test/reusable/SourceRecordSyncTests.ts +164 -25
- package/test/util/tables/sourceRecordSyncTestTables.ts +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@proteinjs/db",
|
|
3
|
-
"version": "1.47.
|
|
3
|
+
"version": "1.47.3",
|
|
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": "08154afae17a9a19c9e48520a3dd67ead7b997c6"
|
|
70
70
|
}
|
package/src/Table.ts
CHANGED
|
@@ -452,11 +452,18 @@ export type SourceRecordOptions<T = any> = {
|
|
|
452
452
|
*/
|
|
453
453
|
onSourceRemoved?: 'delete' | 'keep' | { update: Partial<T> };
|
|
454
454
|
/**
|
|
455
|
-
* When set, the sync keys records on this column instead of `id` — matching
|
|
456
|
-
*
|
|
457
|
-
*
|
|
458
|
-
* be referenced from other tables)
|
|
459
|
-
*
|
|
455
|
+
* When set, the sync keys records on this column instead of `id` — matching and the removed
|
|
456
|
+
* reconcile both use it. A row the sync already owns (`is_loaded_from_source = true`) matched
|
|
457
|
+
* by natural key keeps its id (the declared id is used only for fresh inserts — an
|
|
458
|
+
* environment's own ids may be referenced from other tables) and has its declared fields
|
|
459
|
+
* reverted to source. Drift comparison excludes `id`, so the id difference converges.
|
|
460
|
+
*
|
|
461
|
+
* A row the sync does NOT own that holds the key is never taken over: a natural key is an
|
|
462
|
+
* identity the world shares (an email a person registered, say), unlike a declared id, which
|
|
463
|
+
* only the declaration mints. The declaration is REFUSED for that boot — the row left exactly
|
|
464
|
+
* as it is, the boot continuing, one warning naming the declaration and the key, counted
|
|
465
|
+
* `refused` in the load summary — and lands as a fresh insert on the first boot after that row
|
|
466
|
+
* is gone.
|
|
460
467
|
*
|
|
461
468
|
* Preconditions, validated at boot by the loader (loud failures):
|
|
462
469
|
* - the column is declared unique (`ColumnOptions.unique` or a single-column unique index in
|
|
@@ -123,7 +123,9 @@ type OptionalProperties<T> = Pick<
|
|
|
123
123
|
/**
|
|
124
124
|
* Use this to load a record from source into the db.
|
|
125
125
|
*
|
|
126
|
-
* On Db.init, the record will be inserted if it doesn't exist, and updated if it does exist to mirror what is in source
|
|
126
|
+
* On Db.init, the record will be inserted if it doesn't exist, and updated if it does exist to mirror what is in source —
|
|
127
|
+
* except that a row the sync does not own is never taken over through the table's natural key: the declaration is
|
|
128
|
+
* refused while such a row holds the key (see `SourceRecordOptions.naturalKey`).
|
|
127
129
|
*
|
|
128
130
|
* If the SourceRecordLoader is deleted from source, the record will be deleted from the db on server startup (per the
|
|
129
131
|
* table's `onSourceRemoved` policy) — by the next boot of a build that still carries the declaring package at its
|
|
@@ -25,6 +25,12 @@ export type SourceRecordTableLoadSummary = {
|
|
|
25
25
|
updates: number;
|
|
26
26
|
unchanged: number;
|
|
27
27
|
adopted: number;
|
|
28
|
+
/**
|
|
29
|
+
* Declarations whose natural key is held by a row the sync does not own (written at runtime —
|
|
30
|
+
* a person's own row, say): refused for this boot, that row left exactly as it is (see
|
|
31
|
+
* {@link SourceRecordOptions.naturalKey}).
|
|
32
|
+
*/
|
|
33
|
+
refused: number;
|
|
28
34
|
deletes: number;
|
|
29
35
|
removedUpdates: number;
|
|
30
36
|
/** Rows stamped by a newer version of their declaring package — left exactly as they are. */
|
|
@@ -59,6 +65,12 @@ export class SourceRecordLoader {
|
|
|
59
65
|
* tables it no longer declares anything for: dropping the last declaration is a removal.
|
|
60
66
|
* - Rows with no owner stamp (written before `source_package` existed) are claimed by the
|
|
61
67
|
* declaration that still matches them; the rest are reported as unowned and left alone.
|
|
68
|
+
* - A declaration never takes over a row the sync does not own through a natural key: a
|
|
69
|
+
* natural key (an email, say) is an identity the world shares, so a runtime row holding it
|
|
70
|
+
* may be anyone's — that declaration is REFUSED for the boot (the row untouched, the boot
|
|
71
|
+
* continuing, one warning naming the declaration, counted `refused`). A declared id, minted
|
|
72
|
+
* by the declaration itself, still claims a runtime row holding it (see
|
|
73
|
+
* {@link SourceRecordOptions.naturalKey}).
|
|
62
74
|
* - The version stamp records the version that last WROTE the row (its insert or its last
|
|
63
75
|
* redefinition). A newer version whose declaration is unchanged writes nothing — it neither
|
|
64
76
|
* re-stamps the version nor bumps `updated` — so a release that touches no declaration
|
|
@@ -100,6 +112,7 @@ export class SourceRecordLoader {
|
|
|
100
112
|
let updateCount = 0;
|
|
101
113
|
let unchangedCount = 0;
|
|
102
114
|
let adoptedCount = 0;
|
|
115
|
+
let refusedCount = 0;
|
|
103
116
|
let skippedNewer = removed.skippedNewer;
|
|
104
117
|
for (const { source, qualifiedName, name, record } of records) {
|
|
105
118
|
let sourceRecord = record;
|
|
@@ -135,6 +148,21 @@ export class SourceRecordLoader {
|
|
|
135
148
|
}
|
|
136
149
|
|
|
137
150
|
if (existingRecord) {
|
|
151
|
+
if (existingRecord.isLoadedFromSource !== true && keyProperty !== 'id') {
|
|
152
|
+
// A natural key held by a row the sync does not own: never taken over (see load's
|
|
153
|
+
// ownership model). The row stays exactly as it is and the boot goes on.
|
|
154
|
+
refusedCount += 1;
|
|
155
|
+
this.logger.warn({
|
|
156
|
+
message: `(${table.name}) Refused declaration '${name}': a row the sync does not own holds ${keyProperty} '${(sourceRecord as any)[keyProperty]}' — the row is left untouched and the declaration does not load while it stands`,
|
|
157
|
+
obj: {
|
|
158
|
+
declaration: qualifiedName,
|
|
159
|
+
[keyProperty]: (sourceRecord as any)[keyProperty],
|
|
160
|
+
id: existingRecord.id,
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
|
|
138
166
|
if (
|
|
139
167
|
existingRecord.sourcePackage === source &&
|
|
140
168
|
this.isNewerStamp(existingRecord.sourcePackageVersion, sourceVersion)
|
|
@@ -147,14 +175,16 @@ export class SourceRecordLoader {
|
|
|
147
175
|
}
|
|
148
176
|
|
|
149
177
|
if (existingRecord.id !== sourceRecord.id) {
|
|
150
|
-
//
|
|
151
|
-
//
|
|
178
|
+
// A natural-key match on a row the sync owns under an environment's own id (one an
|
|
179
|
+
// earlier build adopted): the row keeps its id — other tables may reference it. The
|
|
180
|
+
// declared id is only ever used for fresh inserts.
|
|
152
181
|
sourceRecord = { ...sourceRecord, id: existingRecord.id };
|
|
153
182
|
}
|
|
154
183
|
|
|
155
184
|
if (existingRecord.isLoadedFromSource !== true) {
|
|
156
|
-
// A
|
|
157
|
-
//
|
|
185
|
+
// A runtime-created row holding the declaration's OWN id (natural-key tables never
|
|
186
|
+
// reach here — refused above) is being taken over by that declaration — deliberate,
|
|
187
|
+
// but loud: a declaration asserts ownership of the row's identity.
|
|
158
188
|
adoptedCount += 1;
|
|
159
189
|
this.logger.info({
|
|
160
190
|
message: `(${table.name}) Adopting existing record into source ownership`,
|
|
@@ -196,6 +226,7 @@ export class SourceRecordLoader {
|
|
|
196
226
|
updates: updateCount,
|
|
197
227
|
unchanged: unchangedCount,
|
|
198
228
|
adopted: adoptedCount,
|
|
229
|
+
refused: refusedCount,
|
|
199
230
|
deletes: removed.deleteCount,
|
|
200
231
|
removedUpdates: removed.removedUpdateCount,
|
|
201
232
|
skippedNewer,
|
|
@@ -52,10 +52,11 @@ class RecordingMachineAccountWatcher implements TableWatcher<SyncMachineAccount>
|
|
|
52
52
|
|
|
53
53
|
/**
|
|
54
54
|
* Emulator-backed outcome tests for the source-record sync's mixed-table semantics:
|
|
55
|
-
* declare-only ownership (human rows structurally untouchable), natural-key
|
|
56
|
-
*
|
|
57
|
-
* through `Db.update` so
|
|
58
|
-
* validation, and the
|
|
55
|
+
* declare-only ownership (human rows structurally untouchable), natural-key sync (an owned row
|
|
56
|
+
* keeps its id and runtime fields; a row the sync does not own refuses the declaration and is
|
|
57
|
+
* never taken over), `onSourceRemoved` policies (flag-not-delete through `Db.update` so
|
|
58
|
+
* watchers fire; default delete unchanged), boot-time natural-key validation, and the
|
|
59
|
+
* unique-index duplicate preflight.
|
|
59
60
|
*/
|
|
60
61
|
export const sourceRecordSyncTests = (
|
|
61
62
|
driver: DbDriver,
|
|
@@ -201,39 +202,171 @@ export const sourceRecordSyncTests = (
|
|
|
201
202
|
expect(afterIdleBoot.updated.valueOf()).toBe(stampBefore);
|
|
202
203
|
});
|
|
203
204
|
|
|
204
|
-
test('natural
|
|
205
|
+
test('a natural key held by a row the sync does not own refuses the declaration: the row untouched, the boot continues, the refusal counted and named', async () => {
|
|
205
206
|
const db = getDbAsSystem();
|
|
206
|
-
//
|
|
207
|
-
|
|
207
|
+
// A row written at runtime that happens to hold the declared natural key — a person's own
|
|
208
|
+
// row (a signup under the address before the declaration shipped): their fields, their
|
|
209
|
+
// credential (the runtime note stands in for the password hash), never source-owned.
|
|
210
|
+
const persons = await db.insert(machineTable, {
|
|
208
211
|
email: 'bridge@test.local',
|
|
209
|
-
displayName: '
|
|
212
|
+
displayName: 'A person',
|
|
213
|
+
runtimeNote: 'their-password-hash',
|
|
214
|
+
});
|
|
215
|
+
const bridge = machineDeclaration({ id: 'declared-id', email: 'bridge@test.local', displayName: 'Ops bridge' });
|
|
216
|
+
const sibling = machineDeclaration({ id: 'sibling-id', email: 'sibling@test.local', displayName: 'Sibling' });
|
|
217
|
+
|
|
218
|
+
const logged: string[] = [];
|
|
219
|
+
const consoleSpies = (['log', 'info', 'warn', 'error'] as const).map((level) =>
|
|
220
|
+
jest.spyOn(console, level).mockImplementation((...parts: unknown[]) => {
|
|
221
|
+
logged.push(parts.map((part) => (typeof part === 'string' ? part : JSON.stringify(part))).join(' '));
|
|
222
|
+
})
|
|
223
|
+
);
|
|
224
|
+
const summary = await boot([bridge, sibling]).finally(() => consoleSpies.forEach((spy) => spy.mockRestore()));
|
|
225
|
+
|
|
226
|
+
// The boot continued: the sibling declaration landed; the refused one is counted, not adopted.
|
|
227
|
+
expect(summary[machineTable.name]).toMatchObject({ inserts: 1, adopted: 0, refused: 1 });
|
|
228
|
+
expect(await db.get(machineTable, { id: 'sibling-id' })).toMatchObject({ isLoadedFromSource: true });
|
|
229
|
+
// The person's row is exactly as it was: no declared field, no ownership stamp, no write.
|
|
230
|
+
const after = await db.get(machineTable, { id: persons.id });
|
|
231
|
+
expect(after).toMatchObject({
|
|
232
|
+
email: 'bridge@test.local',
|
|
233
|
+
displayName: 'A person',
|
|
234
|
+
runtimeNote: 'their-password-hash',
|
|
235
|
+
});
|
|
236
|
+
expect(after.isLoadedFromSource).toBeFalsy();
|
|
237
|
+
expect(after.sourcePackage).toBeFalsy();
|
|
238
|
+
expect(after.status).toBeFalsy();
|
|
239
|
+
expect(after.updated.valueOf()).toBe(persons.updated.valueOf());
|
|
240
|
+
expect(RecordingMachineAccountWatcher.updates.filter((update) => update.id === persons.id)).toHaveLength(0);
|
|
241
|
+
// Nothing was inserted under the declared id, and nothing is registered as a source record.
|
|
242
|
+
expect(await db.get(machineTable, { id: 'declared-id' })).toBeUndefined();
|
|
243
|
+
expect(new SourceRecordRepo().getSourceRecord(machineTable.name, persons.id)).toBeUndefined();
|
|
244
|
+
expect(new SourceRecordRepo().getSourceRecord(machineTable.name, 'declared-id')).toBeUndefined();
|
|
245
|
+
// One plain line names the declaration and the reason.
|
|
246
|
+
const refusals = logged.filter((line) => line.includes('Refused declaration'));
|
|
247
|
+
expect(refusals).toHaveLength(1);
|
|
248
|
+
expect(refusals[0]).toContain('SeededLoader0');
|
|
249
|
+
expect(refusals[0]).toContain('bridge@test.local');
|
|
250
|
+
expect(refusals[0]).toContain('does not own');
|
|
251
|
+
|
|
252
|
+
// Idempotent: the next boot refuses again and still writes nothing to the row.
|
|
253
|
+
const again = await boot([bridge, sibling]);
|
|
254
|
+
expect(again[machineTable.name]).toMatchObject({ inserts: 0, refused: 1 });
|
|
255
|
+
expect((await db.get(machineTable, { id: persons.id })).updated.valueOf()).toBe(persons.updated.valueOf());
|
|
256
|
+
|
|
257
|
+
// Once the row is gone, the declaration lands as a fresh insert under its declared id.
|
|
258
|
+
await db.delete(machineTable, { id: persons.id });
|
|
259
|
+
const landed = await boot([bridge, sibling]);
|
|
260
|
+
expect(landed[machineTable.name]).toMatchObject({ inserts: 1, refused: 0 });
|
|
261
|
+
const fresh = await db.get(machineTable, { email: 'bridge@test.local' });
|
|
262
|
+
expect(fresh).toMatchObject({ id: 'declared-id', displayName: 'Ops bridge', isLoadedFromSource: true });
|
|
263
|
+
expect(fresh.runtimeNote).toBeFalsy();
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test('a row the sync already owns keeps syncing by its natural key under an environment-random id — id and runtime fields kept, converged boots write nothing', async () => {
|
|
267
|
+
const db = getDbAsSystem();
|
|
268
|
+
// The shape of an account a deployed environment adopted in an earlier build: source-owned,
|
|
269
|
+
// stamped by its package, holding the id the environment gave it (not the declared one),
|
|
270
|
+
// with a provisioned credential and a declared field that has drifted.
|
|
271
|
+
const owned = await db.insert(machineTable, {
|
|
272
|
+
email: 'bridge@test.local',
|
|
273
|
+
displayName: 'Drifted',
|
|
210
274
|
runtimeNote: 'the-password-hash',
|
|
275
|
+
status: 'active',
|
|
276
|
+
isLoadedFromSource: true,
|
|
277
|
+
sourcePackage: DEFAULT_TEST_SOURCE,
|
|
278
|
+
});
|
|
279
|
+
const bridge = machineDeclaration({
|
|
280
|
+
id: 'declared-owned-id',
|
|
281
|
+
email: 'bridge@test.local',
|
|
282
|
+
displayName: 'Ops bridge',
|
|
211
283
|
});
|
|
212
284
|
|
|
213
|
-
await boot([
|
|
285
|
+
const summary = await boot([bridge]);
|
|
214
286
|
|
|
287
|
+
expect(summary[machineTable.name]).toMatchObject({ updates: 1, refused: 0, inserts: 0 });
|
|
215
288
|
const rows = await machineRows({ email: 'bridge@test.local' });
|
|
216
289
|
expect(rows).toHaveLength(1);
|
|
217
|
-
// Adopted: the existing id survives (scoped rows reference it); declared fields reverted;
|
|
218
|
-
// runtime fields (the credential) preserved; the row is now source-owned.
|
|
219
290
|
expect(rows[0]).toMatchObject({
|
|
220
|
-
id:
|
|
291
|
+
id: owned.id,
|
|
221
292
|
displayName: 'Ops bridge',
|
|
222
|
-
status: 'active',
|
|
223
293
|
runtimeNote: 'the-password-hash',
|
|
224
294
|
isLoadedFromSource: true,
|
|
225
295
|
});
|
|
226
|
-
expect(await db.get(machineTable, { id: 'declared-id' })).toBeUndefined();
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
expect(new SourceRecordRepo().getSourceRecord(machineTable.name,
|
|
230
|
-
expect(new SourceRecordRepo().getSourceRecord(machineTable.name, 'declared-id')).toBeUndefined();
|
|
296
|
+
expect(await db.get(machineTable, { id: 'declared-owned-id' })).toBeUndefined();
|
|
297
|
+
// Registered under the row's own id, not the declared one.
|
|
298
|
+
expect(new SourceRecordRepo().getSourceRecord(machineTable.name, owned.id)).toBeDefined();
|
|
299
|
+
expect(new SourceRecordRepo().getSourceRecord(machineTable.name, 'declared-owned-id')).toBeUndefined();
|
|
231
300
|
|
|
232
|
-
//
|
|
301
|
+
// The id difference is not perpetual drift.
|
|
233
302
|
const stampBefore = rows[0].updated.valueOf();
|
|
234
|
-
await boot([
|
|
235
|
-
|
|
236
|
-
|
|
303
|
+
await boot([bridge]);
|
|
304
|
+
expect((await db.get(machineTable, { id: owned.id })).updated.valueOf()).toBe(stampBefore);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test('an owned row that lost its ownership marker outside the sync is refused like any unowned row — never re-stamped, the row and its runtime fields standing; the marker set back is the recovery', async () => {
|
|
308
|
+
const db = getDbAsSystem();
|
|
309
|
+
const bridge = machineDeclaration({
|
|
310
|
+
id: 'declared-lost-id',
|
|
311
|
+
email: 'bridge@test.local',
|
|
312
|
+
displayName: 'Ops bridge',
|
|
313
|
+
});
|
|
314
|
+
// Boot 1: the declaration lands as a fresh insert — owned and stamped; a credential is minted at runtime.
|
|
315
|
+
expect((await boot([bridge]))[machineTable.name]).toMatchObject({ inserts: 1, refused: 0 });
|
|
316
|
+
const owned = await db.get(machineTable, { email: 'bridge@test.local' });
|
|
317
|
+
expect(owned).toMatchObject({
|
|
318
|
+
id: 'declared-lost-id',
|
|
319
|
+
isLoadedFromSource: true,
|
|
320
|
+
sourcePackage: DEFAULT_TEST_SOURCE,
|
|
321
|
+
});
|
|
322
|
+
await db.update(machineTable, { id: owned.id, runtimeNote: 'the-minted-hash' });
|
|
323
|
+
|
|
324
|
+
// The marker is cleared outside the sync (a migration, a hand edit — the service door protects it).
|
|
325
|
+
await db.update(machineTable, { id: owned.id, isLoadedFromSource: false });
|
|
326
|
+
|
|
327
|
+
// Boot 2: the sync no longer owns the row, so the declaration is refused and the row is left as
|
|
328
|
+
// it is — its declared fields, its credential and its status stand; nothing re-stamps it.
|
|
329
|
+
const refusedBoot = await boot([bridge]);
|
|
330
|
+
expect(refusedBoot[machineTable.name]).toMatchObject({ inserts: 0, updates: 0, adopted: 0, refused: 1 });
|
|
331
|
+
const after = await db.get(machineTable, { id: owned.id });
|
|
332
|
+
expect(after).toMatchObject({
|
|
333
|
+
email: 'bridge@test.local',
|
|
334
|
+
displayName: 'Ops bridge',
|
|
335
|
+
status: 'active',
|
|
336
|
+
runtimeNote: 'the-minted-hash',
|
|
337
|
+
isLoadedFromSource: false,
|
|
338
|
+
});
|
|
339
|
+
expect(await db.get(machineTable, { id: 'declared-lost-id-other' })).toBeUndefined();
|
|
340
|
+
|
|
341
|
+
// The recovery is the marker set back: the next boot owns the row again and writes nothing else.
|
|
342
|
+
await db.update(machineTable, { id: owned.id, isLoadedFromSource: true });
|
|
343
|
+
expect((await boot([bridge]))[machineTable.name]).toMatchObject({ inserts: 0, refused: 0, unchanged: 1 });
|
|
344
|
+
expect(await db.get(machineTable, { id: owned.id })).toMatchObject({
|
|
345
|
+
runtimeNote: 'the-minted-hash',
|
|
346
|
+
isLoadedFromSource: true,
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test('an id-keyed declaration still claims a runtime row holding its own declared id — the id is minted by the declaration, never shared', async () => {
|
|
351
|
+
const db = getDbAsSystem();
|
|
352
|
+
// A row written before the table was source-loaded, under the id the declaration carries
|
|
353
|
+
// (the insert type omits `id`; the loader's own inserts pass it the same way).
|
|
354
|
+
await db.insert(defaultPolicyTable, {
|
|
355
|
+
id: 'legacy-id',
|
|
356
|
+
email: 'legacy@test.local',
|
|
357
|
+
displayName: 'Old name',
|
|
358
|
+
} as unknown as { email: string });
|
|
359
|
+
|
|
360
|
+
const summary = await boot([
|
|
361
|
+
{ table: defaultPolicyTable, record: { id: 'legacy-id', email: 'legacy@test.local', displayName: 'Declared' } },
|
|
362
|
+
]);
|
|
363
|
+
|
|
364
|
+
expect(summary[defaultPolicyTable.name]).toMatchObject({ adopted: 1, refused: 0 });
|
|
365
|
+
expect(await db.get(defaultPolicyTable, { id: 'legacy-id' })).toMatchObject({
|
|
366
|
+
displayName: 'Declared',
|
|
367
|
+
isLoadedFromSource: true,
|
|
368
|
+
sourcePackage: DEFAULT_TEST_SOURCE,
|
|
369
|
+
});
|
|
237
370
|
});
|
|
238
371
|
|
|
239
372
|
test('onSourceRemoved update: removed rows are flagged through Db.update (watchers fire), never deleted; re-declaring reverts', async () => {
|
|
@@ -520,10 +653,16 @@ export const sourceRecordSyncTests = (
|
|
|
520
653
|
expect(deactivations).toHaveLength(0);
|
|
521
654
|
});
|
|
522
655
|
|
|
523
|
-
test('cross-source boots leave a natural-key-
|
|
656
|
+
test('cross-source boots leave a natural-key-synced row in place — its environment id preserved', async () => {
|
|
524
657
|
const db = getDbAsSystem();
|
|
525
|
-
//
|
|
526
|
-
|
|
658
|
+
// A row pkg-b owns under an environment-random id (adopted by an earlier build), synced
|
|
659
|
+
// by natural key.
|
|
660
|
+
const handMade = await db.insert(machineTable, {
|
|
661
|
+
email: 'bridge2@test.local',
|
|
662
|
+
runtimeNote: 'cred',
|
|
663
|
+
isLoadedFromSource: true,
|
|
664
|
+
sourcePackage: '@test/pkg-b',
|
|
665
|
+
});
|
|
527
666
|
await bootAsSource('@test/pkg-b', [
|
|
528
667
|
{ table: machineTable, record: { id: 'declared-bridge2', email: 'bridge2@test.local', status: 'active' } },
|
|
529
668
|
]);
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* Fixtures for the source-record sync suites ({@link sourceRecordSyncTests}): a machine-account-
|
|
12
|
-
* shaped mixed table (natural-key
|
|
12
|
+
* shaped mixed table (natural-key sync + flag-on-removed), a default-policy table (removed =
|
|
13
13
|
* deleted), a misdeclared natural-key table (non-unique column), and the two-generation table
|
|
14
14
|
* pair driving the unique-index duplicate preflight.
|
|
15
15
|
*
|
|
@@ -27,7 +27,7 @@ export interface SyncMachineAccount extends SourceRecord {
|
|
|
27
27
|
runtimeNote?: string | null;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
/** The machine-account shape:
|
|
30
|
+
/** The machine-account shape: synced by email, removed rows flagged not deleted. */
|
|
31
31
|
export class SyncMachineAccountTable extends Table<SyncMachineAccount> {
|
|
32
32
|
name = 'db_test_sync_machine_account';
|
|
33
33
|
columns: Table<SyncMachineAccount>['columns'] = withSourceRecordColumns<SyncMachineAccount>({
|