@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.
- package/CHANGELOG.md +28 -0
- package/dist/generated/index.js +1 -1
- package/dist/generated/index.js.map +1 -1
- package/dist/generated/test/index.js +1 -1
- package/dist/generated/test/index.js.map +1 -1
- package/dist/src/Db.d.ts +9 -0
- package/dist/src/Db.d.ts.map +1 -1
- package/dist/src/Db.js.map +1 -1
- package/dist/src/Table.d.ts +12 -5
- package/dist/src/Table.d.ts.map +1 -1
- package/dist/src/services/TransactionRunnerService.d.ts +20 -1
- package/dist/src/services/TransactionRunnerService.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/src/transaction/Transaction.d.ts +6 -1
- package/dist/src/transaction/Transaction.d.ts.map +1 -1
- package/dist/src/transaction/Transaction.js +13 -2
- package/dist/src/transaction/Transaction.js.map +1 -1
- package/dist/src/transaction/TransactionRunner.d.ts +45 -2
- package/dist/src/transaction/TransactionRunner.d.ts.map +1 -1
- package/dist/src/transaction/TransactionRunner.js +227 -26
- package/dist/src/transaction/TransactionRunner.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/index.ts +1 -1
- package/generated/test/index.ts +1 -1
- package/package.json +2 -2
- package/src/Db.ts +9 -0
- package/src/Table.ts +12 -5
- package/src/services/TransactionRunnerService.ts +21 -1
- package/src/source/SourceRecord.ts +3 -1
- package/src/source/SourceRecordLoader.ts +35 -4
- package/src/transaction/Transaction.ts +11 -2
- package/src/transaction/TransactionRunner.ts +174 -3
- 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.
|
|
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": "
|
|
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
|
|
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
|
|
@@ -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
|
-
//
|
|
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,
|
|
@@ -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
|
-
|
|
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 {
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
|
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
|
]);
|