@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.
Files changed (113) hide show
  1. package/.eslintrc.js +1 -1
  2. package/.prettierignore +2 -1
  3. package/CHANGELOG.md +37 -0
  4. package/dist/generated/index.d.ts.map +1 -1
  5. package/dist/generated/index.js +1 -3
  6. package/dist/generated/index.js.map +1 -1
  7. package/dist/generated/test/index.d.ts.map +1 -1
  8. package/dist/generated/test/index.js +1 -3
  9. package/dist/generated/test/index.js.map +1 -1
  10. package/dist/index.d.ts +1 -1
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +4 -5
  13. package/dist/index.js.map +1 -1
  14. package/dist/src/Db.d.ts +9 -0
  15. package/dist/src/Db.d.ts.map +1 -1
  16. package/dist/src/Db.js +2 -2
  17. package/dist/src/Db.js.map +1 -1
  18. package/dist/src/MigrationRunner.js +2 -2
  19. package/dist/src/MigrationRunner.js.map +1 -1
  20. package/dist/src/Table.d.ts +16 -1
  21. package/dist/src/Table.d.ts.map +1 -1
  22. package/dist/src/Table.js +14 -0
  23. package/dist/src/Table.js.map +1 -1
  24. package/dist/src/auth/TableAuth.d.ts +4 -0
  25. package/dist/src/auth/TableAuth.d.ts.map +1 -1
  26. package/dist/src/auth/TableAuth.js +12 -0
  27. package/dist/src/auth/TableAuth.js.map +1 -1
  28. package/dist/src/auth/TableServiceAuth.d.ts.map +1 -1
  29. package/dist/src/auth/TableServiceAuth.js +5 -0
  30. package/dist/src/auth/TableServiceAuth.js.map +1 -1
  31. package/dist/src/schema/SchemaOperations.d.ts +4 -3
  32. package/dist/src/schema/SchemaOperations.d.ts.map +1 -1
  33. package/dist/src/schema/TableManager.d.ts +24 -6
  34. package/dist/src/schema/TableManager.d.ts.map +1 -1
  35. package/dist/src/schema/TableManager.js +93 -26
  36. package/dist/src/schema/TableManager.js.map +1 -1
  37. package/dist/src/services/TransactionRunnerService.d.ts +20 -1
  38. package/dist/src/services/TransactionRunnerService.d.ts.map +1 -1
  39. package/dist/src/source/SourceRecord.d.ts +2 -2
  40. package/dist/src/source/{SourceRecordLoader.d.ts → SourceRecordSyncRunner.d.ts} +15 -7
  41. package/dist/src/source/SourceRecordSyncRunner.d.ts.map +1 -0
  42. package/dist/src/source/{SourceRecordLoader.js → SourceRecordSyncRunner.js} +46 -31
  43. package/dist/src/source/SourceRecordSyncRunner.js.map +1 -0
  44. package/dist/src/tables/MigrationTable.d.ts +10 -2
  45. package/dist/src/tables/MigrationTable.d.ts.map +1 -1
  46. package/dist/src/tables/MigrationTable.js +12 -4
  47. package/dist/src/tables/MigrationTable.js.map +1 -1
  48. package/dist/src/transaction/Transaction.d.ts +6 -1
  49. package/dist/src/transaction/Transaction.d.ts.map +1 -1
  50. package/dist/src/transaction/Transaction.js +13 -2
  51. package/dist/src/transaction/Transaction.js.map +1 -1
  52. package/dist/src/transaction/TransactionRunner.d.ts +45 -2
  53. package/dist/src/transaction/TransactionRunner.d.ts.map +1 -1
  54. package/dist/src/transaction/TransactionRunner.js +227 -26
  55. package/dist/src/transaction/TransactionRunner.js.map +1 -1
  56. package/dist/test/DurableTable.test.d.ts +2 -0
  57. package/dist/test/DurableTable.test.d.ts.map +1 -0
  58. package/dist/test/DurableTable.test.js +172 -0
  59. package/dist/test/DurableTable.test.js.map +1 -0
  60. package/dist/test/MigrationAuth.test.js +12 -9
  61. package/dist/test/MigrationAuth.test.js.map +1 -1
  62. package/dist/test/SchemaReconcileLines.test.d.ts +2 -0
  63. package/dist/test/SchemaReconcileLines.test.d.ts.map +1 -0
  64. package/dist/test/SchemaReconcileLines.test.js +176 -0
  65. package/dist/test/SchemaReconcileLines.test.js.map +1 -0
  66. package/dist/test/SourceRecordLoaderQualifiedName.test.d.ts +2 -0
  67. package/dist/test/SourceRecordLoaderQualifiedName.test.d.ts.map +1 -0
  68. package/dist/test/SourceRecordLoaderQualifiedName.test.js +79 -0
  69. package/dist/test/SourceRecordLoaderQualifiedName.test.js.map +1 -0
  70. package/dist/test/SourceRecordSyncRunner.test.d.ts +2 -0
  71. package/dist/test/SourceRecordSyncRunner.test.d.ts.map +1 -0
  72. package/dist/test/{SourceRecordLoader.test.js → SourceRecordSyncRunner.test.js} +63 -7
  73. package/dist/test/SourceRecordSyncRunner.test.js.map +1 -0
  74. package/dist/test/TableManagerCreateOrder.test.d.ts +2 -0
  75. package/dist/test/TableManagerCreateOrder.test.d.ts.map +1 -0
  76. package/dist/test/TableManagerCreateOrder.test.js +66 -0
  77. package/dist/test/TableManagerCreateOrder.test.js.map +1 -0
  78. package/dist/test/reusable/SourceRecordSyncTests.d.ts.map +1 -1
  79. package/dist/test/reusable/SourceRecordSyncTests.js +3 -5
  80. package/dist/test/reusable/SourceRecordSyncTests.js.map +1 -1
  81. package/dist/test/reusable/TableManagerTests.d.ts.map +1 -1
  82. package/dist/test/reusable/TableManagerTests.js +180 -0
  83. package/dist/test/reusable/TableManagerTests.js.map +1 -1
  84. package/generated/index.ts +20 -19
  85. package/generated/test/index.ts +54 -53
  86. package/index.ts +3 -4
  87. package/package.json +4 -4
  88. package/src/Db.ts +11 -2
  89. package/src/MigrationRunner.ts +2 -2
  90. package/src/Table.ts +16 -1
  91. package/src/auth/TableAuth.ts +14 -0
  92. package/src/auth/TableServiceAuth.ts +6 -1
  93. package/src/schema/SchemaOperations.ts +4 -3
  94. package/src/schema/TableManager.ts +84 -26
  95. package/src/services/TransactionRunnerService.ts +21 -1
  96. package/src/source/SourceRecord.ts +2 -2
  97. package/src/source/{SourceRecordLoader.ts → SourceRecordSyncRunner.ts} +31 -13
  98. package/src/tables/MigrationTable.ts +12 -4
  99. package/src/transaction/Transaction.ts +11 -2
  100. package/src/transaction/TransactionRunner.ts +174 -3
  101. package/test/DurableTable.test.ts +156 -0
  102. package/test/MigrationAuth.test.ts +7 -5
  103. package/test/SchemaReconcileLines.test.ts +127 -0
  104. package/test/SourceRecordLoaderQualifiedName.test.ts +63 -0
  105. package/test/{SourceRecordLoader.test.ts → SourceRecordSyncRunner.test.ts} +75 -8
  106. package/test/TableManagerCreateOrder.test.ts +78 -0
  107. package/test/reusable/SourceRecordSyncTests.ts +3 -5
  108. package/test/reusable/TableManagerTests.ts +95 -0
  109. package/dist/src/source/SourceRecordLoader.d.ts.map +0 -1
  110. package/dist/src/source/SourceRecordLoader.js.map +0 -1
  111. package/dist/test/SourceRecordLoader.test.d.ts +0 -2
  112. package/dist/test/SourceRecordLoader.test.d.ts.map +0 -1
  113. 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, aliased: its class name is taken in this
19
- // index by the SourceRecordLoader declaration interface. Public so consumer test harnesses can
20
- // run one boot of the sync directly (the exports map blocks deep dist imports).
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.47.3",
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.21",
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.5.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": "08154afae17a9a19c9e48520a3dd67ead7b997c6"
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 { SourceRecordLoader } from './source/SourceRecordLoader';
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 SourceRecordLoader().load();
185
+ await new SourceRecordSyncRunner().load();
177
186
  }
178
187
 
179
188
  async tableExists<T extends R>(table: Table<T>): Promise<boolean> {
@@ -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 { SourceRecordLoader } from './source/SourceRecordLoader';
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 SourceRecordLoader().load(migrationTable);
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
  };
@@ -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. Drivers that support batched
7
- * DDL (Spanner) apply the whole set as ONE schema-update operation — statements inside a batch
8
- * apply in order, so the ordering contract is preserved.
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
- * preserving their registration order so foreign keys to other absent tables resolve; existing
76
- * tables are altered individually after, so an alter that adds a foreign key to a
77
- * just-created table sees it live.
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
- this.logger.info({ message: `Creating tables: ${absentTables.map((table) => table.name).join(', ')}` });
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(absentTables);
97
+ await this.schemaOperations.createTables(tablesToCreate);
96
98
  } catch (error) {
97
- await this.reconcileConcurrentSchemaChange(absentTables, error);
99
+ await this.reconcileConcurrentSchemaChange(tablesToCreate, error);
98
100
  }
99
- this.logger.info({ message: `Finished creating ${absentTables.length} tables` });
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, reason, error);
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). reason: ${reason}`,
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. reason: ${reason}`,
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. reason: ${reason}`,
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 SourceRecordLoader} stamps it on every row it writes
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 SourceRecordLoader} on every row it writes;
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 SourceRecordLoader {
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 SourceRecordLoader.canonicalStringify}
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). The package's entry
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
- * Returns undefined where resolution is impossible — no Node `require` (browser bundles), or a
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 candidate = path.join(directory, 'package.json');
646
- if (fs.existsSync(candidate)) {
647
- const packageJson = JSON.parse(fs.readFileSync(candidate, 'utf8'));
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. The generic record
75
- * surfaces derive their affordances from these doors (db-ui renders no create button here).
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' }, delete: { permission: 'dev' } },
79
- service: { query: { permission: 'dev' }, update: { permission: 'dev' }, delete: { 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
- await runner.run(this.ops);
284
+ if (options?.afterRows && options.afterRows.length > 0) {
285
+ await runner.run(this.ops, { afterRows: options.afterRows });
286
+ } else {
287
+ await runner.run(this.ops);
288
+ }
280
289
  this.ops = [];
281
290
  }
282
291
  }