@proteinjs/db 1.47.2 → 1.48.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 (46) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/generated/index.js +1 -1
  3. package/dist/generated/index.js.map +1 -1
  4. package/dist/generated/test/index.js +1 -1
  5. package/dist/generated/test/index.js.map +1 -1
  6. package/dist/src/Db.d.ts +9 -0
  7. package/dist/src/Db.d.ts.map +1 -1
  8. package/dist/src/Db.js.map +1 -1
  9. package/dist/src/Table.d.ts +12 -5
  10. package/dist/src/Table.d.ts.map +1 -1
  11. package/dist/src/services/TransactionRunnerService.d.ts +20 -1
  12. package/dist/src/services/TransactionRunnerService.d.ts.map +1 -1
  13. package/dist/src/source/SourceRecord.d.ts +3 -1
  14. package/dist/src/source/SourceRecord.d.ts.map +1 -1
  15. package/dist/src/source/SourceRecordLoader.d.ts +12 -0
  16. package/dist/src/source/SourceRecordLoader.d.ts.map +1 -1
  17. package/dist/src/source/SourceRecordLoader.js +50 -25
  18. package/dist/src/source/SourceRecordLoader.js.map +1 -1
  19. package/dist/src/transaction/Transaction.d.ts +6 -1
  20. package/dist/src/transaction/Transaction.d.ts.map +1 -1
  21. package/dist/src/transaction/Transaction.js +13 -2
  22. package/dist/src/transaction/Transaction.js.map +1 -1
  23. package/dist/src/transaction/TransactionRunner.d.ts +45 -2
  24. package/dist/src/transaction/TransactionRunner.d.ts.map +1 -1
  25. package/dist/src/transaction/TransactionRunner.js +227 -26
  26. package/dist/src/transaction/TransactionRunner.js.map +1 -1
  27. package/dist/test/reusable/SourceRecordSyncTests.d.ts +5 -4
  28. package/dist/test/reusable/SourceRecordSyncTests.d.ts.map +1 -1
  29. package/dist/test/reusable/SourceRecordSyncTests.js +233 -29
  30. package/dist/test/reusable/SourceRecordSyncTests.js.map +1 -1
  31. package/dist/test/util/tables/sourceRecordSyncTestTables.d.ts +2 -2
  32. package/dist/test/util/tables/sourceRecordSyncTestTables.d.ts.map +1 -1
  33. package/dist/test/util/tables/sourceRecordSyncTestTables.js +1 -1
  34. package/dist/test/util/tables/sourceRecordSyncTestTables.js.map +1 -1
  35. package/generated/index.ts +1 -1
  36. package/generated/test/index.ts +1 -1
  37. package/package.json +2 -2
  38. package/src/Db.ts +9 -0
  39. package/src/Table.ts +12 -5
  40. package/src/services/TransactionRunnerService.ts +21 -1
  41. package/src/source/SourceRecord.ts +3 -1
  42. package/src/source/SourceRecordLoader.ts +35 -4
  43. package/src/transaction/Transaction.ts +11 -2
  44. package/src/transaction/TransactionRunner.ts +174 -3
  45. package/test/reusable/SourceRecordSyncTests.ts +164 -25
  46. 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.2",
3
+ "version": "1.48.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": "5811ffac3d235bce810460223eaa567b9f36a3a6"
69
+ "gitHead": "ec3fd54430dde18afd406ec70eccd857492272a0"
70
70
  }
package/src/Db.ts CHANGED
@@ -95,6 +95,15 @@ export interface DbDriver {
95
95
  ): Promise<SerializedRecord[]>;
96
96
  runDml(generateStatement: (config: DbDriverDmlStatementConfig) => Statement, transaction?: any): Promise<number>;
97
97
  runTransaction<T>(fn: (transaction: any) => Promise<T>): Promise<T>;
98
+ /**
99
+ * The driver's per-operation deadline, in milliseconds: the longest one statement it runs may
100
+ * take before the driver fails it — the bound the driver enforces, as configured. Not a whole
101
+ * transaction's budget (a transaction the database aborts and the driver re-runs may take
102
+ * longer in all). Code that waits on another request's write — a row it depends on whose insert
103
+ * may still be in flight — sizes its wait by this, so the wait follows what the deployment
104
+ * configured its database to tolerate rather than a number of its own.
105
+ */
106
+ getOperationDeadlineMs(): number;
98
107
  }
99
108
 
100
109
  export class Db<R extends Record = Record> implements DbService<R> {
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, adoption, and
456
- * the removed reconcile all use it. An existing row matched by natural key is ADOPTED in
457
- * place: it keeps its id (the declared id is used only for fresh inserts — existing ids may
458
- * be referenced from other tables), gets stamped `is_loaded_from_source = true`, and has its
459
- * declared fields reverted to source. Drift comparison excludes `id`, so adoption converges.
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
@@ -5,6 +5,26 @@ export const getTransactionRunnerService = serviceFactory<TransactionRunnerServi
5
5
  '@proteinjs/db/TransactionRunnerService'
6
6
  );
7
7
 
8
+ /**
9
+ * What a write declares about the rows it depends on.
10
+ *
11
+ * A client that releases a queued write because its page is going away cannot order that write
12
+ * behind the writes it depends on — the page is gone before their answers arrive. The server
13
+ * outlives the page, so the write DECLARES its dependencies and the server honours them.
14
+ */
15
+ export type TransactionRunOptions = {
16
+ /**
17
+ * Ids of rows the operations depend on that may not exist yet: the row an update or a
18
+ * membership update targets while its insert is still in flight, the permission source a
19
+ * child row attaches to while the root's own insert (and the owner grant that commits with
20
+ * it) is still landing. Every id must be a row the operations themselves reference. The
21
+ * runner waits, bounded, for each to exist AND be visible to the caller before running the
22
+ * operations; a row that never appears fails the request with a plain clause — never a
23
+ * silent zero-row write, never a hang.
24
+ */
25
+ afterRows?: string[];
26
+ };
27
+
8
28
  export interface TransactionRunnerService extends Service {
9
- run(ops: Operation<any>[]): Promise<void>;
29
+ run(ops: Operation<any>[], options?: TransactionRunOptions): Promise<void>;
10
30
  }
@@ -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
- // Adopt in place: the existing row keeps its id — other tables may reference it.
151
- // The declared id is only ever used for fresh inserts.
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 pre-existing (runtime-created) row is being taken over by a declaration —
157
- // deliberate, but loud: a declaration asserts ownership of the row's identity.
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,
@@ -1,6 +1,7 @@
1
1
  import { Record } from '../Record';
2
2
  import { DbService, ObjectQuery } from '../services/DbService';
3
3
  import { getTransactionRunner } from './TransactionRunner';
4
+ import { TransactionRunOptions } from '../services/TransactionRunnerService';
4
5
  import { addDefaultFieldValues, Table } from '../Table';
5
6
  import { isInstanceOf } from '@proteinjs/util';
6
7
  import { Condition, QueryBuilder } from '@proteinjs/db-query';
@@ -269,14 +270,22 @@ export class Transaction implements OperationQueue {
269
270
 
270
271
  /**
271
272
  * Run the operations in order (not a batch), as a single transaction.
273
+ *
274
+ * `options.afterRows` declares rows the operations depend on that may not exist yet (see
275
+ * {@link TransactionRunOptions}); the server waits for them before running. A run with nothing
276
+ * to declare hands the runner the operations alone — the request is the one it always was.
272
277
  */
273
- async run(): Promise<void> {
278
+ async run(options?: TransactionRunOptions): Promise<void> {
274
279
  if (this.ops.length === 0) {
275
280
  return;
276
281
  }
277
282
 
278
283
  const runner = getTransactionRunner();
279
- await runner.run(this.ops);
284
+ if (options?.afterRows && options.afterRows.length > 0) {
285
+ await runner.run(this.ops, { afterRows: options.afterRows });
286
+ } else {
287
+ await runner.run(this.ops);
288
+ }
280
289
  this.ops = [];
281
290
  }
282
291
  }
@@ -1,10 +1,32 @@
1
- import { getDb } from '../Db';
2
- import { TransactionRunnerService, getTransactionRunnerService } from '../services/TransactionRunnerService';
1
+ import { Logger } from '@proteinjs/logger';
2
+ import { Db, getDb } from '../Db';
3
+ import { Table, tableByName } from '../Table';
4
+ import { getDefaultTransactionContextFactory } from './TransactionContextFactory';
5
+ import {
6
+ TransactionRunnerService,
7
+ TransactionRunOptions,
8
+ getTransactionRunnerService,
9
+ } from '../services/TransactionRunnerService';
3
10
  import { Operation } from './Transaction';
4
11
 
5
12
  export const getTransactionRunner = () =>
6
13
  typeof self === 'undefined' ? new TransactionRunner() : (getTransactionRunnerService() as TransactionRunner);
7
14
 
15
+ /**
16
+ * Runs a client `Transaction`'s operations as one server-side transaction.
17
+ *
18
+ * A write may DECLARE the rows it depends on (`options.afterRows`): rows its operations
19
+ * reference whose own insert may still be in flight when this request arrives — a text update
20
+ * released by a page that was going away while its row's insert was still being served, a child
21
+ * row's insert whose root (its permission source, and the owner grant that commits with the
22
+ * root) is still landing. Without the declaration such a write ran to completion inside the
23
+ * insert's window and matched nothing, silently, or was refused at the door for a grant that was
24
+ * a moment from existing — and the page that could have retried was gone. The server outlives
25
+ * the page: it waits, bounded by the db driver's per-operation deadline, for every declared row to
26
+ * exist AND be visible to the caller (a read as the caller sees a row exactly when the caller's
27
+ * grant on its scope has committed), then runs the operations. A row that never appears fails the
28
+ * request with a plain clause.
29
+ */
8
30
  export class TransactionRunner implements TransactionRunnerService {
9
31
  public serviceMetadata = {
10
32
  auth: {
@@ -12,8 +34,17 @@ export class TransactionRunner implements TransactionRunnerService {
12
34
  },
13
35
  };
14
36
 
15
- async run(ops: Operation<any>[]): Promise<void> {
37
+ private static readonly FIRST_POLL_INTERVAL_MS = 25;
38
+ private static readonly MAX_POLL_INTERVAL_MS = 250;
39
+
40
+ private logger = new Logger({ name: this.constructor.name });
41
+
42
+ async run(ops: Operation<any>[], options?: TransactionRunOptions): Promise<void> {
16
43
  const db = getDb();
44
+ const afterRows = options?.afterRows ?? [];
45
+ if (afterRows.length > 0) {
46
+ await this.awaitRows(ops, afterRows);
47
+ }
17
48
 
18
49
  await db.runTransaction(async () => {
19
50
  for (const op of ops) {
@@ -21,4 +52,144 @@ export class TransactionRunner implements TransactionRunnerService {
21
52
  }
22
53
  });
23
54
  }
55
+
56
+ /**
57
+ * Wait until every declared row exists and is visible to the caller, bounded by the db driver's
58
+ * per-operation deadline (`DbDriver.getOperationDeadlineMs`), read when the write waits. The
59
+ * bound is the ceiling for a row that NEVER comes — the named failure, never a hang. A
60
+ * dependency's insert lands in hundreds of milliseconds on a healthy link and in seconds on a
61
+ * degraded one (a root's birth of a few dozen sequential statements has taken 13 s), and the
62
+ * writes released behind it by a page that is gone have no second chance. The driver's deadline
63
+ * is how long the deployment lets one statement of that insert run before failing it: the wait
64
+ * takes its measure from there — one owner for the number, configured with the database — not
65
+ * from a number of its own. A write that waits here holds one request and a poll, nothing else.
66
+ *
67
+ * Each poll is a single-use strong read outside any transaction, so a commit is seen the moment
68
+ * it lands; the interval starts short and backs off so a dependency landing in hundreds of
69
+ * milliseconds is caught within tens.
70
+ */
71
+ private async awaitRows(ops: Operation<any>[], afterRows: string[]): Promise<void> {
72
+ if (getDefaultTransactionContextFactory()?.getTransactionContext().currentTransaction) {
73
+ throw new Error(
74
+ `TransactionRunner: afterRows cannot be honoured inside an ambient transaction — its reads would see the transaction's own snapshot, never a row committed after it began`
75
+ );
76
+ }
77
+
78
+ const tablesById = TransactionRunner.rowsTheOperationsReference(ops);
79
+ const unknown = afterRows.filter((id) => !tablesById.has(id));
80
+ if (unknown.length > 0) {
81
+ throw new Error(
82
+ `TransactionRunner: afterRows names rows none of the operations reference (${unknown.join(', ')}) — the write was not run`
83
+ );
84
+ }
85
+
86
+ const db = getDb();
87
+ const boundMs = Db.getDefaultDbDriver().getOperationDeadlineMs();
88
+ const started = Date.now();
89
+ const deadline = started + boundMs;
90
+ /** Per declared id, the (table, id) rows still absent. */
91
+ let waiting: { id: string; tables: Table<any>[] }[] = afterRows
92
+ .filter((id, index) => afterRows.indexOf(id) === index)
93
+ .map((id) => ({ id, tables: tablesById.get(id)!.slice() }));
94
+ let interval = TransactionRunner.FIRST_POLL_INTERVAL_MS;
95
+ let polls = 0;
96
+ for (;;) {
97
+ polls += 1;
98
+ for (const row of waiting) {
99
+ const stillAbsent: Table<any>[] = [];
100
+ for (const table of row.tables) {
101
+ if (!(await db.get(table, { id: row.id } as any))) {
102
+ stillAbsent.push(table);
103
+ }
104
+ }
105
+ row.tables = stillAbsent;
106
+ }
107
+ waiting = waiting.filter((row) => row.tables.length > 0);
108
+ if (waiting.length === 0) {
109
+ const waitedMs = Date.now() - started;
110
+ // One line when the seam did work (the row landed while this write waited); a row that
111
+ // was already there is the common case and stays quiet.
112
+ const line = { message: `Declared rows present`, obj: { afterRows, waitedMs, polls } };
113
+ if (polls > 1) {
114
+ this.logger.info(line);
115
+ } else {
116
+ this.logger.debug(line);
117
+ }
118
+ return;
119
+ }
120
+
121
+ const now = Date.now();
122
+ if (now >= deadline) {
123
+ const absent = waiting.map((row) => row.tables.map((table) => `${table.name}:${row.id}`).join(', ')).join(', ');
124
+ this.logger.warn({
125
+ message: `Declared rows absent at the bound — the write was not run`,
126
+ obj: { absent, boundMs, waitedMs: now - started, polls },
127
+ });
128
+ throw new Error(
129
+ `TransactionRunner: waited ${boundMs} ms for rows this write depends on to exist and be visible to the caller; still absent: ${absent} — the write was not run (the bound is the db driver's per-operation deadline)`
130
+ );
131
+ }
132
+ await new Promise((resolve) => setTimeout(resolve, Math.min(interval, deadline - now)));
133
+ interval = Math.min(interval * 2, TransactionRunner.MAX_POLL_INTERVAL_MS);
134
+ }
135
+ }
136
+
137
+ /**
138
+ * The rows the operations reference, by id → the table(s) they are read from: the permission
139
+ * source a row attaches to (an insert whose `permissionSource` is another row), the row an
140
+ * update / preserving update / membership update / delete targets. The rows the operations
141
+ * insert themselves are not dependencies.
142
+ */
143
+ private static rowsTheOperationsReference(ops: Operation<any>[]): Map<string, Table<any>[]> {
144
+ const inserted: string[] = [];
145
+ const referenced = new Map<string, Table<any>[]>();
146
+ const reference = (table: Table<any> | undefined, id: unknown) => {
147
+ if (!table || typeof id !== 'string') {
148
+ return;
149
+ }
150
+ let tables = referenced.get(id);
151
+ if (!tables) {
152
+ tables = [];
153
+ referenced.set(id, tables);
154
+ }
155
+ if (tables.indexOf(table) === -1) {
156
+ tables.push(table);
157
+ }
158
+ };
159
+
160
+ for (const op of ops) {
161
+ const args = op.args as unknown[];
162
+ const table = args[0] as Table<any> | undefined;
163
+ if (op.name === 'insert') {
164
+ const record = args[1] as
165
+ | { id?: string; permissionSource?: { _table?: string; _id?: string }; permissionSourceTable?: string }
166
+ | undefined;
167
+ if (record?.id) {
168
+ inserted.push(record.id);
169
+ }
170
+ const source = record?.permissionSource;
171
+ if (source?._id && source._id !== record?.id) {
172
+ const sourceTableName = record?.permissionSourceTable ?? source._table;
173
+ reference(sourceTableName ? TransactionRunner.tableNamed(sourceTableName, table) : table, source._id);
174
+ }
175
+ } else if (op.name === 'update' || op.name === 'updatePreserving') {
176
+ const record = args[1] as { id?: string } | undefined;
177
+ const query = args[2] as { id?: unknown } | undefined;
178
+ reference(table, record?.id ?? (query && typeof query === 'object' && 'id' in query ? query.id : undefined));
179
+ } else if (op.name === 'updateArrayMembership') {
180
+ reference(table, (args[1] as { recordId?: string } | undefined)?.recordId);
181
+ } else if (op.name === 'delete') {
182
+ const query = args[1] as { id?: unknown } | undefined;
183
+ reference(table, query && typeof query === 'object' ? query.id : undefined);
184
+ }
185
+ }
186
+
187
+ inserted.forEach((id) => referenced.delete(id));
188
+ return referenced;
189
+ }
190
+
191
+ /** The table a permission source lives in: the insert's own table when it names the same one. */
192
+ private static tableNamed(tableName: string, inserting: Table<any> | undefined): Table<any> {
193
+ return inserting && inserting.name === tableName ? inserting : tableByName(tableName);
194
+ }
24
195
  }
@@ -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 adoption
56
- * (existing row keeps its id and runtime fields), `onSourceRemoved` policies (flag-not-delete
57
- * through `Db.update` so watchers fire; default delete unchanged), boot-time natural-key
58
- * validation, and the unique-index duplicate preflight.
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-key adoption: an existing row is adopted in place — id and runtime fields preserved', async () => {
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
- // The hand-made bridge row: env-random id, runtime-provisioned fields, never flagged.
207
- const handMade = await db.insert(machineTable, {
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: 'Hand-made bridge',
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([machineDeclaration({ id: 'declared-id', email: 'bridge@test.local', displayName: 'Ops bridge' })]);
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: handMade.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
- // The in-process repo registers the record under the ADOPTED id, not the declared one.
229
- expect(new SourceRecordRepo().getSourceRecord(machineTable.name, handMade.id)).toBeDefined();
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
- // Adoption converges: the id difference is not perpetual drift.
301
+ // The id difference is not perpetual drift.
233
302
  const stampBefore = rows[0].updated.valueOf();
234
- await boot([machineDeclaration({ id: 'declared-id', email: 'bridge@test.local', displayName: 'Ops bridge' })]);
235
- const afterIdleBoot = await db.get(machineTable, { id: handMade.id });
236
- expect(afterIdleBoot.updated.valueOf()).toBe(stampBefore);
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-adopted row in place — adopted id preserved', async () => {
656
+ test('cross-source boots leave a natural-key-synced row in place — its environment id preserved', async () => {
524
657
  const db = getDbAsSystem();
525
- // Hand-made row with an environment-random id, adopted by pkg-b via natural key.
526
- const handMade = await db.insert(machineTable, { email: 'bridge2@test.local', runtimeNote: 'cred' });
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
  ]);