@proteinjs/db 1.43.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/generated/index.js +1 -1
  3. package/dist/generated/index.js.map +1 -1
  4. package/dist/generated/test/index.d.ts.map +1 -1
  5. package/dist/generated/test/index.js +3 -1
  6. package/dist/generated/test/index.js.map +1 -1
  7. package/dist/src/Table.d.ts +37 -1
  8. package/dist/src/Table.d.ts.map +1 -1
  9. package/dist/src/Table.js.map +1 -1
  10. package/dist/src/source/SourceRecord.d.ts +2 -3
  11. package/dist/src/source/SourceRecord.d.ts.map +1 -1
  12. package/dist/src/source/SourceRecord.js +16 -2
  13. package/dist/src/source/SourceRecord.js.map +1 -1
  14. package/dist/src/source/SourceRecordLoader.d.ts +12 -0
  15. package/dist/src/source/SourceRecordLoader.d.ts.map +1 -1
  16. package/dist/src/source/SourceRecordLoader.js +72 -50
  17. package/dist/src/source/SourceRecordLoader.js.map +1 -1
  18. package/dist/src/tables/MigrationTable.d.ts +22 -4
  19. package/dist/src/tables/MigrationTable.d.ts.map +1 -1
  20. package/dist/src/tables/MigrationTable.js +38 -16
  21. package/dist/src/tables/MigrationTable.js.map +1 -1
  22. package/dist/test/SourceRecordLoader.test.js +151 -0
  23. package/dist/test/SourceRecordLoader.test.js.map +1 -1
  24. package/dist/test/reusable/SourceRecordSyncTests.d.ts.map +1 -1
  25. package/dist/test/reusable/SourceRecordSyncTests.js +124 -0
  26. package/dist/test/reusable/SourceRecordSyncTests.js.map +1 -1
  27. package/dist/test/util/tables/sourceRecordSyncTestTables.d.ts +17 -0
  28. package/dist/test/util/tables/sourceRecordSyncTestTables.d.ts.map +1 -1
  29. package/dist/test/util/tables/sourceRecordSyncTestTables.js +26 -1
  30. package/dist/test/util/tables/sourceRecordSyncTestTables.js.map +1 -1
  31. package/generated/index.ts +1 -1
  32. package/generated/test/index.ts +3 -1
  33. package/package.json +2 -2
  34. package/src/Table.ts +38 -1
  35. package/src/source/SourceRecord.ts +17 -4
  36. package/src/source/SourceRecordLoader.ts +30 -3
  37. package/src/tables/MigrationTable.ts +43 -16
  38. package/test/SourceRecordLoader.test.ts +90 -0
  39. package/test/reusable/SourceRecordSyncTests.ts +72 -0
  40. package/test/util/tables/sourceRecordSyncTestTables.ts +25 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proteinjs/db",
3
- "version": "1.43.0",
3
+ "version": "1.44.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": "b9fcfe6efb85e76f68876f7b1f2c38e5fdab7932"
69
+ "gitHead": "e58dd79705692df4325dc68b766aa2c9f7073881"
70
70
  }
package/src/Table.ts CHANGED
@@ -2,7 +2,7 @@ import { Loadable, SourceRepository } from '@proteinjs/reflection';
2
2
  import { CustomSerializableObject } from '@proteinjs/serializer';
3
3
  import { isRecordColumn, Record } from './Record';
4
4
  import { TableSerializerId } from './serializers/TableSerializer';
5
- import { ColumnQueryTransform, QueryBuilder } from '@proteinjs/db-query';
5
+ import { ColumnQueryTransform, QueryBuilder, SortCriteria } from '@proteinjs/db-query';
6
6
  import { Identity, TableOperationsAuth } from './auth/TableAuth';
7
7
  import { Db } from './Db';
8
8
  import { EncryptionDerivedTableRegistry } from './encryption/EncryptionDerivedTableRegistry';
@@ -132,6 +132,13 @@ export abstract class Table<T extends Record> implements Loadable, CustomSeriali
132
132
  * form still carries the full record).
133
133
  */
134
134
  columns?: (keyof T & string)[];
135
+ /**
136
+ * The record table's default ordering (the sort its default loader applies before any
137
+ * user interaction). Undeclared tables keep the generic `updated` descending. Declare
138
+ * when the rows have a natural reading order the shared face can't know — e.g. the
139
+ * migration ledger reads most-recently-run first, never-run rows last.
140
+ */
141
+ sort?: SortCriteria<T>[];
135
142
  };
136
143
  };
137
144
  public auth?: {
@@ -330,6 +337,14 @@ export type ColumnOptions = {
330
337
  ) => Promise<void>;
331
338
  ui?: {
332
339
  hidden?: boolean;
340
+ /**
341
+ * The column's display label on the generic record surfaces (table header, form field,
342
+ * phone card label). Undeclared, the surfaces humanize the property name (`startTime` →
343
+ * "Start time"); declare when the property name is not how a human reads the value
344
+ * (the migration ledger's `startTime` reads as "Ran at"). One owner: both surfaces derive
345
+ * from it, so a label can never differ between the table and the form.
346
+ */
347
+ label?: string;
333
348
  /**
334
349
  * Which section of the record form this column belongs to. The form derives a sane
335
350
  * section from the column's type and name (identity strings up top, long text and
@@ -342,7 +357,29 @@ export type ColumnOptions = {
342
357
  };
343
358
  };
344
359
 
360
+ /**
361
+ * The identity of one source-record declaration (a `SourceRecordLoader` in a build): the
362
+ * package that owns it, the reflection qualified name, and the declaration's own name — the
363
+ * loader's class name (or variable name), the part of the qualified name after the package.
364
+ * See `SourceRecordLoaderDeclaration` for the identity paired with the loader itself.
365
+ */
366
+ export type SourceRecordDeclarationIdentity = {
367
+ source: string;
368
+ qualifiedName: string;
369
+ name: string;
370
+ };
371
+
345
372
  export type SourceRecordOptions<T = any> = {
373
+ /**
374
+ * Fields the sync derives from the DECLARATION ITSELF rather than from its record — for
375
+ * tables whose rows carry facts about their declaration: the migration ledger's `name` is
376
+ * the declaring loader's class name, which no migration author should have to type twice.
377
+ * Called once per declaration per boot; the result is merged over the declared record
378
+ * BEFORE the drift comparison, so the derivation owns those fields (a value typed into the
379
+ * record does not survive it), an existing row missing a derived field is backfilled by the
380
+ * next boot exactly once, and an unchanged derivation never rewrites a row.
381
+ */
382
+ fromDeclaration?: (declaration: SourceRecordDeclarationIdentity) => Partial<T>;
346
383
  /**
347
384
  * What the source-record sync does with rows it previously loaded from source
348
385
  * (`is_loaded_from_source = true`) whose declaration no longer exists:
@@ -1,5 +1,5 @@
1
1
  import { Loadable, SourceRepository } from '@proteinjs/reflection';
2
- import { Columns, Table, getTables } from '../Table';
2
+ import { Columns, SourceRecordDeclarationIdentity, Table, getTables } from '../Table';
3
3
  import { Record as DbRecord, withRecordColumns } from '../Record';
4
4
  import { BooleanColumn, StringColumn } from '../Columns';
5
5
 
@@ -10,15 +10,28 @@ import { BooleanColumn, StringColumn } from '../Columns';
10
10
  * and prunes only within it, so servers running different builds against one shared database
11
11
  * never delete each other's rows.
12
12
  */
13
- export type SourceRecordLoaderDeclaration<T extends SourceRecord = SourceRecord> = {
14
- source: string;
13
+ export type SourceRecordLoaderDeclaration<T extends SourceRecord = SourceRecord> = SourceRecordDeclarationIdentity & {
15
14
  loader: SourceRecordLoader<T>;
16
15
  };
17
16
 
18
17
  export const getSourceRecordLoaders = <T extends SourceRecord = SourceRecord>(): SourceRecordLoaderDeclaration<T>[] =>
19
18
  SourceRepository.get()
20
19
  .objectsWithNames<SourceRecordLoader<T>>('@proteinjs/db/SourceRecordLoader')
21
- .map(({ packageName, object }) => ({ source: packageName, loader: object }));
20
+ .map(({ packageName, qualifiedName, object }) => ({
21
+ source: packageName,
22
+ qualifiedName,
23
+ name: declarationName(qualifiedName, packageName),
24
+ loader: object,
25
+ }));
26
+
27
+ /**
28
+ * The declaration's own name from its reflection qualified name (`<package>/<name>`): the
29
+ * loader's class name for a class declaration, the variable name for a variable one.
30
+ */
31
+ const declarationName = (qualifiedName: string, packageName: string) =>
32
+ qualifiedName.startsWith(`${packageName}/`)
33
+ ? qualifiedName.slice(packageName.length + 1)
34
+ : qualifiedName.slice(qualifiedName.lastIndexOf('/') + 1);
22
35
 
23
36
  export function getSourceRecordTables() {
24
37
  const tables = getTables();
@@ -9,6 +9,9 @@ import { RecordSerializer } from '../Record';
9
9
  type DeclaredRecord = {
10
10
  /** The owning package (the declaring loader's package) — the grain the sync prunes within. */
11
11
  source: string;
12
+ /** The declaration's reflection qualified name and its own name (the loader's class name). */
13
+ qualifiedName: string;
14
+ name: string;
12
15
  record: Omit<SourceRecord, 'created' | 'updated'>;
13
16
  };
14
17
 
@@ -56,6 +59,10 @@ export class SourceRecordLoader {
56
59
  * tables it no longer declares anything for: dropping the last declaration is a removal.
57
60
  * - Rows with no owner stamp (written before `source_package` existed) are claimed by the
58
61
  * declaration that still matches them; the rest are reported as unowned and left alone.
62
+ * - The version stamp records the version that last WROTE the row (its insert or its last
63
+ * redefinition). A newer version whose declaration is unchanged writes nothing — it neither
64
+ * re-stamps the version nor bumps `updated` — so a release that touches no declaration
65
+ * leaves every row byte-identical (see {@link hasChanges}).
59
66
  *
60
67
  * Accepted residuals: a package removed from every build leaves its rows behind (no surviving
61
68
  * boot carries its authority — clean up explicitly); two builds of one package at the SAME
@@ -94,9 +101,16 @@ export class SourceRecordLoader {
94
101
  let unchangedCount = 0;
95
102
  let adoptedCount = 0;
96
103
  let skippedNewer = removed.skippedNewer;
97
- for (const { source, record } of records) {
104
+ for (const { source, qualifiedName, name, record } of records) {
98
105
  let sourceRecord = record;
99
106
  sourceRecord.isLoadedFromSource = true;
107
+ // Fields derived from the declaration itself (e.g. the migration ledger's `name` = the
108
+ // loader's class name) — merged before the drift comparison, so they backfill existing
109
+ // rows exactly once and the derivation, not the declared record, owns them.
110
+ const derived = table.sourceRecordOptions.fromDeclaration?.({ source, qualifiedName, name });
111
+ if (derived) {
112
+ Object.assign(sourceRecord, derived);
113
+ }
100
114
  // Ownership stamp: the declaring package (and its version) claims the row. Stamped
101
115
  // before the drift comparison so pre-existing rows (including pre-source_package legacy
102
116
  // rows and rows whose declaration moved packages) converge to the current owner on
@@ -408,16 +422,29 @@ export class SourceRecordLoader {
408
422
  * Object-valued fields (e.g. `JsonColumn` blobs) are treated as source-authoritative:
409
423
  * any structural drift, including extra keys left behind by earlier source versions,
410
424
  * triggers a rewrite. Primitive columns retain their existing semantics.
425
+ *
426
+ * The version stamp (`source_package_version`) is bookkeeping, not definition: it names the
427
+ * version that last WROTE the row and rides every source record (every release bumps the
428
+ * declaring package), so comparing it would rewrite every row on every release — the churn
429
+ * that bumped `updated` on migration ledger rows nobody had touched. A stamped row's version
430
+ * is therefore never drift by itself; it moves only when a genuine change rewrites the row.
431
+ * A row with NO stamp (written before versions existed, or by a build that could not resolve
432
+ * its version) still gets one — once — so version ordering can protect it from then on.
411
433
  */
412
434
  private async hasChanges(table: Table<any>, sourceRecord: any, existingRecord: any): Promise<boolean> {
413
435
  const serializer = new RecordSerializer(table);
414
436
  const serializedSource = await serializer.serialize(sourceRecord);
415
437
  const serializedExisting = await serializer.serialize(existingRecord);
438
+ const versionStampColumn: string | undefined = (table.columns as any).sourcePackageVersion?.name;
416
439
  for (const columnName in serializedSource) {
417
440
  if (columnName === 'id' || columnName === 'created' || columnName === 'updated') {
418
441
  continue;
419
442
  }
420
443
 
444
+ if (columnName === versionStampColumn && serializedExisting[columnName] != null) {
445
+ continue;
446
+ }
447
+
421
448
  const sourceValue = serializedSource[columnName];
422
449
  const existingValue = serializedExisting[columnName];
423
450
  if (this.findMismatchPath(sourceValue, existingValue, columnName)) {
@@ -439,13 +466,13 @@ export class SourceRecordLoader {
439
466
  }
440
467
 
441
468
  const buildSources = new Set<string>();
442
- for (const { source, loader } of getSourceRecordLoaders()) {
469
+ for (const { source, qualifiedName, name, loader } of getSourceRecordLoaders()) {
443
470
  buildSources.add(source);
444
471
  if (!tables[loader.table.name]) {
445
472
  tables[loader.table.name] = { table: loader.table, records: [] };
446
473
  }
447
474
 
448
- tables[loader.table.name].records.push({ source, record: loader.record });
475
+ tables[loader.table.name].records.push({ source, qualifiedName, name, record: loader.record });
449
476
  }
450
477
 
451
478
  return { tables, buildSources };
@@ -4,6 +4,14 @@ import { Table } from '../Table';
4
4
  import { SourceRecord, withSourceRecordColumns } from '../source/SourceRecord';
5
5
 
6
6
  export interface Migration extends SourceRecord {
7
+ /**
8
+ * The declaring loader's class name (`BackfillOnboardingStateForExistingAccounts`) — the
9
+ * ledger's human handle, leading the Migrations table. Derived by the source-record sync from
10
+ * the declaration itself ({@link MigrationTable.sourceRecordOptions} `fromDeclaration`), never
11
+ * typed into a migration's record: existing rows are backfilled by the first boot after this
12
+ * column ships and left alone from then on (a derived field is drift-compared like any other).
13
+ */
14
+ name?: string;
7
15
  description: string;
8
16
  /**
9
17
  * The explicit non-automatable class (plans/POST_RELEASE_QUEUE.md 27f): destructive
@@ -71,32 +79,51 @@ export class MigrationTable extends Table<Migration> {
71
79
  service: { query: { permission: 'dev' }, update: { permission: 'dev' }, delete: { permission: 'dev' } },
72
80
  };
73
81
  /**
74
- * The row scan the founder actually reads: what ran, how it went, HOW LONG it took
75
- * (duration — stamped by the runner at completion; the default pick's five-column cap
76
- * dropped it), then the declaration flags. Failure detail and timestamps stay on the
77
- * record form.
82
+ * The row scan the founder actually reads (ruled 2026-09-02): WHICH migration (name — the
83
+ * loader's class name), what it does, how the run went, WHEN it ran ("Ran at" = start_time,
84
+ * the one timestamp that answers "when did it land"), how long, when it finished, and a
85
+ * glimpse of its output (the mono JSON snippet; the record form carries the full value).
86
+ * The declaration flags and the failure detail stay on the record form. Most recent run
87
+ * first; never-run rows last — GoogleSQL orders NULL as the least value, so start_time DESC
88
+ * places rows without a run after every real timestamp; ledger order (created) breaks ties.
78
89
  */
79
90
  public ui: Table<Migration>['ui'] = {
80
91
  recordTable: {
81
- columns: ['description', 'status', 'duration', 'manual', 'preSchemaSync', 'retired'],
92
+ columns: ['name', 'description', 'status', 'startTime', 'duration', 'endTime', 'output'],
93
+ sort: [
94
+ { field: 'startTime', desc: true },
95
+ { field: 'created', desc: false },
96
+ ],
82
97
  },
83
98
  };
99
+ /**
100
+ * Declared in the order the record form reads them (within a section the form keeps schema
101
+ * order): the migration's identity and flags up top (unlabeled), then its Run — status, ran
102
+ * at, duration, end time, the full output, failure message and stack — then the System meta.
103
+ * Column order carries no DDL weight on an existing table.
104
+ */
84
105
  public columns = withSourceRecordColumns<Migration>({
85
- description: new StringColumn('description', {}, 4000),
86
- manual: new BooleanColumn('manual'),
87
- preSchemaSync: new BooleanColumn('pre_schema_sync'),
88
- retired: new BooleanColumn('retired'),
89
- status: new StringColumn('status', { defaultValue: async () => 'proposed' }),
90
- failureMessage: new StringColumn('failure_message', {}, 4000),
91
- failureStack: new StringColumn('failure_stack', {}, 'MAX'),
92
- startTime: new DateTimeColumn('start_time'),
93
- endTime: new DateTimeColumn('end_time'),
94
- duration: new StringColumn('duration'),
95
- output: new ObjectColumn('output'),
106
+ name: new StringColumn('name'),
107
+ description: new StringColumn('description', { ui: { formGroup: 'identity' } }, 4000),
108
+ manual: new BooleanColumn('manual', { ui: { formGroup: 'identity' } }),
109
+ preSchemaSync: new BooleanColumn('pre_schema_sync', { ui: { formGroup: 'identity' } }),
110
+ retired: new BooleanColumn('retired', { ui: { formGroup: 'identity' } }),
111
+ status: new StringColumn('status', { defaultValue: async () => 'proposed', ui: { formGroup: 'run' } }),
112
+ startTime: new DateTimeColumn('start_time', { ui: { label: 'Ran at', formGroup: 'run' } }),
113
+ duration: new StringColumn('duration', { ui: { formGroup: 'run' } }),
114
+ endTime: new DateTimeColumn('end_time', { ui: { formGroup: 'run' } }),
115
+ // Opted visible: an ObjectColumn hides by default, but a run's output is exactly what the
116
+ // ledger is opened to read (snippet on the row, full JSON on the form).
117
+ output: new ObjectColumn('output', { ui: { hidden: false, formGroup: 'run' } }),
118
+ failureMessage: new StringColumn('failure_message', { ui: { formGroup: 'run' } }, 4000),
119
+ failureStack: new StringColumn('failure_stack', { ui: { formGroup: 'run' } }, 'MAX'),
96
120
  });
97
121
  public sourceRecordOptions: Table<Migration>['sourceRecordOptions'] = {
98
122
  // The ledger outlives the migration class: run history stays when the source is deleted.
99
123
  onSourceRemoved: 'keep',
124
+ // The ledger's `name` is the declaring loader's class name — derived from the declaration
125
+ // by the sync (backfilled once onto existing rows), never typed into each migration.
126
+ fromDeclaration: ({ name }) => ({ name }),
100
127
  ui: {
101
128
  hideColumns: true,
102
129
  },
@@ -152,3 +152,93 @@ describe('SourceRecordLoader.findMismatchPath', () => {
152
152
  });
153
153
  });
154
154
  });
155
+
156
+ /**
157
+ * Stamp hygiene on the real migration-ledger shape: `hasChanges` decides whether a boot
158
+ * rewrites a row (bumping `updated`). The ownership stamps ride the declared record, so the
159
+ * comparison must know which of them mean "the definition changed" and which are bookkeeping.
160
+ */
161
+ describe('SourceRecordLoader.hasChanges — stamp hygiene on the migration ledger', () => {
162
+ type HasChangesInternals = { hasChanges: (table: any, source: any, existing: any) => Promise<boolean> };
163
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
164
+ const { MigrationTable } = require('../src/tables/MigrationTable');
165
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
166
+ const moment = require('moment');
167
+ const loader = () => new SourceRecordLoader() as unknown as HasChangesInternals;
168
+ const table = new MigrationTable();
169
+
170
+ /** What a build declares for a migration (the run function rides along, as it does at boot). */
171
+ const declared = (overrides: Record<string, unknown> = {}) => ({
172
+ id: 'bee7a15c-369f-4e77-abf7-ccd5dc3ae60c',
173
+ description: 'Marks every existing account onboarded',
174
+ run: async () => undefined,
175
+ isLoadedFromSource: true,
176
+ sourcePackage: '@n3xa/app-server',
177
+ sourcePackageVersion: '1.24.0',
178
+ ...overrides,
179
+ });
180
+
181
+ /** The ledger row a previous release wrote and later ran. */
182
+ const stored = (overrides: Record<string, unknown> = {}) => ({
183
+ id: 'bee7a15c-369f-4e77-abf7-ccd5dc3ae60c',
184
+ description: 'Marks every existing account onboarded',
185
+ isLoadedFromSource: true,
186
+ sourcePackage: '@n3xa/app-server',
187
+ sourcePackageVersion: '1.23.0',
188
+ status: 'success',
189
+ startTime: moment('2026-08-30T10:00:00Z'),
190
+ endTime: moment('2026-08-30T10:00:02Z'),
191
+ duration: '2 secs',
192
+ output: { rowsInserted: 12 },
193
+ created: moment('2026-08-30T09:00:00Z'),
194
+ updated: moment('2026-08-30T10:00:02Z'),
195
+ ...overrides,
196
+ });
197
+
198
+ it('a newer package version alone is NOT a change — the every-release re-stamp', async () => {
199
+ expect(await loader().hasChanges(table, declared(), stored())).toBe(false);
200
+ });
201
+
202
+ it('a row with no version stamp gets one (once)', async () => {
203
+ const unstamped = stored();
204
+ delete (unstamped as Partial<typeof unstamped>).sourcePackageVersion;
205
+ expect(await loader().hasChanges(table, declared(), unstamped)).toBe(true);
206
+ });
207
+
208
+ it('a redefined description is still a change', async () => {
209
+ expect(await loader().hasChanges(table, declared({ description: 'Reworded' }), stored())).toBe(true);
210
+ });
211
+
212
+ it('ledger-owned run state (status, times, output) never reads as drift', async () => {
213
+ expect(await loader().hasChanges(table, declared({ sourcePackageVersion: '1.23.0' }), stored())).toBe(false);
214
+ });
215
+ });
216
+
217
+ describe('getSourceRecordLoaders — declaration identity', () => {
218
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
219
+ const { SourceRepository } = require('@proteinjs/reflection');
220
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
221
+ const { getSourceRecordLoaders } = require('../src/source/SourceRecord');
222
+ const namedObjectCache = () => (SourceRepository.get() as any).namedObjectCache;
223
+
224
+ afterEach(() => {
225
+ delete namedObjectCache()['@proteinjs/db/SourceRecordLoader'];
226
+ });
227
+
228
+ it("carries the declaration's own name (the loader class) beside its package and qualified name", () => {
229
+ namedObjectCache()['@proteinjs/db/SourceRecordLoader'] = [
230
+ {
231
+ qualifiedName: '@n3xa/app-server/BackfillOnboardingStateForExistingAccounts',
232
+ packageName: '@n3xa/app-server',
233
+ object: { table: {}, record: {} },
234
+ },
235
+ ];
236
+
237
+ const [declaration] = getSourceRecordLoaders() as any[];
238
+ expect(declaration).toMatchObject({
239
+ source: '@n3xa/app-server',
240
+ qualifiedName: '@n3xa/app-server/BackfillOnboardingStateForExistingAccounts',
241
+ name: 'BackfillOnboardingStateForExistingAccounts',
242
+ });
243
+ });
244
+ });
@@ -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
 
@@ -729,6 +730,77 @@ export const sourceRecordSyncTests = (
729
730
  });
730
731
  });
731
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
+
732
804
  test('a natural key without a unique guarantee fails boot loudly', async () => {
733
805
  // Local (unregistered, never created) on purpose: a registered table with an invalid
734
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
  };