@proteinjs/db 1.47.3 → 1.49.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/.eslintrc.js +1 -1
- package/.prettierignore +2 -1
- package/CHANGELOG.md +37 -0
- package/dist/generated/index.d.ts.map +1 -1
- package/dist/generated/index.js +1 -3
- package/dist/generated/index.js.map +1 -1
- package/dist/generated/test/index.d.ts.map +1 -1
- package/dist/generated/test/index.js +1 -3
- package/dist/generated/test/index.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -5
- package/dist/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 +2 -2
- package/dist/src/Db.js.map +1 -1
- package/dist/src/MigrationRunner.js +2 -2
- package/dist/src/MigrationRunner.js.map +1 -1
- package/dist/src/Table.d.ts +16 -1
- package/dist/src/Table.d.ts.map +1 -1
- package/dist/src/Table.js +14 -0
- package/dist/src/Table.js.map +1 -1
- package/dist/src/auth/TableAuth.d.ts +4 -0
- package/dist/src/auth/TableAuth.d.ts.map +1 -1
- package/dist/src/auth/TableAuth.js +12 -0
- package/dist/src/auth/TableAuth.js.map +1 -1
- package/dist/src/auth/TableServiceAuth.d.ts.map +1 -1
- package/dist/src/auth/TableServiceAuth.js +5 -0
- package/dist/src/auth/TableServiceAuth.js.map +1 -1
- package/dist/src/schema/SchemaOperations.d.ts +4 -3
- package/dist/src/schema/SchemaOperations.d.ts.map +1 -1
- package/dist/src/schema/TableManager.d.ts +24 -6
- package/dist/src/schema/TableManager.d.ts.map +1 -1
- package/dist/src/schema/TableManager.js +93 -26
- package/dist/src/schema/TableManager.js.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 +2 -2
- package/dist/src/source/{SourceRecordLoader.d.ts → SourceRecordSyncRunner.d.ts} +15 -7
- package/dist/src/source/SourceRecordSyncRunner.d.ts.map +1 -0
- package/dist/src/source/{SourceRecordLoader.js → SourceRecordSyncRunner.js} +46 -31
- package/dist/src/source/SourceRecordSyncRunner.js.map +1 -0
- package/dist/src/tables/MigrationTable.d.ts +10 -2
- package/dist/src/tables/MigrationTable.d.ts.map +1 -1
- package/dist/src/tables/MigrationTable.js +12 -4
- package/dist/src/tables/MigrationTable.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/DurableTable.test.d.ts +2 -0
- package/dist/test/DurableTable.test.d.ts.map +1 -0
- package/dist/test/DurableTable.test.js +172 -0
- package/dist/test/DurableTable.test.js.map +1 -0
- package/dist/test/MigrationAuth.test.js +12 -9
- package/dist/test/MigrationAuth.test.js.map +1 -1
- package/dist/test/SchemaReconcileLines.test.d.ts +2 -0
- package/dist/test/SchemaReconcileLines.test.d.ts.map +1 -0
- package/dist/test/SchemaReconcileLines.test.js +176 -0
- package/dist/test/SchemaReconcileLines.test.js.map +1 -0
- package/dist/test/SourceRecordLoaderQualifiedName.test.d.ts +2 -0
- package/dist/test/SourceRecordLoaderQualifiedName.test.d.ts.map +1 -0
- package/dist/test/SourceRecordLoaderQualifiedName.test.js +79 -0
- package/dist/test/SourceRecordLoaderQualifiedName.test.js.map +1 -0
- package/dist/test/SourceRecordSyncRunner.test.d.ts +2 -0
- package/dist/test/SourceRecordSyncRunner.test.d.ts.map +1 -0
- package/dist/test/{SourceRecordLoader.test.js → SourceRecordSyncRunner.test.js} +63 -7
- package/dist/test/SourceRecordSyncRunner.test.js.map +1 -0
- package/dist/test/TableManagerCreateOrder.test.d.ts +2 -0
- package/dist/test/TableManagerCreateOrder.test.d.ts.map +1 -0
- package/dist/test/TableManagerCreateOrder.test.js +66 -0
- package/dist/test/TableManagerCreateOrder.test.js.map +1 -0
- package/dist/test/reusable/SourceRecordSyncTests.d.ts.map +1 -1
- package/dist/test/reusable/SourceRecordSyncTests.js +3 -5
- package/dist/test/reusable/SourceRecordSyncTests.js.map +1 -1
- package/dist/test/reusable/TableManagerTests.d.ts.map +1 -1
- package/dist/test/reusable/TableManagerTests.js +180 -0
- package/dist/test/reusable/TableManagerTests.js.map +1 -1
- package/generated/index.ts +20 -19
- package/generated/test/index.ts +54 -53
- package/index.ts +3 -4
- package/package.json +4 -4
- package/src/Db.ts +11 -2
- package/src/MigrationRunner.ts +2 -2
- package/src/Table.ts +16 -1
- package/src/auth/TableAuth.ts +14 -0
- package/src/auth/TableServiceAuth.ts +6 -1
- package/src/schema/SchemaOperations.ts +4 -3
- package/src/schema/TableManager.ts +84 -26
- package/src/services/TransactionRunnerService.ts +21 -1
- package/src/source/SourceRecord.ts +2 -2
- package/src/source/{SourceRecordLoader.ts → SourceRecordSyncRunner.ts} +31 -13
- package/src/tables/MigrationTable.ts +12 -4
- package/src/transaction/Transaction.ts +11 -2
- package/src/transaction/TransactionRunner.ts +174 -3
- package/test/DurableTable.test.ts +156 -0
- package/test/MigrationAuth.test.ts +7 -5
- package/test/SchemaReconcileLines.test.ts +127 -0
- package/test/SourceRecordLoaderQualifiedName.test.ts +63 -0
- package/test/{SourceRecordLoader.test.ts → SourceRecordSyncRunner.test.ts} +75 -8
- package/test/TableManagerCreateOrder.test.ts +78 -0
- package/test/reusable/SourceRecordSyncTests.ts +3 -5
- package/test/reusable/TableManagerTests.ts +95 -0
- package/dist/src/source/SourceRecordLoader.d.ts.map +0 -1
- package/dist/src/source/SourceRecordLoader.js.map +0 -1
- package/dist/test/SourceRecordLoader.test.d.ts +0 -2
- package/dist/test/SourceRecordLoader.test.d.ts.map +0 -1
- package/dist/test/SourceRecordLoader.test.js.map +0 -1
package/index.ts
CHANGED
|
@@ -15,10 +15,9 @@ export * from './src/RecordIterator';
|
|
|
15
15
|
export * from './src/CursorWindowPager';
|
|
16
16
|
export * from './src/source/SourceRecord';
|
|
17
17
|
export * from './src/source/SourceRecordRepo';
|
|
18
|
-
// The sync runner behind Db.init's source-record leg
|
|
19
|
-
//
|
|
20
|
-
|
|
21
|
-
export { SourceRecordLoader as SourceRecordSyncRunner } from './src/source/SourceRecordLoader';
|
|
18
|
+
// The sync runner behind Db.init's source-record leg. Public so consumer test harnesses can run
|
|
19
|
+
// one boot of the sync directly (the exports map blocks deep dist imports).
|
|
20
|
+
export { SourceRecordSyncRunner } from './src/source/SourceRecordSyncRunner';
|
|
22
21
|
export * from './src/MigrationRunner';
|
|
23
22
|
export * from './src/QueryBuilderFactory';
|
|
24
23
|
export * from './src/TableWatcher';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@proteinjs/db",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.49.0",
|
|
4
4
|
"main": "./dist/generated/index.js",
|
|
5
5
|
"types": "./dist/generated/index.d.ts",
|
|
6
6
|
"exports": {
|
|
@@ -42,11 +42,11 @@
|
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
44
|
"@proteinjs/db-query": "^1.10.1",
|
|
45
|
-
"@proteinjs/logger": "^1.0
|
|
45
|
+
"@proteinjs/logger": "^1.2.0",
|
|
46
46
|
"@proteinjs/reflection": "^1.2.0",
|
|
47
47
|
"@proteinjs/serializer": "^1.1.10",
|
|
48
48
|
"@proteinjs/server-api": "^3.0.11",
|
|
49
|
-
"@proteinjs/service": "^1.
|
|
49
|
+
"@proteinjs/service": "^1.6.0",
|
|
50
50
|
"@proteinjs/user-auth": "^1.3.0",
|
|
51
51
|
"@proteinjs/util": "^1.6.1",
|
|
52
52
|
"moment": "2.29.4",
|
|
@@ -66,5 +66,5 @@
|
|
|
66
66
|
"ts-jest": "29.1.1",
|
|
67
67
|
"typescript": "5.2.2"
|
|
68
68
|
},
|
|
69
|
-
"gitHead": "
|
|
69
|
+
"gitHead": "5ef7c5a2f3eb6ef3b774aa93cc74cc1754cfa011"
|
|
70
70
|
}
|
package/src/Db.ts
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
import { ReverseCascadeEdgeIndex } from './ReverseCascadeEdgeIndex';
|
|
13
13
|
import { Record, RecordSerializer, SerializedRecord } from './Record';
|
|
14
14
|
import { Logger } from '@proteinjs/logger';
|
|
15
|
-
import {
|
|
15
|
+
import { SourceRecordSyncRunner } from './source/SourceRecordSyncRunner';
|
|
16
16
|
import { ParameterizationConfig, ParamType, QueryBuilder, Statement, StatementFactory } from '@proteinjs/db-query';
|
|
17
17
|
import { QueryBuilderFactory } from './QueryBuilderFactory';
|
|
18
18
|
import { StatementConfigFactory } from './StatementConfigFactory';
|
|
@@ -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> {
|
|
@@ -173,7 +182,7 @@ export class Db<R extends Record = Record> implements DbService<R> {
|
|
|
173
182
|
const { MigrationRunner } = await import('./MigrationRunner');
|
|
174
183
|
await new MigrationRunner().runPreSchemaSyncMigrations(this.dbDriver.getTableManager());
|
|
175
184
|
await this.dbDriver.getTableManager().loadTables();
|
|
176
|
-
await new
|
|
185
|
+
await new SourceRecordSyncRunner().load();
|
|
177
186
|
}
|
|
178
187
|
|
|
179
188
|
async tableExists<T extends R>(table: Table<T>): Promise<boolean> {
|
package/src/MigrationRunner.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { Moment, moment } from './opt/moment';
|
|
|
2
2
|
import { Db, getDb, getDbAsSystem } from './Db';
|
|
3
3
|
import { Table } from './Table';
|
|
4
4
|
import { SourceRecordRepo } from './source/SourceRecordRepo';
|
|
5
|
-
import {
|
|
5
|
+
import { SourceRecordSyncRunner } from './source/SourceRecordSyncRunner';
|
|
6
6
|
import { getSourceRecordLoaders } from './source/SourceRecord';
|
|
7
7
|
import { MigrationRunnerService, getMigrationRunnerService } from './services/MigrationRunnerService';
|
|
8
8
|
import { Migration, MigrationTable } from './tables/MigrationTable';
|
|
@@ -139,7 +139,7 @@ export class MigrationRunner implements MigrationRunnerService {
|
|
|
139
139
|
}
|
|
140
140
|
|
|
141
141
|
await tableManager.loadTable(migrationTable);
|
|
142
|
-
await new
|
|
142
|
+
await new SourceRecordSyncRunner().load(migrationTable);
|
|
143
143
|
this.logger.info({
|
|
144
144
|
message: `Running ${flagged.length} pre-schema-sync migration${flagged.length === 1 ? '' : 's'} before schema sync`,
|
|
145
145
|
obj: { ids: flagged.map((migration) => migration.id) },
|
package/src/Table.ts
CHANGED
|
@@ -140,6 +140,20 @@ export abstract class Table<T extends Record> implements Loadable, CustomSeriali
|
|
|
140
140
|
public cascadeDeleteReferences: () => { table: string; referenceColumn: string }[] = () => [];
|
|
141
141
|
/** Options for configuring SourceRecords (see {@link SourceRecordOptions}) */
|
|
142
142
|
public sourceRecordOptions: SourceRecordOptions<T> = {};
|
|
143
|
+
/**
|
|
144
|
+
* A DURABLE table keeps its rows: no caller deletes them. The delete door is closed on both apis
|
|
145
|
+
* for everyone — break-glass included, whatever `auth` declares — and refuses with a 403
|
|
146
|
+
* `ServiceRefusal` naming the table (`TableAuth.canDelete`). The generic record surfaces derive
|
|
147
|
+
* the same verdict: the record table's seat carries no delete act (a declared `delete` action
|
|
148
|
+
* included), the record form no Delete.
|
|
149
|
+
*
|
|
150
|
+
* Declare it on ledgers — tables whose rows are the record of what happened (the migration
|
|
151
|
+
* ledger's runs, audit and usage events), where a deleted row is history lost. The platform's
|
|
152
|
+
* own system-context code (`getDbAsSystem`) is outside the doors here as everywhere: a durable
|
|
153
|
+
* table's rows leave only by a lifecycle the platform itself declares, or by hand in the
|
|
154
|
+
* database's own console.
|
|
155
|
+
*/
|
|
156
|
+
public durable = false;
|
|
143
157
|
/**
|
|
144
158
|
* Presentation the table declares for the GENERIC record surfaces (@proteinjs/db-ui) — the
|
|
145
159
|
* framework renders what tables declare; per-table display choices never hard-code into the
|
|
@@ -178,7 +192,8 @@ export abstract class Table<T extends Record> implements Loadable, CustomSeriali
|
|
|
178
192
|
* insert doors, delete-on-selection from the delete doors — exactly as every table did
|
|
179
193
|
* before this declaration existed. A declared entry CONFIGURES the act of its kind (door
|
|
180
194
|
* and/or label); kinds left undeclared keep the derivation. The seat, not the table, owns
|
|
181
|
-
* placement: every declared act renders in the same seat on desktop and phone.
|
|
195
|
+
* placement: every declared act renders in the same seat on desktop and phone. A
|
|
196
|
+
* {@link Table.durable} table's seat carries no delete act, declared or derived.
|
|
182
197
|
*/
|
|
183
198
|
actions?: RecordTableAction[];
|
|
184
199
|
};
|
package/src/auth/TableAuth.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { UserAuth } from '@proteinjs/user-auth';
|
|
2
|
+
import { ServiceRefusal } from '@proteinjs/service';
|
|
2
3
|
import { Table } from '../Table';
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -100,7 +101,15 @@ export class TableAuth {
|
|
|
100
101
|
}
|
|
101
102
|
}
|
|
102
103
|
|
|
104
|
+
/**
|
|
105
|
+
* A durable table's delete door is refused before any identity is read (`Table.durable`): a 403
|
|
106
|
+
* refusal naming the table, for every caller on both apis — no declared door reopens it.
|
|
107
|
+
*/
|
|
103
108
|
canDelete(table: Table<any>, api: 'db' | 'service' = 'db'): void {
|
|
109
|
+
if (table.durable) {
|
|
110
|
+
throw new ServiceRefusal(403, `Table ${table.name} is durable: its rows are never deleted`);
|
|
111
|
+
}
|
|
112
|
+
|
|
104
113
|
if (!this.canAccess(table, api, 'delete')) {
|
|
105
114
|
throw new TableAuthError(`User is not authorized to delete records from table: ${table.name}`);
|
|
106
115
|
}
|
|
@@ -111,6 +120,11 @@ export class TableAuth {
|
|
|
111
120
|
api: 'db' | 'service',
|
|
112
121
|
operation: 'query' | 'insert' | 'update' | 'delete'
|
|
113
122
|
): boolean {
|
|
123
|
+
// A durable table has no delete door for anyone (see `canDelete`); the capability read agrees.
|
|
124
|
+
if (operation === 'delete' && table.durable) {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
|
|
114
128
|
const tableAuth = table.auth ? table.auth[api] : undefined;
|
|
115
129
|
if (!tableAuth || Object.keys(tableAuth).length == 0) {
|
|
116
130
|
return UserAuth.hasRole('admin');
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Logger } from '@proteinjs/logger';
|
|
2
|
-
import { ServiceError } from '@proteinjs/service';
|
|
2
|
+
import { ServiceError, ServiceRefusal } from '@proteinjs/service';
|
|
3
3
|
import { Table, isTable } from '../Table';
|
|
4
4
|
import { TableAuth, TableAuthError } from './TableAuth';
|
|
5
5
|
|
|
@@ -37,6 +37,11 @@ export class TableServiceAuth {
|
|
|
37
37
|
throw new Error(`User is not authorized to access unsupported Db service api: ${methodName}`);
|
|
38
38
|
}
|
|
39
39
|
} catch (error: any) {
|
|
40
|
+
// A deliberate refusal (a durable table's delete door) reaches the caller as it is: the
|
|
41
|
+
// router answers with its status and message.
|
|
42
|
+
if (ServiceRefusal.is(error)) {
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
40
45
|
// A protected-column rejection carries a client-safe message — let it surface as the 400
|
|
41
46
|
// body instead of collapsing into the generic authorization failure. Name check, not
|
|
42
47
|
// instanceof: `ServiceError extends Error` loses its prototype chain under the service
|
|
@@ -3,9 +3,10 @@ import { Table } from '../Table';
|
|
|
3
3
|
export interface SchemaOperations {
|
|
4
4
|
/**
|
|
5
5
|
* Create every table in `tables`, in the given order. The order is load-bearing: a table whose
|
|
6
|
-
* foreign keys reference another absent table must appear after it
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* foreign keys reference another absent table must appear after it — `TableManager` owns that
|
|
7
|
+
* order and hands the set over already sorted by reference, so drivers never re-order. Drivers
|
|
8
|
+
* that support batched DDL (Spanner) apply the whole set as ONE schema-update operation —
|
|
9
|
+
* statements inside a batch apply in order, so the ordering contract is preserved.
|
|
9
10
|
*/
|
|
10
11
|
createTables(tables: Table<any>[]): Promise<void>;
|
|
11
12
|
alterTable(table: Table<any>, changes: TableChanges): Promise<void>;
|
|
@@ -72,9 +72,10 @@ export class TableManager {
|
|
|
72
72
|
/**
|
|
73
73
|
* Reconcile every registered table with the live schema. Absent tables are created as ONE
|
|
74
74
|
* batch (a single schema-update operation on drivers that support it — the prod-boot win),
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
75
|
+
* ordered by their foreign-key references ({@link orderByReferences}) so each table's foreign
|
|
76
|
+
* keys resolve against tables created before it — the registry's enumeration order carries no
|
|
77
|
+
* such guarantee; existing tables are altered individually after, so an alter that adds a
|
|
78
|
+
* foreign key to a just-created table sees it live.
|
|
78
79
|
*/
|
|
79
80
|
async loadTables(): Promise<void> {
|
|
80
81
|
const tables = this.prepareTablesForLoad(getTables());
|
|
@@ -90,13 +91,14 @@ export class TableManager {
|
|
|
90
91
|
}
|
|
91
92
|
|
|
92
93
|
if (absentTables.length > 0) {
|
|
93
|
-
|
|
94
|
+
const tablesToCreate = this.orderByReferences(absentTables);
|
|
95
|
+
this.logger.info({ message: `Creating tables: ${tablesToCreate.map((table) => table.name).join(', ')}` });
|
|
94
96
|
try {
|
|
95
|
-
await this.schemaOperations.createTables(
|
|
97
|
+
await this.schemaOperations.createTables(tablesToCreate);
|
|
96
98
|
} catch (error) {
|
|
97
|
-
await this.reconcileConcurrentSchemaChange(
|
|
99
|
+
await this.reconcileConcurrentSchemaChange(tablesToCreate, error);
|
|
98
100
|
}
|
|
99
|
-
this.logger.info({ message: `Finished creating ${
|
|
101
|
+
this.logger.info({ message: `Finished creating ${tablesToCreate.length} tables` });
|
|
100
102
|
}
|
|
101
103
|
|
|
102
104
|
for (const table of existingTables) {
|
|
@@ -152,6 +154,69 @@ export class TableManager {
|
|
|
152
154
|
return prepared;
|
|
153
155
|
}
|
|
154
156
|
|
|
157
|
+
/**
|
|
158
|
+
* The order to create `tables` in: every table after the tables its foreign keys reference
|
|
159
|
+
* (`ColumnOptions.references`), so each foreign key a CREATE declares resolves against a table
|
|
160
|
+
* already created — one statement batch or one statement at a time alike. Tables are grouped by
|
|
161
|
+
* rank (0: references no other table in the set; otherwise one more than the highest rank it
|
|
162
|
+
* references) and keep their given order within a rank, so a deterministic input gives a
|
|
163
|
+
* deterministic output. A reference to the table itself, or to a table outside the set (already
|
|
164
|
+
* created, or not registered), does not constrain the order. A cycle of references cannot be
|
|
165
|
+
* created in any order: it is refused, naming the cycle and every table it blocks.
|
|
166
|
+
*/
|
|
167
|
+
private orderByReferences(tables: Table<any>[]): Table<any>[] {
|
|
168
|
+
const tableNames = new Set(tables.map((table) => table.name));
|
|
169
|
+
const referencedTables = new Map<string, string[]>();
|
|
170
|
+
for (const table of tables) {
|
|
171
|
+
const referenced = new Set<string>();
|
|
172
|
+
for (const column of Object.values(table.columns)) {
|
|
173
|
+
const referencedTable = column.options?.references?.table;
|
|
174
|
+
if (referencedTable && referencedTable !== table.name && tableNames.has(referencedTable)) {
|
|
175
|
+
referenced.add(referencedTable);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
referencedTables.set(table.name, Array.from(referenced));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const ordered: Table<any>[] = [];
|
|
182
|
+
const created = new Set<string>();
|
|
183
|
+
let remaining = tables;
|
|
184
|
+
while (remaining.length > 0) {
|
|
185
|
+
// One rank: every remaining table whose references were all created by EARLIER ranks.
|
|
186
|
+
const rank = remaining.filter((table) => referencedTables.get(table.name)!.every((name) => created.has(name)));
|
|
187
|
+
if (rank.length === 0) {
|
|
188
|
+
throw new Error(this.referenceCycleMessage(remaining, referencedTables));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
ordered.push(...rank);
|
|
192
|
+
rank.forEach((table) => created.add(table.name));
|
|
193
|
+
remaining = remaining.filter((table) => !created.has(table.name));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return ordered;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Every table in `blocked` references another table in `blocked`, so walking those references
|
|
201
|
+
* from any of them must revisit a table: the walk from the first yields one cycle to name.
|
|
202
|
+
*/
|
|
203
|
+
private referenceCycleMessage(blocked: Table<any>[], referencedTables: Map<string, string[]>): string {
|
|
204
|
+
const blockedNames = blocked.map((table) => table.name);
|
|
205
|
+
const walk: string[] = [];
|
|
206
|
+
let current = blockedNames[0];
|
|
207
|
+
while (!walk.includes(current)) {
|
|
208
|
+
walk.push(current);
|
|
209
|
+
current = referencedTables.get(current)!.find((name) => blockedNames.includes(name))!;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const cycle = [...walk.slice(walk.indexOf(current)), current];
|
|
213
|
+
return (
|
|
214
|
+
`Cannot create tables whose foreign keys form a cycle: ${cycle.join(' -> ')}. A foreign key can only ` +
|
|
215
|
+
`reference a table created before it, so no creation order exists — declare one of these references ` +
|
|
216
|
+
`once the tables exist (an alter adds it). Tables it blocks: ${blockedNames.join(', ')}`
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
155
220
|
private requireEncryptedDeclarations(): boolean {
|
|
156
221
|
if (getEnvVar('DB_REQUIRE_ENCRYPTED_DECLARATIONS') === 'true') {
|
|
157
222
|
return true;
|
|
@@ -207,6 +272,10 @@ export class TableManager {
|
|
|
207
272
|
* never calls it). Every outcome that reaches the intended-definition gate is logged LOUDLY at
|
|
208
273
|
* WARN — tolerance fired (treated as applied) OR the re-read failed (genuine conflict / still
|
|
209
274
|
* absent, re-throwing) — so an unexpected activation is visible in prod logs rather than silent.
|
|
275
|
+
* Each of those lines carries the ERROR itself (`obj.error`), never text copied out of it: the
|
|
276
|
+
* backend words a schema error itself and can quote row data in it (a failed backfill names the
|
|
277
|
+
* duplicate key), and only an error OBJECT can be recognized by the logger as one its driver
|
|
278
|
+
* marked to print as a status and a sentence (`LogLineErrors`).
|
|
210
279
|
*/
|
|
211
280
|
private async reconcileConcurrentSchemaChange(tables: Table<any>[], error: unknown): Promise<void> {
|
|
212
281
|
if (!this.schemaOperations.isAlreadyExistsError?.(error)) {
|
|
@@ -214,14 +283,14 @@ export class TableManager {
|
|
|
214
283
|
}
|
|
215
284
|
|
|
216
285
|
const tableNames = tables.map((table) => table.name).join(', ');
|
|
217
|
-
const reason = this.schemaErrorReason(error);
|
|
218
286
|
|
|
219
287
|
for (const table of tables) {
|
|
220
|
-
await this.verifyIntendedDefinition(table, tableNames,
|
|
288
|
+
await this.verifyIntendedDefinition(table, tableNames, error);
|
|
221
289
|
}
|
|
222
290
|
|
|
223
291
|
this.logger.warn({
|
|
224
|
-
message: `[schema reconcile] tolerated concurrent ALREADY_EXISTS for table(s) '${tableNames}' — re-read verified the object present with the intended definition, treating as applied (a concurrent actor won the race)
|
|
292
|
+
message: `[schema reconcile] tolerated concurrent ALREADY_EXISTS for table(s) '${tableNames}' — re-read verified the object present with the intended definition, treating as applied (a concurrent actor won the race).`,
|
|
293
|
+
obj: { error },
|
|
225
294
|
});
|
|
226
295
|
}
|
|
227
296
|
|
|
@@ -237,12 +306,7 @@ export class TableManager {
|
|
|
237
306
|
* success; only a definition still absent or mismatched after every attempt is a genuine
|
|
238
307
|
* conflict — logged at WARN, then the ORIGINAL error re-throws.
|
|
239
308
|
*/
|
|
240
|
-
private async verifyIntendedDefinition(
|
|
241
|
-
table: Table<any>,
|
|
242
|
-
tableNames: string,
|
|
243
|
-
reason: string,
|
|
244
|
-
error: unknown
|
|
245
|
-
): Promise<void> {
|
|
309
|
+
private async verifyIntendedDefinition(table: Table<any>, tableNames: string, error: unknown): Promise<void> {
|
|
246
310
|
let remainingChanges: TableChanges | undefined;
|
|
247
311
|
for (let attempt = 1; attempt <= TableManager.RECONCILE_VERIFY_ATTEMPTS; attempt++) {
|
|
248
312
|
if (attempt > 1) {
|
|
@@ -264,7 +328,8 @@ export class TableManager {
|
|
|
264
328
|
// Still absent after every re-read — not the concurrent-winner case; surface the original
|
|
265
329
|
// error rather than masking it.
|
|
266
330
|
this.logger.warn({
|
|
267
|
-
message: `[schema reconcile] caught ALREADY_EXISTS for table(s) '${tableNames}' but '${table.name}' is still absent after ${TableManager.RECONCILE_VERIFY_ATTEMPTS} re-reads — NOT a concurrent apply; re-throwing
|
|
331
|
+
message: `[schema reconcile] caught ALREADY_EXISTS for table(s) '${tableNames}' but '${table.name}' is still absent after ${TableManager.RECONCILE_VERIFY_ATTEMPTS} re-reads — NOT a concurrent apply; re-throwing.`,
|
|
332
|
+
obj: { error },
|
|
268
333
|
});
|
|
269
334
|
throw error;
|
|
270
335
|
}
|
|
@@ -272,19 +337,12 @@ export class TableManager {
|
|
|
272
337
|
// The object exists but the live schema still differs from the intended definition, so a
|
|
273
338
|
// concurrent actor did NOT apply our exact change (a genuine conflict). Propagate.
|
|
274
339
|
this.logger.warn({
|
|
275
|
-
message: `[schema reconcile] caught ALREADY_EXISTS for table '${table.name}' but the live schema does NOT match the intended definition after ${TableManager.RECONCILE_VERIFY_ATTEMPTS} re-reads (genuine conflict) — re-throwing
|
|
276
|
-
obj: { remainingChanges },
|
|
340
|
+
message: `[schema reconcile] caught ALREADY_EXISTS for table '${table.name}' but the live schema does NOT match the intended definition after ${TableManager.RECONCILE_VERIFY_ATTEMPTS} re-reads (genuine conflict) — re-throwing.`,
|
|
341
|
+
obj: { remainingChanges, error },
|
|
277
342
|
});
|
|
278
343
|
throw error;
|
|
279
344
|
}
|
|
280
345
|
|
|
281
|
-
/** Concise, loggable reason from a schema-update error: the backend names the offending object
|
|
282
|
-
* in `details` (validation phase) or `message` (apply phase). */
|
|
283
|
-
private schemaErrorReason(error: unknown): string {
|
|
284
|
-
const candidate = error as { details?: unknown; message?: unknown } | undefined;
|
|
285
|
-
return String(candidate?.details ?? candidate?.message ?? error);
|
|
286
|
-
}
|
|
287
|
-
|
|
288
346
|
private delay(ms: number): Promise<void> {
|
|
289
347
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
290
348
|
}
|
|
@@ -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
|
}
|
|
@@ -6,7 +6,7 @@ import { BooleanColumn, StringColumn } from '../Columns';
|
|
|
6
6
|
/**
|
|
7
7
|
* A source record declaration paired with its owning source: the package that compiled the
|
|
8
8
|
* declaration into this build (from the declaration's reflection qualified name). The source is
|
|
9
|
-
* the ownership grain of the sync — {@link
|
|
9
|
+
* the ownership grain of the sync — {@link SourceRecordSyncRunner} stamps it on every row it writes
|
|
10
10
|
* and prunes only within it, so servers running different builds against one shared database
|
|
11
11
|
* never delete each other's rows.
|
|
12
12
|
*/
|
|
@@ -60,7 +60,7 @@ export interface SourceRecord extends DbRecord {
|
|
|
60
60
|
isLoadedFromSource?: boolean;
|
|
61
61
|
/**
|
|
62
62
|
* The package whose declaration owns this row (the declaring loader's package, from its
|
|
63
|
-
* reflection qualified name). Stamped by {@link
|
|
63
|
+
* reflection qualified name). Stamped by {@link SourceRecordSyncRunner} on every row it writes;
|
|
64
64
|
* the removed-reconcile prunes only rows whose `sourcePackage` matches a package the running
|
|
65
65
|
* build actually declares from — so a build never deletes rows owned by a package it does not
|
|
66
66
|
* carry (e.g. another server's types on a shared database). Rows written before this column
|
|
@@ -41,7 +41,7 @@ export type SourceRecordTableLoadSummary = {
|
|
|
41
41
|
|
|
42
42
|
export type SourceRecordLoadSummary = { [tableName: string]: SourceRecordTableLoadSummary };
|
|
43
43
|
|
|
44
|
-
export class
|
|
44
|
+
export class SourceRecordSyncRunner {
|
|
45
45
|
private logger = new Logger({ name: this.constructor.name });
|
|
46
46
|
|
|
47
47
|
/** Memo of resolved package versions — one resolution (and at most one warning) per source. */
|
|
@@ -516,7 +516,7 @@ export class SourceRecordLoader {
|
|
|
516
516
|
* For object-valued fields (e.g. a `JsonColumn` blob), source is treated as
|
|
517
517
|
* fully authoritative: any structural drift — extra keys in existing, missing
|
|
518
518
|
* keys in existing, or value differences anywhere in the subtree — produces
|
|
519
|
-
* a mismatch. Comparison goes through {@link
|
|
519
|
+
* a mismatch. Comparison goes through {@link SourceRecordSyncRunner.canonicalStringify}
|
|
520
520
|
* so that key ordering (which backing stores may canonicalize alphabetically)
|
|
521
521
|
* does not cause false positives.
|
|
522
522
|
*
|
|
@@ -617,11 +617,17 @@ export class SourceRecordLoader {
|
|
|
617
617
|
* The declaring package's version, read from its own package.json at runtime. Resolution runs
|
|
618
618
|
* from the process cwd — the booting server's package, the one dependency tree every declaring
|
|
619
619
|
* package is reachable from (`@proteinjs/db` itself does not depend on the packages that
|
|
620
|
-
* declare records, so module-relative resolution could never find them).
|
|
621
|
-
* is resolved (honoring exports maps, where `<pkg>/package.json` is usually not requireable),
|
|
622
|
-
* then the nearest package.json whose `name` matches is read walking up from it.
|
|
620
|
+
* declare records, so module-relative resolution could never find them).
|
|
623
621
|
*
|
|
624
|
-
*
|
|
622
|
+
* Two roads, in order:
|
|
623
|
+
* 1. The booting package itself: `<cwd>/package.json`, when its `name` is `source`. A lookup by
|
|
624
|
+
* name from cwd searches cwd's node_modules chain, which never contains the package cwd IS,
|
|
625
|
+
* so without this read the server's own records would sync unversioned.
|
|
626
|
+
* 2. Any other package: its entry is resolved from cwd (honoring exports maps, where
|
|
627
|
+
* `<pkg>/package.json` is usually not requireable), then the nearest package.json whose
|
|
628
|
+
* `name` matches is read walking up from it.
|
|
629
|
+
*
|
|
630
|
+
* Returns undefined where neither road resolves — no Node `require` (browser bundles), or a
|
|
625
631
|
* package not resolvable from cwd. The sync then has no place in the version order for that
|
|
626
632
|
* package: it never touches its version-stamped rows, and what it writes is unversioned
|
|
627
633
|
* (see {@link isNewerStamp}). Logged once per boot so a misconfigured cwd is visible.
|
|
@@ -636,18 +642,20 @@ export class SourceRecordLoader {
|
|
|
636
642
|
const cwd = typeof process !== 'undefined' && typeof process.cwd === 'function' ? process.cwd() : undefined;
|
|
637
643
|
if (nodeRequire && cwd) {
|
|
638
644
|
try {
|
|
639
|
-
const entryPath = nodeRequire.resolve(source, { paths: [cwd] });
|
|
640
645
|
const path = nodeRequire('path');
|
|
641
646
|
const fs = nodeRequire('fs');
|
|
647
|
+
const ownVersion = this.packageVersionAt(fs, path.join(cwd, 'package.json'), source);
|
|
648
|
+
if (ownVersion) {
|
|
649
|
+
return ownVersion;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const entryPath = nodeRequire.resolve(source, { paths: [cwd] });
|
|
642
653
|
// Walk up from the entry file to the package's own package.json (the name check skips
|
|
643
654
|
// nested stubs like a dist/package.json); stop at the filesystem root.
|
|
644
655
|
for (let directory = path.dirname(entryPath); ; directory = path.dirname(directory)) {
|
|
645
|
-
const
|
|
646
|
-
if (
|
|
647
|
-
|
|
648
|
-
if (packageJson?.name === source && typeof packageJson.version === 'string') {
|
|
649
|
-
return packageJson.version;
|
|
650
|
-
}
|
|
656
|
+
const version = this.packageVersionAt(fs, path.join(directory, 'package.json'), source);
|
|
657
|
+
if (version) {
|
|
658
|
+
return version;
|
|
651
659
|
}
|
|
652
660
|
|
|
653
661
|
if (path.dirname(directory) === directory) {
|
|
@@ -665,6 +673,16 @@ export class SourceRecordLoader {
|
|
|
665
673
|
return undefined;
|
|
666
674
|
}
|
|
667
675
|
|
|
676
|
+
/** The `version` of the package.json at `packageJsonPath` when it exists and names `source`. */
|
|
677
|
+
private packageVersionAt(fs: typeof import('fs'), packageJsonPath: string, source: string): string | undefined {
|
|
678
|
+
if (!fs.existsSync(packageJsonPath)) {
|
|
679
|
+
return undefined;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
|
683
|
+
return packageJson?.name === source && typeof packageJson.version === 'string' ? packageJson.version : undefined;
|
|
684
|
+
}
|
|
685
|
+
|
|
668
686
|
/**
|
|
669
687
|
* Whether a row's version stamp is strictly newer than the reconciling build's version of the
|
|
670
688
|
* same package — the guard that makes version skew of one package safe on a shared database:
|
|
@@ -71,13 +71,21 @@ export class MigrationTable extends Table<Migration> {
|
|
|
71
71
|
* the service api, and the runner writes run state via the db api as the calling user.
|
|
72
72
|
* INSERT deliberately has no door — for anyone, break-glass included: ledger rows are born
|
|
73
73
|
* from source declarations only (boot-time loading rides getDbAsSystem, which bypasses
|
|
74
|
-
* doors), so a caller-path insert could only mint a row no source owns.
|
|
75
|
-
*
|
|
74
|
+
* doors), so a caller-path insert could only mint a row no source owns. DELETE has no door
|
|
75
|
+
* either: the ledger is {@link MigrationTable.durable}. The generic record surfaces derive
|
|
76
|
+
* their affordances from these doors (db-ui renders no create button and no delete act here).
|
|
76
77
|
*/
|
|
77
78
|
public auth: Table<Migration>['auth'] = {
|
|
78
|
-
db: { query: { permission: 'dev' }, update: { permission: 'dev' }
|
|
79
|
-
service: { query: { permission: 'dev' }, update: { permission: 'dev' }
|
|
79
|
+
db: { query: { permission: 'dev' }, update: { permission: 'dev' } },
|
|
80
|
+
service: { query: { permission: 'dev' }, update: { permission: 'dev' } },
|
|
80
81
|
};
|
|
82
|
+
/**
|
|
83
|
+
* The ledger keeps every row: run history is the record of what ran against this database, so
|
|
84
|
+
* no caller deletes it (the house's rule: migration rows are durable records; a row that must go
|
|
85
|
+
* goes by hand in the database's own console). The source-record sync keeps
|
|
86
|
+
* removed migrations' rows too ({@link MigrationTable.sourceRecordOptions} `onSourceRemoved`).
|
|
87
|
+
*/
|
|
88
|
+
public durable = true;
|
|
81
89
|
/**
|
|
82
90
|
* The row scan: WHICH migration (name — the loader's class name), what it does, how the run
|
|
83
91
|
* went, WHEN it ran ("Ran at" = start_time, the one timestamp that answers "when did it
|
|
@@ -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
|
}
|