@proteinjs/db 1.48.0 → 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 (98) hide show
  1. package/.eslintrc.js +1 -1
  2. package/.prettierignore +2 -1
  3. package/CHANGELOG.md +20 -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.js +2 -2
  15. package/dist/src/Db.js.map +1 -1
  16. package/dist/src/MigrationRunner.js +2 -2
  17. package/dist/src/MigrationRunner.js.map +1 -1
  18. package/dist/src/Table.d.ts +16 -1
  19. package/dist/src/Table.d.ts.map +1 -1
  20. package/dist/src/Table.js +14 -0
  21. package/dist/src/Table.js.map +1 -1
  22. package/dist/src/auth/TableAuth.d.ts +4 -0
  23. package/dist/src/auth/TableAuth.d.ts.map +1 -1
  24. package/dist/src/auth/TableAuth.js +12 -0
  25. package/dist/src/auth/TableAuth.js.map +1 -1
  26. package/dist/src/auth/TableServiceAuth.d.ts.map +1 -1
  27. package/dist/src/auth/TableServiceAuth.js +5 -0
  28. package/dist/src/auth/TableServiceAuth.js.map +1 -1
  29. package/dist/src/schema/SchemaOperations.d.ts +4 -3
  30. package/dist/src/schema/SchemaOperations.d.ts.map +1 -1
  31. package/dist/src/schema/TableManager.d.ts +24 -6
  32. package/dist/src/schema/TableManager.d.ts.map +1 -1
  33. package/dist/src/schema/TableManager.js +93 -26
  34. package/dist/src/schema/TableManager.js.map +1 -1
  35. package/dist/src/source/SourceRecord.d.ts +2 -2
  36. package/dist/src/source/{SourceRecordLoader.d.ts → SourceRecordSyncRunner.d.ts} +15 -7
  37. package/dist/src/source/SourceRecordSyncRunner.d.ts.map +1 -0
  38. package/dist/src/source/{SourceRecordLoader.js → SourceRecordSyncRunner.js} +46 -31
  39. package/dist/src/source/SourceRecordSyncRunner.js.map +1 -0
  40. package/dist/src/tables/MigrationTable.d.ts +10 -2
  41. package/dist/src/tables/MigrationTable.d.ts.map +1 -1
  42. package/dist/src/tables/MigrationTable.js +12 -4
  43. package/dist/src/tables/MigrationTable.js.map +1 -1
  44. package/dist/test/DurableTable.test.d.ts +2 -0
  45. package/dist/test/DurableTable.test.d.ts.map +1 -0
  46. package/dist/test/DurableTable.test.js +172 -0
  47. package/dist/test/DurableTable.test.js.map +1 -0
  48. package/dist/test/MigrationAuth.test.js +12 -9
  49. package/dist/test/MigrationAuth.test.js.map +1 -1
  50. package/dist/test/SchemaReconcileLines.test.d.ts +2 -0
  51. package/dist/test/SchemaReconcileLines.test.d.ts.map +1 -0
  52. package/dist/test/SchemaReconcileLines.test.js +176 -0
  53. package/dist/test/SchemaReconcileLines.test.js.map +1 -0
  54. package/dist/test/SourceRecordLoaderQualifiedName.test.d.ts +2 -0
  55. package/dist/test/SourceRecordLoaderQualifiedName.test.d.ts.map +1 -0
  56. package/dist/test/SourceRecordLoaderQualifiedName.test.js +79 -0
  57. package/dist/test/SourceRecordLoaderQualifiedName.test.js.map +1 -0
  58. package/dist/test/SourceRecordSyncRunner.test.d.ts +2 -0
  59. package/dist/test/SourceRecordSyncRunner.test.d.ts.map +1 -0
  60. package/dist/test/{SourceRecordLoader.test.js → SourceRecordSyncRunner.test.js} +63 -7
  61. package/dist/test/SourceRecordSyncRunner.test.js.map +1 -0
  62. package/dist/test/TableManagerCreateOrder.test.d.ts +2 -0
  63. package/dist/test/TableManagerCreateOrder.test.d.ts.map +1 -0
  64. package/dist/test/TableManagerCreateOrder.test.js +66 -0
  65. package/dist/test/TableManagerCreateOrder.test.js.map +1 -0
  66. package/dist/test/reusable/SourceRecordSyncTests.d.ts.map +1 -1
  67. package/dist/test/reusable/SourceRecordSyncTests.js +3 -5
  68. package/dist/test/reusable/SourceRecordSyncTests.js.map +1 -1
  69. package/dist/test/reusable/TableManagerTests.d.ts.map +1 -1
  70. package/dist/test/reusable/TableManagerTests.js +180 -0
  71. package/dist/test/reusable/TableManagerTests.js.map +1 -1
  72. package/generated/index.ts +20 -19
  73. package/generated/test/index.ts +54 -53
  74. package/index.ts +3 -4
  75. package/package.json +4 -4
  76. package/src/Db.ts +2 -2
  77. package/src/MigrationRunner.ts +2 -2
  78. package/src/Table.ts +16 -1
  79. package/src/auth/TableAuth.ts +14 -0
  80. package/src/auth/TableServiceAuth.ts +6 -1
  81. package/src/schema/SchemaOperations.ts +4 -3
  82. package/src/schema/TableManager.ts +84 -26
  83. package/src/source/SourceRecord.ts +2 -2
  84. package/src/source/{SourceRecordLoader.ts → SourceRecordSyncRunner.ts} +31 -13
  85. package/src/tables/MigrationTable.ts +12 -4
  86. package/test/DurableTable.test.ts +156 -0
  87. package/test/MigrationAuth.test.ts +7 -5
  88. package/test/SchemaReconcileLines.test.ts +127 -0
  89. package/test/SourceRecordLoaderQualifiedName.test.ts +63 -0
  90. package/test/{SourceRecordLoader.test.ts → SourceRecordSyncRunner.test.ts} +75 -8
  91. package/test/TableManagerCreateOrder.test.ts +78 -0
  92. package/test/reusable/SourceRecordSyncTests.ts +3 -5
  93. package/test/reusable/TableManagerTests.ts +95 -0
  94. package/dist/src/source/SourceRecordLoader.d.ts.map +0 -1
  95. package/dist/src/source/SourceRecordLoader.js.map +0 -1
  96. package/dist/test/SourceRecordLoader.test.d.ts +0 -2
  97. package/dist/test/SourceRecordLoader.test.d.ts.map +0 -1
  98. 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.48.0",
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": "ec3fd54430dde18afd406ec70eccd857492272a0"
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';
@@ -182,7 +182,7 @@ export class Db<R extends Record = Record> implements DbService<R> {
182
182
  const { MigrationRunner } = await import('./MigrationRunner');
183
183
  await new MigrationRunner().runPreSchemaSyncMigrations(this.dbDriver.getTableManager());
184
184
  await this.dbDriver.getTableManager().loadTables();
185
- await new SourceRecordLoader().load();
185
+ await new SourceRecordSyncRunner().load();
186
186
  }
187
187
 
188
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
  }
@@ -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
@@ -0,0 +1,156 @@
1
+ import { UserAuth } from '@proteinjs/user-auth';
2
+ import { Table } from '../src/Table';
3
+ import { withRecordColumns, Record } from '../src/Record';
4
+ import { StringColumn } from '../src/Columns';
5
+ import { TableAuth } from '../src/auth/TableAuth';
6
+ import { TableServiceAuth } from '../src/auth/TableServiceAuth';
7
+ import { MigrationTable } from '../src/tables/MigrationTable';
8
+
9
+ /**
10
+ * A DURABLE table (`Table.durable`) keeps its rows: the delete door is closed on both apis for
11
+ * every caller — break-glass included, whatever `auth` declares — and the refusal is a typed 403
12
+ * that names the table. Everything else about the table's doors is unchanged, and a table that
13
+ * does not declare it keeps its delete doors exactly as declared.
14
+ *
15
+ * The house's rule: the migration ledger's rows could be selected and deleted from the record
16
+ * table in a deployment — they are durable records; the migration table declares it.
17
+ *
18
+ * `UserAuth` reads from a static repo; tests stub it directly per identity — no server needed.
19
+ */
20
+
21
+ type UserAuthInternals = {
22
+ userRepo?: { getUser: () => { email: string; roles: string[] } };
23
+ permissionRolesMapping?: { getRoles: (permission: string) => string[] | undefined };
24
+ };
25
+
26
+ const setUser = (roles: string[]) => {
27
+ (UserAuth as unknown as UserAuthInternals).userRepo = {
28
+ getUser: () => ({ email: 'user@test.local', roles }),
29
+ };
30
+ };
31
+
32
+ const setMapping = (mapping: { [permission: string]: string[] }) => {
33
+ (UserAuth as unknown as UserAuthInternals).permissionRolesMapping = {
34
+ getRoles: (permission: string) => mapping[permission],
35
+ };
36
+ };
37
+
38
+ interface Entry extends Record {
39
+ title: string;
40
+ }
41
+
42
+ /** Every door open to the 'ledger' permission — the delete door included. */
43
+ class OpenLedgerTable extends Table<Entry> {
44
+ public name = 'open_ledger_test';
45
+ public auth: Table<Entry>['auth'] = {
46
+ db: { all: { permission: 'ledger' } },
47
+ service: { all: { permission: 'ledger' } },
48
+ };
49
+ public columns = withRecordColumns<Entry>({
50
+ title: new StringColumn('title'),
51
+ });
52
+ }
53
+
54
+ /** The same doors, declared durable: the declared delete doors do not reopen what durability closes. */
55
+ class DurableLedgerTable extends OpenLedgerTable {
56
+ public name = 'durable_ledger_test';
57
+ public durable = true;
58
+ }
59
+
60
+ /** Durable with no auth block at all: admin's break-glass default does not reach delete either. */
61
+ class DurableNoAuthTable extends Table<Entry> {
62
+ public name = 'durable_no_auth_test';
63
+ public durable = true;
64
+ public columns = withRecordColumns<Entry>({
65
+ title: new StringColumn('title'),
66
+ });
67
+ }
68
+
69
+ /** What the refusal is, read by shape (the router reads a `ServiceRefusal` the same way). */
70
+ const refusalOf = (act: () => unknown): { name?: string; status?: number; message?: string } | undefined => {
71
+ try {
72
+ act();
73
+ } catch (error: any) {
74
+ return { name: error?.name, status: error?.status, message: error?.message };
75
+ }
76
+ return undefined;
77
+ };
78
+
79
+ describe('a durable table keeps its rows', () => {
80
+ beforeEach(() => {
81
+ setMapping({ ledger: ['ledger-keeper'] });
82
+ });
83
+
84
+ afterEach(() => {
85
+ (UserAuth as unknown as UserAuthInternals).userRepo = undefined;
86
+ (UserAuth as unknown as UserAuthInternals).permissionRolesMapping = undefined;
87
+ });
88
+
89
+ it('the delete door refuses every caller on both apis — a 403 refusal naming the table, whatever auth declares', () => {
90
+ const auth = new TableAuth();
91
+ for (const table of [new DurableLedgerTable(), new DurableNoAuthTable()]) {
92
+ for (const roles of [['ledger-keeper'], ['admin']]) {
93
+ setUser(roles);
94
+ for (const api of ['db', 'service'] as const) {
95
+ const refusal = refusalOf(() => auth.canDelete(table, api));
96
+ expect(refusal).toEqual({
97
+ name: 'ServiceRefusal',
98
+ status: 403,
99
+ message: `Table ${table.name} is durable: its rows are never deleted`,
100
+ });
101
+ // The capability read the record surfaces derive their acts from agrees with the door.
102
+ expect(auth.canPerform(table, 'delete', api)).toBe(false);
103
+ }
104
+ }
105
+ }
106
+ });
107
+
108
+ it('the service door passes the refusal to the caller as it is — not a generic denial, not a failure', () => {
109
+ setUser(['admin']);
110
+ const table = new DurableLedgerTable();
111
+ const refusal = refusalOf(() => new TableServiceAuth().canAccess('delete', [table, { id: 'entry-1' }]));
112
+ expect(refusal).toEqual({
113
+ name: 'ServiceRefusal',
114
+ status: 403,
115
+ message: 'Table durable_ledger_test is durable: its rows are never deleted',
116
+ });
117
+ });
118
+
119
+ it('the durable table keeps every other door it declares', () => {
120
+ setUser(['ledger-keeper']);
121
+ const auth = new TableAuth();
122
+ const table = new DurableLedgerTable();
123
+ for (const api of ['db', 'service'] as const) {
124
+ expect(() => auth.canQuery(table, api)).not.toThrow();
125
+ expect(() => auth.canInsert(table, api)).not.toThrow();
126
+ expect(() => auth.canUpdate(table, api)).not.toThrow();
127
+ }
128
+ expect(new TableServiceAuth().canAccess('query', [table, {}])).toBe(true);
129
+ expect(new TableServiceAuth().canAccess('update', [table, { id: 'entry-1', title: 't' }])).toBe(true);
130
+ });
131
+
132
+ it('a table that does not declare it keeps its delete doors exactly as declared', () => {
133
+ const auth = new TableAuth();
134
+ const table = new OpenLedgerTable();
135
+
136
+ setUser(['ledger-keeper']);
137
+ for (const api of ['db', 'service'] as const) {
138
+ expect(() => auth.canDelete(table, api)).not.toThrow();
139
+ expect(auth.canPerform(table, 'delete', api)).toBe(true);
140
+ }
141
+ expect(new TableServiceAuth().canAccess('delete', [table, { id: 'entry-1' }])).toBe(true);
142
+
143
+ setUser(['someone-else']);
144
+ expect(() => auth.canDelete(table, 'service')).toThrow(
145
+ 'User is not authorized to delete records from table: open_ledger_test'
146
+ );
147
+ });
148
+
149
+ it('the migration ledger is durable', () => {
150
+ setUser(['admin']);
151
+ const table = new MigrationTable();
152
+ const refusal = refusalOf(() => new TableServiceAuth().canAccess('delete', [table, { id: 'migration-1' }]));
153
+ expect(refusal?.status).toBe(403);
154
+ expect(refusal?.message).toBe('Table migration is durable: its rows are never deleted');
155
+ });
156
+ });