@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
@@ -1,10 +1,32 @@
1
- import { getDb } from '../Db';
2
- import { TransactionRunnerService, getTransactionRunnerService } from '../services/TransactionRunnerService';
1
+ import { Logger } from '@proteinjs/logger';
2
+ import { Db, getDb } from '../Db';
3
+ import { Table, tableByName } from '../Table';
4
+ import { getDefaultTransactionContextFactory } from './TransactionContextFactory';
5
+ import {
6
+ TransactionRunnerService,
7
+ TransactionRunOptions,
8
+ getTransactionRunnerService,
9
+ } from '../services/TransactionRunnerService';
3
10
  import { Operation } from './Transaction';
4
11
 
5
12
  export const getTransactionRunner = () =>
6
13
  typeof self === 'undefined' ? new TransactionRunner() : (getTransactionRunnerService() as TransactionRunner);
7
14
 
15
+ /**
16
+ * Runs a client `Transaction`'s operations as one server-side transaction.
17
+ *
18
+ * A write may DECLARE the rows it depends on (`options.afterRows`): rows its operations
19
+ * reference whose own insert may still be in flight when this request arrives — a text update
20
+ * released by a page that was going away while its row's insert was still being served, a child
21
+ * row's insert whose root (its permission source, and the owner grant that commits with the
22
+ * root) is still landing. Without the declaration such a write ran to completion inside the
23
+ * insert's window and matched nothing, silently, or was refused at the door for a grant that was
24
+ * a moment from existing — and the page that could have retried was gone. The server outlives
25
+ * the page: it waits, bounded by the db driver's per-operation deadline, for every declared row to
26
+ * exist AND be visible to the caller (a read as the caller sees a row exactly when the caller's
27
+ * grant on its scope has committed), then runs the operations. A row that never appears fails the
28
+ * request with a plain clause.
29
+ */
8
30
  export class TransactionRunner implements TransactionRunnerService {
9
31
  public serviceMetadata = {
10
32
  auth: {
@@ -12,8 +34,17 @@ export class TransactionRunner implements TransactionRunnerService {
12
34
  },
13
35
  };
14
36
 
15
- async run(ops: Operation<any>[]): Promise<void> {
37
+ private static readonly FIRST_POLL_INTERVAL_MS = 25;
38
+ private static readonly MAX_POLL_INTERVAL_MS = 250;
39
+
40
+ private logger = new Logger({ name: this.constructor.name });
41
+
42
+ async run(ops: Operation<any>[], options?: TransactionRunOptions): Promise<void> {
16
43
  const db = getDb();
44
+ const afterRows = options?.afterRows ?? [];
45
+ if (afterRows.length > 0) {
46
+ await this.awaitRows(ops, afterRows);
47
+ }
17
48
 
18
49
  await db.runTransaction(async () => {
19
50
  for (const op of ops) {
@@ -21,4 +52,144 @@ export class TransactionRunner implements TransactionRunnerService {
21
52
  }
22
53
  });
23
54
  }
55
+
56
+ /**
57
+ * Wait until every declared row exists and is visible to the caller, bounded by the db driver's
58
+ * per-operation deadline (`DbDriver.getOperationDeadlineMs`), read when the write waits. The
59
+ * bound is the ceiling for a row that NEVER comes — the named failure, never a hang. A
60
+ * dependency's insert lands in hundreds of milliseconds on a healthy link and in seconds on a
61
+ * degraded one (a root's birth of a few dozen sequential statements has taken 13 s), and the
62
+ * writes released behind it by a page that is gone have no second chance. The driver's deadline
63
+ * is how long the deployment lets one statement of that insert run before failing it: the wait
64
+ * takes its measure from there — one owner for the number, configured with the database — not
65
+ * from a number of its own. A write that waits here holds one request and a poll, nothing else.
66
+ *
67
+ * Each poll is a single-use strong read outside any transaction, so a commit is seen the moment
68
+ * it lands; the interval starts short and backs off so a dependency landing in hundreds of
69
+ * milliseconds is caught within tens.
70
+ */
71
+ private async awaitRows(ops: Operation<any>[], afterRows: string[]): Promise<void> {
72
+ if (getDefaultTransactionContextFactory()?.getTransactionContext().currentTransaction) {
73
+ throw new Error(
74
+ `TransactionRunner: afterRows cannot be honoured inside an ambient transaction — its reads would see the transaction's own snapshot, never a row committed after it began`
75
+ );
76
+ }
77
+
78
+ const tablesById = TransactionRunner.rowsTheOperationsReference(ops);
79
+ const unknown = afterRows.filter((id) => !tablesById.has(id));
80
+ if (unknown.length > 0) {
81
+ throw new Error(
82
+ `TransactionRunner: afterRows names rows none of the operations reference (${unknown.join(', ')}) — the write was not run`
83
+ );
84
+ }
85
+
86
+ const db = getDb();
87
+ const boundMs = Db.getDefaultDbDriver().getOperationDeadlineMs();
88
+ const started = Date.now();
89
+ const deadline = started + boundMs;
90
+ /** Per declared id, the (table, id) rows still absent. */
91
+ let waiting: { id: string; tables: Table<any>[] }[] = afterRows
92
+ .filter((id, index) => afterRows.indexOf(id) === index)
93
+ .map((id) => ({ id, tables: tablesById.get(id)!.slice() }));
94
+ let interval = TransactionRunner.FIRST_POLL_INTERVAL_MS;
95
+ let polls = 0;
96
+ for (;;) {
97
+ polls += 1;
98
+ for (const row of waiting) {
99
+ const stillAbsent: Table<any>[] = [];
100
+ for (const table of row.tables) {
101
+ if (!(await db.get(table, { id: row.id } as any))) {
102
+ stillAbsent.push(table);
103
+ }
104
+ }
105
+ row.tables = stillAbsent;
106
+ }
107
+ waiting = waiting.filter((row) => row.tables.length > 0);
108
+ if (waiting.length === 0) {
109
+ const waitedMs = Date.now() - started;
110
+ // One line when the seam did work (the row landed while this write waited); a row that
111
+ // was already there is the common case and stays quiet.
112
+ const line = { message: `Declared rows present`, obj: { afterRows, waitedMs, polls } };
113
+ if (polls > 1) {
114
+ this.logger.info(line);
115
+ } else {
116
+ this.logger.debug(line);
117
+ }
118
+ return;
119
+ }
120
+
121
+ const now = Date.now();
122
+ if (now >= deadline) {
123
+ const absent = waiting.map((row) => row.tables.map((table) => `${table.name}:${row.id}`).join(', ')).join(', ');
124
+ this.logger.warn({
125
+ message: `Declared rows absent at the bound — the write was not run`,
126
+ obj: { absent, boundMs, waitedMs: now - started, polls },
127
+ });
128
+ throw new Error(
129
+ `TransactionRunner: waited ${boundMs} ms for rows this write depends on to exist and be visible to the caller; still absent: ${absent} — the write was not run (the bound is the db driver's per-operation deadline)`
130
+ );
131
+ }
132
+ await new Promise((resolve) => setTimeout(resolve, Math.min(interval, deadline - now)));
133
+ interval = Math.min(interval * 2, TransactionRunner.MAX_POLL_INTERVAL_MS);
134
+ }
135
+ }
136
+
137
+ /**
138
+ * The rows the operations reference, by id → the table(s) they are read from: the permission
139
+ * source a row attaches to (an insert whose `permissionSource` is another row), the row an
140
+ * update / preserving update / membership update / delete targets. The rows the operations
141
+ * insert themselves are not dependencies.
142
+ */
143
+ private static rowsTheOperationsReference(ops: Operation<any>[]): Map<string, Table<any>[]> {
144
+ const inserted: string[] = [];
145
+ const referenced = new Map<string, Table<any>[]>();
146
+ const reference = (table: Table<any> | undefined, id: unknown) => {
147
+ if (!table || typeof id !== 'string') {
148
+ return;
149
+ }
150
+ let tables = referenced.get(id);
151
+ if (!tables) {
152
+ tables = [];
153
+ referenced.set(id, tables);
154
+ }
155
+ if (tables.indexOf(table) === -1) {
156
+ tables.push(table);
157
+ }
158
+ };
159
+
160
+ for (const op of ops) {
161
+ const args = op.args as unknown[];
162
+ const table = args[0] as Table<any> | undefined;
163
+ if (op.name === 'insert') {
164
+ const record = args[1] as
165
+ | { id?: string; permissionSource?: { _table?: string; _id?: string }; permissionSourceTable?: string }
166
+ | undefined;
167
+ if (record?.id) {
168
+ inserted.push(record.id);
169
+ }
170
+ const source = record?.permissionSource;
171
+ if (source?._id && source._id !== record?.id) {
172
+ const sourceTableName = record?.permissionSourceTable ?? source._table;
173
+ reference(sourceTableName ? TransactionRunner.tableNamed(sourceTableName, table) : table, source._id);
174
+ }
175
+ } else if (op.name === 'update' || op.name === 'updatePreserving') {
176
+ const record = args[1] as { id?: string } | undefined;
177
+ const query = args[2] as { id?: unknown } | undefined;
178
+ reference(table, record?.id ?? (query && typeof query === 'object' && 'id' in query ? query.id : undefined));
179
+ } else if (op.name === 'updateArrayMembership') {
180
+ reference(table, (args[1] as { recordId?: string } | undefined)?.recordId);
181
+ } else if (op.name === 'delete') {
182
+ const query = args[1] as { id?: unknown } | undefined;
183
+ reference(table, query && typeof query === 'object' ? query.id : undefined);
184
+ }
185
+ }
186
+
187
+ inserted.forEach((id) => referenced.delete(id));
188
+ return referenced;
189
+ }
190
+
191
+ /** The table a permission source lives in: the insert's own table when it names the same one. */
192
+ private static tableNamed(tableName: string, inserting: Table<any> | undefined): Table<any> {
193
+ return inserting && inserting.name === tableName ? inserting : tableByName(tableName);
194
+ }
24
195
  }
@@ -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
+ });
@@ -100,13 +100,15 @@ describe('migrations ride the dev permission', () => {
100
100
  }
101
101
  });
102
102
 
103
- it('delete keeps its dev door (ledger hygiene stays possible)', () => {
104
- setUser(['dev-crew']);
103
+ it('delete has no door — for anyone, break-glass included: the ledger is durable', () => {
105
104
  const auth = new TableAuth();
106
105
  const table = new MigrationTable();
107
- for (const api of ['db', 'service'] as const) {
108
- expect(() => auth.canDelete(table, api)).not.toThrow();
109
- expect(auth.canPerform(table, 'delete', api)).toBe(true);
106
+ for (const roles of [['dev-crew'], ['admin']]) {
107
+ setUser(roles);
108
+ for (const api of ['db', 'service'] as const) {
109
+ expect(() => auth.canDelete(table, api)).toThrow('Table migration is durable: its rows are never deleted');
110
+ expect(auth.canPerform(table, 'delete', api)).toBe(false);
111
+ }
110
112
  }
111
113
  });
112
114
  });
@@ -0,0 +1,127 @@
1
+ import { inspect } from 'util';
2
+ import { Logger, LogLineErrors } from '@proteinjs/logger';
3
+ import { TableManager } from '../src/schema/TableManager';
4
+ import { TableChanges } from '../src/schema/SchemaOperations';
5
+ import { Table } from '../src/Table';
6
+
7
+ /**
8
+ * The schema-reconcile WARN lines (`TableManager.reconcileConcurrentSchemaChange`) carry the
9
+ * error a driver rejected with — never text copied out of it.
10
+ *
11
+ * A backend words a schema error itself and can quote row data in it (a unique index whose
12
+ * backfill meets duplicate rows names the duplicate key). A driver marks such an error so the
13
+ * logger prints its status and the driver's sentence in its place (`LogLineErrors`) — which the
14
+ * logger can only do for the error OBJECT. These lines used to interpolate the error's `details`
15
+ * / `message` into their own message, where nothing can recognize it.
16
+ *
17
+ * Judged at the log WRITER, as the text a writer would print, for each of the three outcomes
18
+ * that log: tolerated, still absent, a genuine conflict.
19
+ */
20
+
21
+ // A fixture value shaped like row content (not a real credential).
22
+ const VALUE = 'rst_reconcile_5d41402abc4b2a76b9719d911017c592';
23
+ const SENTENCE =
24
+ 'Failed when executing schema update (ALREADY_EXISTS, code 6): what the statement creates already exists';
25
+
26
+ type CapturedLog = { logLevel: string; message?: string; obj?: any };
27
+ type ManagerInternals = {
28
+ logger: Logger;
29
+ reconcileConcurrentSchemaChange(tables: Table<any>[], error: unknown): Promise<void>;
30
+ getTableChanges(table: Table<any>): Promise<TableChanges>;
31
+ delay(ms: number): Promise<void>;
32
+ };
33
+
34
+ const noChanges = (): TableChanges =>
35
+ ({
36
+ columnsToCreate: [],
37
+ columnsToRename: [],
38
+ columnsToAlter: [],
39
+ columnsWithForeignKeysToDrop: [],
40
+ columnsWithUniqueConstraintsToDrop: [],
41
+ indexesToCreate: [],
42
+ indexesToDrop: [],
43
+ }) as unknown as TableChanges;
44
+
45
+ /** What a driver rejects with when the backend refuses a schema change — marked, as a driver marks it. */
46
+ const backendError = () => {
47
+ const message = `6 ALREADY_EXISTS: Duplicate name in schema: ledger_by_email. duplicate key: {String("${VALUE}")}`;
48
+ const error = Object.assign(new Error(message), { code: 6, details: message });
49
+ LogLineErrors.mark(error, () => ({ code: 6, sentence: SENTENCE, facts: { status: 'ALREADY_EXISTS' } }));
50
+ return error;
51
+ };
52
+
53
+ const printedLine = (log: CapturedLog) =>
54
+ [log.message ?? '', inspect(log.obj, { depth: 10, maxStringLength: null }), JSON.stringify(log.obj) ?? ''].join('\n');
55
+
56
+ describe('the schema-reconcile lines carry the error itself, never its text', () => {
57
+ const table = { name: 'ledger' } as Table<any>;
58
+ let captured: CapturedLog[];
59
+
60
+ const managerWhere = (live: { exists: boolean; changes: TableChanges }) => {
61
+ const manager = new TableManager(
62
+ {} as never,
63
+ {} as never,
64
+ { isAlreadyExistsError: () => true } as never,
65
+ { tableExists: async () => live.exists } as never
66
+ );
67
+ const internals = manager as unknown as ManagerInternals;
68
+ internals.logger = new Logger({
69
+ name: 'TableManager',
70
+ logWriter: { write: (log: CapturedLog) => captured.push(log) } as never,
71
+ });
72
+ internals.getTableChanges = async () => live.changes;
73
+ internals.delay = async () => undefined;
74
+ return internals;
75
+ };
76
+
77
+ beforeEach(() => {
78
+ captured = [];
79
+ });
80
+
81
+ const expectTheErrorNotItsText = (line: CapturedLog, error: Error) => {
82
+ expect(error.message).toContain(VALUE);
83
+ expect(printedLine(line)).not.toContain(VALUE);
84
+ expect(printedLine(line)).not.toContain('Duplicate name in schema');
85
+ expect(line.obj.error).toBeInstanceOf(Error);
86
+ expect(line.obj.error.message).toBe(SENTENCE);
87
+ expect(line.message).toContain('ledger');
88
+ };
89
+
90
+ test('tolerated: the table is there with the intended definition', async () => {
91
+ const error = backendError();
92
+
93
+ await managerWhere({ exists: true, changes: noChanges() }).reconcileConcurrentSchemaChange([table], error);
94
+
95
+ expect(captured).toHaveLength(1);
96
+ expect(captured[0].message).toContain('tolerated concurrent ALREADY_EXISTS');
97
+ expectTheErrorNotItsText(captured[0], error);
98
+ });
99
+
100
+ test('still absent after every re-read: the original error is thrown as it is', async () => {
101
+ const error = backendError();
102
+
103
+ const thrown = await managerWhere({ exists: false, changes: noChanges() })
104
+ .reconcileConcurrentSchemaChange([table], error)
105
+ .catch((each: unknown) => each);
106
+
107
+ expect(thrown).toBe(error);
108
+ expect(captured).toHaveLength(1);
109
+ expect(captured[0].message).toContain('still absent');
110
+ expectTheErrorNotItsText(captured[0], error);
111
+ });
112
+
113
+ test('a genuine conflict: the live schema differs from the intended definition', async () => {
114
+ const error = backendError();
115
+ const changes = { ...noChanges(), columnsToCreate: ['email'] } as unknown as TableChanges;
116
+
117
+ const thrown = await managerWhere({ exists: true, changes })
118
+ .reconcileConcurrentSchemaChange([table], error)
119
+ .catch((each: unknown) => each);
120
+
121
+ expect(thrown).toBe(error);
122
+ expect(captured).toHaveLength(1);
123
+ expect(captured[0].message).toContain('genuine conflict');
124
+ expect(captured[0].obj.remainingChanges).toEqual(changes);
125
+ expectTheErrorNotItsText(captured[0], error);
126
+ });
127
+ });
@@ -0,0 +1,63 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { SourceType } from '@proteinjs/reflection';
4
+ import { graphSerializer } from '@proteinjs/util';
5
+
6
+ /**
7
+ * `@proteinjs/db/SourceRecordLoader` is the qualified name every source-record declaration
8
+ * implements (`class X implements SourceRecordLoader<T>`) and the name `getSourceRecordLoaders`
9
+ * resolves through the reflection registry. The generated index keeps ONE node per qualified
10
+ * name, so a second declaration of the name anywhere in the package's reflected sources leaves
11
+ * which one the artifact records to the build's file order — and the loser disappears without
12
+ * a trace. These tests read the artifacts the build wrote (`generated/index.ts` for the package
13
+ * entry, `generated/test/index.ts` for the `./test` subpath, both built from `src/`) and assert
14
+ * the name holds the declaration interface, not some other declaration that shares it.
15
+ */
16
+ const QUALIFIED_NAME = '@proteinjs/db/SourceRecordLoader';
17
+ const ARTIFACTS = ['generated/index.ts', 'generated/test/index.ts'];
18
+
19
+ /** The fields of an emitted node value these tests read. */
20
+ type EmittedDeclaration = { packageName: string; name: string; qualifiedName: string; sourceType: SourceType };
21
+ type EmittedEdge = { v: string; w: string };
22
+
23
+ const readArtifact = (relativePath: string) => fs.readFileSync(path.join(__dirname, '..', relativePath), 'utf8');
24
+
25
+ /** The source graph exactly as the generated index hands it to `SourceRepository.merge`. */
26
+ const emittedGraph = (artifact: string) => {
27
+ const literal = artifact.match(/^const sourceGraph = (".*");$/m);
28
+ if (!literal) {
29
+ throw new Error('generated index carries no serialized source graph');
30
+ }
31
+ const serialized: string = JSON.parse(literal[1]);
32
+ return graphSerializer.deserialize(serialized.replace(/\\'/g, "'"));
33
+ };
34
+
35
+ describe.each(ARTIFACTS)('the qualified name SourceRecordLoader in %s', (relativePath) => {
36
+ const artifact = readArtifact(relativePath);
37
+ const graph = emittedGraph(artifact);
38
+
39
+ it('names exactly one declaration of this package: the interface declarations implement', () => {
40
+ const qualifiedNames: string[] = graph.nodes();
41
+ const declarations = qualifiedNames
42
+ .map((qualifiedName): EmittedDeclaration | undefined => graph.node(qualifiedName))
43
+ .filter((node) => node?.packageName === '@proteinjs/db' && node.name === 'SourceRecordLoader');
44
+
45
+ expect(declarations).toHaveLength(1);
46
+ expect(declarations[0]).toMatchObject({
47
+ qualifiedName: QUALIFIED_NAME,
48
+ sourceType: SourceType.interface,
49
+ filePath: 'src/source/SourceRecord.ts',
50
+ });
51
+ const parents: EmittedEdge[] = graph.outEdges(QUALIFIED_NAME) ?? [];
52
+ expect(parents.map((edge) => [edge.w, graph.edge(edge)])).toEqual([
53
+ ['@proteinjs/reflection/Loadable', 'extends interface'],
54
+ ]);
55
+ });
56
+
57
+ it('links no runtime value under the name (an interface has none)', () => {
58
+ const linkedNames = (artifact.match(/^\t'[^']+': /gm) ?? []).map((line) => line.slice(2, -3));
59
+
60
+ expect(linkedNames).toContain('@proteinjs/db/MigrationRunner');
61
+ expect(linkedNames).not.toContain(QUALIFIED_NAME);
62
+ });
63
+ });