@proteinjs/db 1.34.3 → 1.34.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/dist/generated/index.js +1 -1
- package/dist/generated/index.js.map +1 -1
- package/dist/generated/test/index.d.ts.map +1 -1
- package/dist/generated/test/index.js +3 -1
- package/dist/generated/test/index.js.map +1 -1
- package/dist/src/source/SourceRecord.d.ts +35 -2
- package/dist/src/source/SourceRecord.d.ts.map +1 -1
- package/dist/src/source/SourceRecord.js +8 -1
- package/dist/src/source/SourceRecord.js.map +1 -1
- package/dist/src/source/SourceRecordLoader.d.ts +98 -7
- package/dist/src/source/SourceRecordLoader.d.ts.map +1 -1
- package/dist/src/source/SourceRecordLoader.js +348 -93
- package/dist/src/source/SourceRecordLoader.js.map +1 -1
- package/dist/src/tables/MigrationTable.d.ts +2 -0
- package/dist/src/tables/MigrationTable.d.ts.map +1 -1
- package/dist/test/reusable/SourceRecordSyncTests.d.ts.map +1 -1
- package/dist/test/reusable/SourceRecordSyncTests.js +803 -27
- package/dist/test/reusable/SourceRecordSyncTests.js.map +1 -1
- package/dist/test/util/tables/sourceRecordSyncTestTables.d.ts +16 -0
- package/dist/test/util/tables/sourceRecordSyncTestTables.d.ts.map +1 -1
- package/dist/test/util/tables/sourceRecordSyncTestTables.js +23 -1
- package/dist/test/util/tables/sourceRecordSyncTestTables.js.map +1 -1
- package/generated/index.ts +1 -1
- package/generated/test/index.ts +3 -1
- package/package.json +4 -4
- package/src/source/SourceRecord.ts +42 -4
- package/src/source/SourceRecordLoader.ts +333 -48
- package/test/reusable/SourceRecordSyncTests.ts +462 -11
- package/test/util/tables/sourceRecordSyncTestTables.ts +20 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@proteinjs/db",
|
|
3
|
-
"version": "1.34.
|
|
3
|
+
"version": "1.34.4",
|
|
4
4
|
"main": "./dist/generated/index.js",
|
|
5
5
|
"types": "./dist/generated/index.d.ts",
|
|
6
6
|
"exports": {
|
|
@@ -41,9 +41,9 @@
|
|
|
41
41
|
"test": "jest --passWithNoTests"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@proteinjs/db-query": "^1.7.
|
|
44
|
+
"@proteinjs/db-query": "^1.7.2",
|
|
45
45
|
"@proteinjs/logger": "^1.0.21",
|
|
46
|
-
"@proteinjs/reflection": "^1.
|
|
46
|
+
"@proteinjs/reflection": "^1.2.0",
|
|
47
47
|
"@proteinjs/serializer": "^1.1.10",
|
|
48
48
|
"@proteinjs/server-api": "^3.0.11",
|
|
49
49
|
"@proteinjs/service": "^1.5.1",
|
|
@@ -66,5 +66,5 @@
|
|
|
66
66
|
"ts-jest": "29.1.1",
|
|
67
67
|
"typescript": "5.2.2"
|
|
68
68
|
},
|
|
69
|
-
"gitHead": "
|
|
69
|
+
"gitHead": "6fe97aa884e73cdeed9cfc7b4a0c12b396657057"
|
|
70
70
|
}
|
|
@@ -1,10 +1,24 @@
|
|
|
1
1
|
import { Loadable, SourceRepository } from '@proteinjs/reflection';
|
|
2
2
|
import { Columns, Table, getTables } from '../Table';
|
|
3
3
|
import { Record as DbRecord, withRecordColumns } from '../Record';
|
|
4
|
-
import { BooleanColumn } from '../Columns';
|
|
4
|
+
import { BooleanColumn, StringColumn } from '../Columns';
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
/**
|
|
7
|
+
* A source record declaration paired with its owning source: the package that compiled the
|
|
8
|
+
* declaration into this build (from the declaration's reflection qualified name). The source is
|
|
9
|
+
* the ownership grain of the sync — {@link SourceRecordLoader} stamps it on every row it writes
|
|
10
|
+
* and prunes only within it, so servers running different builds against one shared database
|
|
11
|
+
* never delete each other's rows.
|
|
12
|
+
*/
|
|
13
|
+
export type SourceRecordLoaderDeclaration<T extends SourceRecord = SourceRecord> = {
|
|
14
|
+
source: string;
|
|
15
|
+
loader: SourceRecordLoader<T>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const getSourceRecordLoaders = <T extends SourceRecord = SourceRecord>(): SourceRecordLoaderDeclaration<T>[] =>
|
|
19
|
+
SourceRepository.get()
|
|
20
|
+
.objectsWithNames<SourceRecordLoader<T>>('@proteinjs/db/SourceRecordLoader')
|
|
21
|
+
.map(({ packageName, object }) => ({ source: packageName, loader: object }));
|
|
8
22
|
|
|
9
23
|
export function getSourceRecordTables() {
|
|
10
24
|
const tables = getTables();
|
|
@@ -31,11 +45,31 @@ export function isSourceRecordTable(table: Table<any>) {
|
|
|
31
45
|
|
|
32
46
|
export interface SourceRecord extends DbRecord {
|
|
33
47
|
isLoadedFromSource?: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* The package whose declaration owns this row (the declaring loader's package, from its
|
|
50
|
+
* reflection qualified name). Stamped by {@link SourceRecordLoader} on every row it writes;
|
|
51
|
+
* the removed-reconcile prunes only rows whose `sourcePackage` matches a package the running
|
|
52
|
+
* build actually declares from — so a build never deletes rows owned by a package it does not
|
|
53
|
+
* carry (e.g. another server's types on a shared database). Rows written before this column
|
|
54
|
+
* existed carry NULL until their owning package's next boot adopts and stamps them.
|
|
55
|
+
*/
|
|
56
|
+
sourcePackage?: string;
|
|
57
|
+
/**
|
|
58
|
+
* The declaring package's version at the time this row was last stamped (from the package's
|
|
59
|
+
* own package.json, resolved at runtime). This is the ordering WITHIN a package that makes
|
|
60
|
+
* version skew safe on a shared database: a boot never prunes, flags, or rewrites a row stamped
|
|
61
|
+
* by a strictly NEWER version of the same package, so an older build cannot delete the types a
|
|
62
|
+
* newer build added or churn the ones it redefined. NULL (legacy rows, or builds whose package
|
|
63
|
+
* version could not be resolved) carries no ordering and keeps the last-writer-wins semantics.
|
|
64
|
+
*/
|
|
65
|
+
sourcePackageVersion?: string;
|
|
34
66
|
}
|
|
35
67
|
|
|
36
68
|
const getSourceRecordColumns = (hideFromUi = true) => {
|
|
37
69
|
return {
|
|
38
70
|
isLoadedFromSource: new BooleanColumn('is_loaded_from_source', { ui: { hidden: hideFromUi } }),
|
|
71
|
+
sourcePackage: new StringColumn('source_package', { ui: { hidden: hideFromUi } }),
|
|
72
|
+
sourcePackageVersion: new StringColumn('source_package_version', { ui: { hidden: hideFromUi } }),
|
|
39
73
|
};
|
|
40
74
|
};
|
|
41
75
|
|
|
@@ -78,7 +112,11 @@ type OptionalProperties<T> = Pick<
|
|
|
78
112
|
*
|
|
79
113
|
* 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.
|
|
80
114
|
*
|
|
81
|
-
* If the SourceRecordLoader is deleted from source, the record will be deleted from the db on server startup
|
|
115
|
+
* If the SourceRecordLoader is deleted from source, the record will be deleted from the db on server startup (per the
|
|
116
|
+
* table's `onSourceRemoved` policy) — by the next boot of a build that still carries the declaring package at its
|
|
117
|
+
* version or newer. Ownership is per package: a boot only reconciles rows owned by packages it carries, and never
|
|
118
|
+
* rows a newer version of the same package stamped; a package removed from every build leaves its rows behind.
|
|
119
|
+
* This will also be the behavior if id is changed - the record with the old id will be deleted.
|
|
82
120
|
*/
|
|
83
121
|
export interface SourceRecordLoader<T extends SourceRecord> extends Loadable {
|
|
84
122
|
table: Table<T>;
|
|
@@ -6,32 +6,110 @@ import { Db, getDbAsSystem } from '../Db';
|
|
|
6
6
|
import { SourceRecordRepo } from './SourceRecordRepo';
|
|
7
7
|
import { RecordSerializer } from '../Record';
|
|
8
8
|
|
|
9
|
+
type DeclaredRecord = {
|
|
10
|
+
/** The owning package (the declaring loader's package) — the grain the sync prunes within. */
|
|
11
|
+
source: string;
|
|
12
|
+
record: Omit<SourceRecord, 'created' | 'updated'>;
|
|
13
|
+
};
|
|
14
|
+
|
|
9
15
|
type SourceRecordsMap = {
|
|
10
|
-
[tableName: string]: { table: Table<any>; records:
|
|
16
|
+
[tableName: string]: { table: Table<any>; records: DeclaredRecord[] };
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** What one boot of the sync did to one source-record table. */
|
|
20
|
+
export type SourceRecordTableLoadSummary = {
|
|
21
|
+
inserts: number;
|
|
22
|
+
updates: number;
|
|
23
|
+
unchanged: number;
|
|
24
|
+
adopted: number;
|
|
25
|
+
deletes: number;
|
|
26
|
+
removedUpdates: number;
|
|
27
|
+
/** Rows stamped by a newer version of their declaring package — left exactly as they are. */
|
|
28
|
+
skippedNewer: number;
|
|
29
|
+
/** Source-loaded rows with no owner stamp that no declaration in this build claims. */
|
|
30
|
+
unowned: number;
|
|
11
31
|
};
|
|
12
32
|
|
|
33
|
+
export type SourceRecordLoadSummary = { [tableName: string]: SourceRecordTableLoadSummary };
|
|
34
|
+
|
|
13
35
|
export class SourceRecordLoader {
|
|
14
36
|
private logger = new Logger({ name: this.constructor.name });
|
|
15
37
|
|
|
16
|
-
|
|
17
|
-
|
|
38
|
+
/** Memo of resolved package versions — one resolution (and at most one warning) per source. */
|
|
39
|
+
private sourceVersions = new Map<string, string | undefined>();
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* One boot of the source-record sync, per source-record table in this build. Returns what it
|
|
43
|
+
* did to each table (also logged).
|
|
44
|
+
*
|
|
45
|
+
* Ownership model — every row has one owner, the package that declares it, ordered by that
|
|
46
|
+
* package's version:
|
|
47
|
+
* - A boot speaks only for the packages it carries. Rows owned by a package this build does
|
|
48
|
+
* not carry are never touched: several servers running different builds against one shared
|
|
49
|
+
* database coexist without pruning each other's rows.
|
|
50
|
+
* - Within a package, a boot speaks only for its own version or older. A row stamped by a
|
|
51
|
+
* NEWER version of the same package is left exactly as it is — neither pruned nor rewritten —
|
|
52
|
+
* so ordinary version skew (an older build restarting against rows a newer build added or
|
|
53
|
+
* redefined) cannot delete or churn data. The newest version stays authoritative for
|
|
54
|
+
* genuine removals and redefinitions.
|
|
55
|
+
* - A package in the build is authoritative for all of its rows on every table, including
|
|
56
|
+
* tables it no longer declares anything for: dropping the last declaration is a removal.
|
|
57
|
+
* - Rows with no owner stamp (written before `source_package` existed) are claimed by the
|
|
58
|
+
* declaration that still matches them; the rest are reported as unowned and left alone.
|
|
59
|
+
*
|
|
60
|
+
* Accepted residuals: a package removed from every build leaves its rows behind (no surviving
|
|
61
|
+
* boot carries its authority — clean up explicitly); two builds of one package at the SAME
|
|
62
|
+
* version with differing sets (uncommitted local skew) are last-writer-wins, since versions
|
|
63
|
+
* cannot order them.
|
|
64
|
+
*/
|
|
65
|
+
async load(): Promise<SourceRecordLoadSummary> {
|
|
66
|
+
const { tables, buildSources } = await this.getDeclarations();
|
|
18
67
|
const db = getDbAsSystem();
|
|
19
|
-
|
|
20
|
-
|
|
68
|
+
const summary: SourceRecordLoadSummary = {};
|
|
69
|
+
for (const tableName in tables) {
|
|
70
|
+
const { table, records } = tables[tableName];
|
|
21
71
|
// 'id' unless the table declares a natural key (validated: unique-indexed, present and
|
|
22
72
|
// unambiguous across declarations).
|
|
23
|
-
const keyProperty = this.validateSyncKey(
|
|
24
|
-
|
|
25
|
-
|
|
73
|
+
const keyProperty = this.validateSyncKey(
|
|
74
|
+
table,
|
|
75
|
+
records.map(({ record }) => record)
|
|
76
|
+
);
|
|
77
|
+
// The exclusion set is the UNION of every key the build declares for the table: a key
|
|
78
|
+
// declared by ANY package in this build is re-owned by the stamp leg, never pruned (a
|
|
79
|
+
// declaration moving between packages within one build must not transit a delete+re-insert).
|
|
80
|
+
const allDeclaredKeys = records.map(({ record }) => (record as any)[keyProperty]);
|
|
81
|
+
const removed = await this.reconcileRemoved(db, table, keyProperty, buildSources, allDeclaredKeys);
|
|
26
82
|
|
|
27
83
|
let insertCount = 0;
|
|
28
84
|
let updateCount = 0;
|
|
29
85
|
let unchangedCount = 0;
|
|
30
86
|
let adoptedCount = 0;
|
|
31
|
-
|
|
87
|
+
let skippedNewer = removed.skippedNewer;
|
|
88
|
+
for (const { source, record } of records) {
|
|
89
|
+
let sourceRecord = record;
|
|
32
90
|
sourceRecord.isLoadedFromSource = true;
|
|
91
|
+
// Ownership stamp: the declaring package (and its version) claims the row. Stamped
|
|
92
|
+
// before the drift comparison so pre-existing rows (including pre-source_package legacy
|
|
93
|
+
// rows and rows whose declaration moved packages) converge to the current owner on
|
|
94
|
+
// their next boot.
|
|
95
|
+
sourceRecord.sourcePackage = source;
|
|
96
|
+
const sourceVersion = this.sourceVersion(source);
|
|
97
|
+
if (sourceVersion !== undefined) {
|
|
98
|
+
sourceRecord.sourcePackageVersion = sourceVersion;
|
|
99
|
+
}
|
|
33
100
|
const existingRecord = await db.get(table, { [keyProperty]: (sourceRecord as any)[keyProperty] });
|
|
34
101
|
if (existingRecord) {
|
|
102
|
+
if (
|
|
103
|
+
existingRecord.sourcePackage === source &&
|
|
104
|
+
this.isNewerStamp(existingRecord.sourcePackageVersion, sourceVersion)
|
|
105
|
+
) {
|
|
106
|
+
// A newer version of this very package already defined the row: this build's older
|
|
107
|
+
// definition does not land, and the row's stamp is not downgraded.
|
|
108
|
+
skippedNewer += 1;
|
|
109
|
+
new SourceRecordRepo().loadSourceRecord(table.name, existingRecord);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
35
113
|
if (existingRecord.id !== sourceRecord.id) {
|
|
36
114
|
// Adopt in place: the existing row keeps its id — other tables may reference it.
|
|
37
115
|
// The declared id is only ever used for fresh inserts.
|
|
@@ -64,61 +142,118 @@ export class SourceRecordLoader {
|
|
|
64
142
|
new SourceRecordRepo().loadSourceRecord(table.name, sourceRecord as any);
|
|
65
143
|
}
|
|
66
144
|
|
|
145
|
+
const unowned = await this.countUnowned(db, table, keyProperty);
|
|
146
|
+
summary[table.name] = {
|
|
147
|
+
inserts: insertCount,
|
|
148
|
+
updates: updateCount,
|
|
149
|
+
unchanged: unchangedCount,
|
|
150
|
+
adopted: adoptedCount,
|
|
151
|
+
deletes: removed.deleteCount,
|
|
152
|
+
removedUpdates: removed.removedUpdateCount,
|
|
153
|
+
skippedNewer,
|
|
154
|
+
unowned,
|
|
155
|
+
};
|
|
67
156
|
this.logger.info({
|
|
68
157
|
message: `(${table.name}) Loaded ${records.length} ${records.length == 1 ? 'record' : 'records'} from source`,
|
|
69
|
-
obj:
|
|
70
|
-
inserts: insertCount,
|
|
71
|
-
updates: updateCount,
|
|
72
|
-
unchanged: unchangedCount,
|
|
73
|
-
adopted: adoptedCount,
|
|
74
|
-
deletes: deleteCount,
|
|
75
|
-
removedUpdates: removedUpdateCount,
|
|
76
|
-
},
|
|
158
|
+
obj: summary[table.name],
|
|
77
159
|
});
|
|
78
160
|
}
|
|
161
|
+
|
|
162
|
+
return summary;
|
|
79
163
|
}
|
|
80
164
|
|
|
81
165
|
/**
|
|
82
|
-
* The removed-reconcile leg: rows
|
|
83
|
-
*
|
|
84
|
-
* `
|
|
85
|
-
*
|
|
86
|
-
* `
|
|
166
|
+
* The removed-reconcile leg: rows owned by a package this build carries, at that package's
|
|
167
|
+
* version or older, that NO package in this build still declares
|
|
168
|
+
* (`is_loaded_from_source = true AND source_package IN <build's packages> AND <key> NOT IN
|
|
169
|
+
* <build's declared keys for the table>`, minus rows stamped by a newer version of their
|
|
170
|
+
* package) are handled per the table's `onSourceRemoved` policy — delete (default), keep, or
|
|
171
|
+
* update with a patch. The update leg applies the patch only to rows whose fields actually
|
|
172
|
+
* differ (idempotent boots), through `Db.update` so table watchers observe each write.
|
|
173
|
+
* See {@link load} for the ownership model.
|
|
87
174
|
*/
|
|
88
175
|
private async reconcileRemoved(
|
|
89
176
|
db: Db,
|
|
90
177
|
table: Table<any>,
|
|
91
178
|
keyProperty: string,
|
|
92
|
-
|
|
93
|
-
|
|
179
|
+
buildSources: Set<string>,
|
|
180
|
+
allDeclaredKeys: unknown[]
|
|
181
|
+
): Promise<{ deleteCount: number; removedUpdateCount: number; skippedNewer: number }> {
|
|
94
182
|
const policy = table.sourceRecordOptions.onSourceRemoved ?? 'delete';
|
|
95
|
-
if (policy === 'keep') {
|
|
96
|
-
return { deleteCount: 0, removedUpdateCount: 0 };
|
|
183
|
+
if (policy === 'keep' || buildSources.size == 0) {
|
|
184
|
+
return { deleteCount: 0, removedUpdateCount: 0, skippedNewer: 0 };
|
|
97
185
|
}
|
|
98
186
|
|
|
99
187
|
const qb = QueryBuilder.fromObject<SourceRecord>({ isLoadedFromSource: true }, table.name);
|
|
100
|
-
|
|
101
|
-
|
|
188
|
+
qb.condition({ field: 'sourcePackage', operator: 'IN', value: Array.from(buildSources) as any });
|
|
189
|
+
if (allDeclaredKeys.length > 0) {
|
|
190
|
+
qb.condition({ field: keyProperty as any, operator: 'NOT IN', value: allDeclaredKeys as any });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const candidates: SourceRecord[] = await db.query(table, qb);
|
|
194
|
+
const stampedNewer = (candidate: SourceRecord) =>
|
|
195
|
+
this.isNewerStamp(candidate.sourcePackageVersion, this.sourceVersion(candidate.sourcePackage as string));
|
|
196
|
+
const removedRecords = candidates.filter((candidate) => !stampedNewer(candidate));
|
|
197
|
+
const skippedNewer = candidates.length - removedRecords.length;
|
|
198
|
+
if (skippedNewer > 0) {
|
|
199
|
+
this.logger.info({
|
|
200
|
+
message: `(${table.name}) Left ${skippedNewer} record${skippedNewer == 1 ? '' : 's'} stamped by a newer version of ${skippedNewer == 1 ? 'its' : 'their'} package — not treated as removed`,
|
|
201
|
+
obj: { [keyProperty]: candidates.filter(stampedNewer).map((candidate) => (candidate as any)[keyProperty]) },
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (removedRecords.length == 0) {
|
|
206
|
+
return { deleteCount: 0, removedUpdateCount: 0, skippedNewer };
|
|
102
207
|
}
|
|
103
208
|
|
|
104
209
|
if (policy === 'delete') {
|
|
105
|
-
|
|
210
|
+
const deleteQb = QueryBuilder.fromObject<SourceRecord>({ isLoadedFromSource: true }, table.name);
|
|
211
|
+
deleteQb.condition({ field: 'id', operator: 'IN', value: removedRecords.map((record) => record.id) });
|
|
212
|
+
return { deleteCount: await db.delete(table, deleteQb), removedUpdateCount: 0, skippedNewer };
|
|
106
213
|
}
|
|
107
214
|
|
|
108
215
|
let removedUpdateCount = 0;
|
|
109
|
-
const removedRecords = await db.query(table, qb);
|
|
110
216
|
for (const removedRecord of removedRecords) {
|
|
111
217
|
if (await this.hasChanges(table, policy.update, removedRecord)) {
|
|
112
218
|
await db.update(table, { id: removedRecord.id, ...policy.update });
|
|
113
219
|
removedUpdateCount += 1;
|
|
114
220
|
this.logger.info({
|
|
115
221
|
message: `(${table.name}) Applied onSourceRemoved update to record removed from source`,
|
|
116
|
-
obj: {
|
|
222
|
+
obj: {
|
|
223
|
+
id: removedRecord.id,
|
|
224
|
+
[keyProperty]: (removedRecord as any)[keyProperty],
|
|
225
|
+
source: removedRecord.sourcePackage,
|
|
226
|
+
},
|
|
117
227
|
});
|
|
118
228
|
}
|
|
119
229
|
}
|
|
120
230
|
|
|
121
|
-
return { deleteCount: 0, removedUpdateCount };
|
|
231
|
+
return { deleteCount: 0, removedUpdateCount, skippedNewer };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Source-loaded rows with no owner stamp that no declaration in this build claimed on this
|
|
236
|
+
* boot (the stamp leg has already run). They predate `source_package`, or their declaring
|
|
237
|
+
* package is not in this build; nobody prunes them. Reported so they are visible, never acted
|
|
238
|
+
* on. Tables that keep removed rows (`onSourceRemoved: 'keep'`) opted out of removal
|
|
239
|
+
* semantics altogether, so there is nothing to explain there.
|
|
240
|
+
*/
|
|
241
|
+
private async countUnowned(db: Db, table: Table<any>, keyProperty: string): Promise<number> {
|
|
242
|
+
if ((table.sourceRecordOptions.onSourceRemoved ?? 'delete') === 'keep') {
|
|
243
|
+
return 0;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const qb = QueryBuilder.fromObject<SourceRecord>({ isLoadedFromSource: true }, table.name);
|
|
247
|
+
qb.condition({ field: 'sourcePackage', operator: 'IS NULL' });
|
|
248
|
+
const unowned: SourceRecord[] = await db.query(table, qb);
|
|
249
|
+
if (unowned.length > 0) {
|
|
250
|
+
this.logger.warn({
|
|
251
|
+
message: `(${table.name}) ${unowned.length} source-loaded ${unowned.length == 1 ? 'record has' : 'records have'} no owning package and no declaration in this build — left untouched`,
|
|
252
|
+
obj: { [keyProperty]: unowned.map((record) => (record as any)[keyProperty]) },
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return unowned.length;
|
|
122
257
|
}
|
|
123
258
|
|
|
124
259
|
/**
|
|
@@ -205,28 +340,27 @@ export class SourceRecordLoader {
|
|
|
205
340
|
return false;
|
|
206
341
|
}
|
|
207
342
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
343
|
+
/**
|
|
344
|
+
* Every source-record table in this build with the records declared for it, plus the set of
|
|
345
|
+
* packages that declare ANY source record in this build — the packages this boot speaks for.
|
|
346
|
+
*/
|
|
347
|
+
private async getDeclarations(): Promise<{ tables: SourceRecordsMap; buildSources: Set<string> }> {
|
|
348
|
+
const tables: SourceRecordsMap = {};
|
|
349
|
+
for (const table of getSourceRecordTables()) {
|
|
350
|
+
tables[table.name] = { table, records: [] };
|
|
215
351
|
}
|
|
216
352
|
|
|
217
|
-
const
|
|
218
|
-
for (const
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
records: [],
|
|
223
|
-
};
|
|
353
|
+
const buildSources = new Set<string>();
|
|
354
|
+
for (const { source, loader } of getSourceRecordLoaders()) {
|
|
355
|
+
buildSources.add(source);
|
|
356
|
+
if (!tables[loader.table.name]) {
|
|
357
|
+
tables[loader.table.name] = { table: loader.table, records: [] };
|
|
224
358
|
}
|
|
225
359
|
|
|
226
|
-
|
|
360
|
+
tables[loader.table.name].records.push({ source, record: loader.record });
|
|
227
361
|
}
|
|
228
362
|
|
|
229
|
-
return
|
|
363
|
+
return { tables, buildSources };
|
|
230
364
|
}
|
|
231
365
|
|
|
232
366
|
/**
|
|
@@ -323,4 +457,155 @@ export class SourceRecordLoader {
|
|
|
323
457
|
.sort();
|
|
324
458
|
return '{' + keys.map((k) => JSON.stringify(k) + ':' + this.canonicalStringify(obj[k])).join(',') + '}';
|
|
325
459
|
}
|
|
460
|
+
|
|
461
|
+
/** Memoized {@link resolveSourceVersion} — one resolution (and at most one warning) per source. */
|
|
462
|
+
private sourceVersion(source: string): string | undefined {
|
|
463
|
+
if (!this.sourceVersions.has(source)) {
|
|
464
|
+
this.sourceVersions.set(source, this.resolveSourceVersion(source));
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
return this.sourceVersions.get(source);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* The declaring package's version, read from its own package.json at runtime. Resolution runs
|
|
472
|
+
* from the process cwd — the booting server's package, the one dependency tree every declaring
|
|
473
|
+
* package is reachable from (`@proteinjs/db` itself does not depend on the packages that
|
|
474
|
+
* declare records, so module-relative resolution could never find them). The package's entry
|
|
475
|
+
* is resolved (honoring exports maps, where `<pkg>/package.json` is usually not requireable),
|
|
476
|
+
* then the nearest package.json whose `name` matches is read walking up from it.
|
|
477
|
+
*
|
|
478
|
+
* Returns undefined where resolution is impossible — no Node `require` (browser bundles), or a
|
|
479
|
+
* package not resolvable from cwd. The sync then has no place in the version order for that
|
|
480
|
+
* package: it never touches its version-stamped rows, and what it writes is unversioned
|
|
481
|
+
* (see {@link isNewerStamp}). Logged once per boot so a misconfigured cwd is visible.
|
|
482
|
+
*/
|
|
483
|
+
private resolveSourceVersion(source: string): string | undefined {
|
|
484
|
+
// The ambient CJS `require` — the only require carrying `.resolve` (module.require does
|
|
485
|
+
// not). Every use below goes through the variable, so bundlers never see a statically
|
|
486
|
+
// analyzable `require(...)` call; in a browser bundle the runtime attempts throw into
|
|
487
|
+
// their catches and the method degrades to undefined.
|
|
488
|
+
const nodeRequire: NodeRequire | undefined =
|
|
489
|
+
typeof require === 'function' && typeof require.resolve === 'function' ? require : undefined;
|
|
490
|
+
const cwd = typeof process !== 'undefined' && typeof process.cwd === 'function' ? process.cwd() : undefined;
|
|
491
|
+
if (nodeRequire && cwd) {
|
|
492
|
+
try {
|
|
493
|
+
const entryPath = nodeRequire.resolve(source, { paths: [cwd] });
|
|
494
|
+
const path = nodeRequire('path');
|
|
495
|
+
const fs = nodeRequire('fs');
|
|
496
|
+
// Walk up from the entry file to the package's own package.json (the name check skips
|
|
497
|
+
// nested stubs like a dist/package.json); stop at the filesystem root.
|
|
498
|
+
for (let directory = path.dirname(entryPath); ; directory = path.dirname(directory)) {
|
|
499
|
+
const candidate = path.join(directory, 'package.json');
|
|
500
|
+
if (fs.existsSync(candidate)) {
|
|
501
|
+
const packageJson = JSON.parse(fs.readFileSync(candidate, 'utf8'));
|
|
502
|
+
if (packageJson?.name === source && typeof packageJson.version === 'string') {
|
|
503
|
+
return packageJson.version;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if (path.dirname(directory) === directory) {
|
|
508
|
+
break;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
} catch (error) {
|
|
512
|
+
// Unresolvable from cwd — reported below.
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
this.logger.warn({
|
|
517
|
+
message: `Could not resolve a version for source package '${source}' from '${cwd}' — its records sync without version ordering (this build never touches its version-stamped rows)`,
|
|
518
|
+
});
|
|
519
|
+
return undefined;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Whether a row's version stamp is strictly newer than the reconciling build's version of the
|
|
524
|
+
* same package — the guard that makes version skew of one package safe on a shared database:
|
|
525
|
+
* an older build never prunes (or flags) rows a newer build of the same package declared.
|
|
526
|
+
*
|
|
527
|
+
* Unversioned stamps (NULL — legacy rows, or builds whose version could not be resolved)
|
|
528
|
+
* carry no ordering and stay reconcilable (last-writer-wins, the pre-version behavior). A
|
|
529
|
+
* build whose OWN version is unresolvable cannot place itself in the order, so it never
|
|
530
|
+
* touches a version-stamped row — conservative: prefer leaving a removed row behind over
|
|
531
|
+
* deleting one that might be newer.
|
|
532
|
+
*/
|
|
533
|
+
private isNewerStamp(stampedVersion: string | null | undefined, reconcilingVersion: string | undefined): boolean {
|
|
534
|
+
if (stampedVersion == null) {
|
|
535
|
+
return false;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if (reconcilingVersion == null) {
|
|
539
|
+
return true;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const comparison = this.compareVersions(stampedVersion, reconcilingVersion);
|
|
543
|
+
return comparison === undefined ? true : comparison > 0;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Semver comparison (major.minor.patch, prerelease-aware): negative when a < b, 0 when equal,
|
|
548
|
+
* positive when a > b, and undefined when either side does not parse as semver (unorderable —
|
|
549
|
+
* the caller treats that conservatively).
|
|
550
|
+
*/
|
|
551
|
+
private compareVersions(a: string, b: string): number | undefined {
|
|
552
|
+
const parse = (version: string): { main: number[]; prerelease?: string[] } | undefined => {
|
|
553
|
+
const match = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?/.exec(version.trim());
|
|
554
|
+
if (!match) {
|
|
555
|
+
return undefined;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
return { main: [Number(match[1]), Number(match[2]), Number(match[3])], prerelease: match[4]?.split('.') };
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
const parsedA = parse(a);
|
|
562
|
+
const parsedB = parse(b);
|
|
563
|
+
if (!parsedA || !parsedB) {
|
|
564
|
+
return undefined;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
for (let i = 0; i < 3; i++) {
|
|
568
|
+
if (parsedA.main[i] !== parsedB.main[i]) {
|
|
569
|
+
return parsedA.main[i] - parsedB.main[i];
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
if (!parsedA.prerelease && !parsedB.prerelease) {
|
|
574
|
+
return 0;
|
|
575
|
+
}
|
|
576
|
+
if (!parsedA.prerelease) {
|
|
577
|
+
return 1; // a release outranks any prerelease of the same triple
|
|
578
|
+
}
|
|
579
|
+
if (!parsedB.prerelease) {
|
|
580
|
+
return -1;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const length = Math.max(parsedA.prerelease.length, parsedB.prerelease.length);
|
|
584
|
+
for (let i = 0; i < length; i++) {
|
|
585
|
+
const identifierA = parsedA.prerelease[i];
|
|
586
|
+
const identifierB = parsedB.prerelease[i];
|
|
587
|
+
if (identifierA === undefined) {
|
|
588
|
+
return -1; // fewer identifiers sorts first when all shared ones are equal
|
|
589
|
+
}
|
|
590
|
+
if (identifierB === undefined) {
|
|
591
|
+
return 1;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const numericA = /^\d+$/.test(identifierA) ? Number(identifierA) : undefined;
|
|
595
|
+
const numericB = /^\d+$/.test(identifierB) ? Number(identifierB) : undefined;
|
|
596
|
+
if (numericA !== undefined && numericB !== undefined) {
|
|
597
|
+
if (numericA !== numericB) {
|
|
598
|
+
return numericA - numericB;
|
|
599
|
+
}
|
|
600
|
+
} else if (numericA !== undefined) {
|
|
601
|
+
return -1; // numeric identifiers sort before alphanumeric ones
|
|
602
|
+
} else if (numericB !== undefined) {
|
|
603
|
+
return 1;
|
|
604
|
+
} else if (identifierA !== identifierB) {
|
|
605
|
+
return identifierA < identifierB ? -1 : 1;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
return 0;
|
|
610
|
+
}
|
|
326
611
|
}
|