@proteinjs/db 1.26.0 → 1.28.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 (63) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/generated/index.js +3 -3
  3. package/dist/generated/index.js.map +1 -1
  4. package/dist/generated/test/index.js +7 -7
  5. package/dist/generated/test/index.js.map +1 -1
  6. package/dist/index.d.ts +2 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +2 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/src/Db.d.ts +18 -5
  11. package/dist/src/Db.d.ts.map +1 -1
  12. package/dist/src/Db.js +43 -35
  13. package/dist/src/Db.js.map +1 -1
  14. package/dist/src/MigrationRunner.d.ts +9 -0
  15. package/dist/src/MigrationRunner.d.ts.map +1 -1
  16. package/dist/src/MigrationRunner.js +24 -10
  17. package/dist/src/MigrationRunner.js.map +1 -1
  18. package/dist/src/auth/TableAuth.d.ts +7 -1
  19. package/dist/src/auth/TableAuth.d.ts.map +1 -1
  20. package/dist/src/auth/TableAuth.js +7 -1
  21. package/dist/src/auth/TableAuth.js.map +1 -1
  22. package/dist/src/tables/MigrationTable.d.ts +7 -0
  23. package/dist/src/tables/MigrationTable.d.ts.map +1 -1
  24. package/dist/src/tables/MigrationTable.js +10 -0
  25. package/dist/src/tables/MigrationTable.js.map +1 -1
  26. package/dist/src/transaction/TransactionContextFactory.d.ts +8 -1
  27. package/dist/src/transaction/TransactionContextFactory.d.ts.map +1 -1
  28. package/dist/test/MigrationAuth.test.d.ts +2 -0
  29. package/dist/test/MigrationAuth.test.d.ts.map +1 -0
  30. package/dist/test/MigrationAuth.test.js +77 -0
  31. package/dist/test/MigrationAuth.test.js.map +1 -0
  32. package/dist/test/MigrationRunnerContainment.test.d.ts +2 -0
  33. package/dist/test/MigrationRunnerContainment.test.d.ts.map +1 -0
  34. package/dist/test/MigrationRunnerContainment.test.js +144 -0
  35. package/dist/test/MigrationRunnerContainment.test.js.map +1 -0
  36. package/dist/test/TableServiceAuth.test.js +67 -0
  37. package/dist/test/TableServiceAuth.test.js.map +1 -1
  38. package/dist/test/index.d.ts +1 -0
  39. package/dist/test/index.d.ts.map +1 -1
  40. package/dist/test/index.js +1 -0
  41. package/dist/test/index.js.map +1 -1
  42. package/dist/test/util/DbTestEnvironment.d.ts.map +1 -1
  43. package/dist/test/util/DbTestEnvironment.js +5 -0
  44. package/dist/test/util/DbTestEnvironment.js.map +1 -1
  45. package/dist/test/util/testUser.d.ts +5 -0
  46. package/dist/test/util/testUser.d.ts.map +1 -0
  47. package/dist/test/util/testUser.js +17 -0
  48. package/dist/test/util/testUser.js.map +1 -0
  49. package/generated/index.ts +3 -3
  50. package/generated/test/index.ts +7 -7
  51. package/index.ts +2 -0
  52. package/package.json +4 -4
  53. package/src/Db.ts +47 -28
  54. package/src/MigrationRunner.ts +20 -5
  55. package/src/auth/TableAuth.ts +13 -2
  56. package/src/tables/MigrationTable.ts +10 -0
  57. package/src/transaction/TransactionContextFactory.ts +8 -1
  58. package/test/MigrationAuth.test.ts +87 -0
  59. package/test/MigrationRunnerContainment.test.ts +104 -0
  60. package/test/TableServiceAuth.test.ts +90 -1
  61. package/test/index.ts +2 -0
  62. package/test/util/DbTestEnvironment.ts +5 -0
  63. package/test/util/testUser.ts +27 -0
package/index.ts CHANGED
@@ -11,6 +11,8 @@ export * from './src/UpdatePreserving';
11
11
  export * from './src/reference/ReferenceCache';
12
12
  export * from './src/RecordIterator';
13
13
  export * from './src/source/SourceRecord';
14
+ export * from './src/source/SourceRecordRepo';
15
+ export * from './src/MigrationRunner';
14
16
  export * from './src/QueryBuilderFactory';
15
17
  export * from './src/TableWatcher';
16
18
  export * from './src/transaction/Transaction';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proteinjs/db",
3
- "version": "1.26.0",
3
+ "version": "1.28.0",
4
4
  "main": "./dist/generated/index.js",
5
5
  "types": "./dist/generated/index.d.ts",
6
6
  "exports": {
@@ -41,13 +41,13 @@
41
41
  "test": "jest --passWithNoTests"
42
42
  },
43
43
  "dependencies": {
44
- "@proteinjs/db-query": "^1.5.0",
44
+ "@proteinjs/db-query": "^1.6.0",
45
45
  "@proteinjs/logger": "^1.0.21",
46
46
  "@proteinjs/reflection": "^1.1.12",
47
47
  "@proteinjs/serializer": "^1.1.10",
48
48
  "@proteinjs/server-api": "^3.0.10",
49
- "@proteinjs/service": "^1.4.1",
50
- "@proteinjs/user-auth": "^1.2.0",
49
+ "@proteinjs/service": "^1.5.0",
50
+ "@proteinjs/user-auth": "^1.3.0",
51
51
  "@proteinjs/util": "^1.6.1",
52
52
  "moment": "2.29.4",
53
53
  "uuid": "8.3.0"
package/src/Db.ts CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  DefaultTransactionContextFactory,
25
25
  PostCommitHook,
26
26
  getDefaultTransactionContextFactory,
27
+ TransactionContextData,
27
28
  } from './transaction/TransactionContextFactory';
28
29
  import { isInstanceOf } from '@proteinjs/util';
29
30
  import { Reference } from './reference/Reference';
@@ -76,7 +77,6 @@ export class Db<R extends Record = Record> implements DbService<R> {
76
77
  private statementConfigFactory: StatementConfigFactory;
77
78
  private auth = new TableAuth();
78
79
  private tableWatcherRunner = new TableWatcherRunner<R>();
79
- private currentTransaction?: any;
80
80
  private transactionContextFactory: DefaultTransactionContextFactory;
81
81
  public serviceMetadata: Service['serviceMetadata'] = {
82
82
  auth: {
@@ -96,10 +96,6 @@ export class Db<R extends Record = Record> implements DbService<R> {
96
96
  this.transactionContextFactory = transactionContextFactory
97
97
  ? transactionContextFactory
98
98
  : this.getDefaultTransactionContextFactory();
99
- const transactionContext = this.transactionContextFactory.getTransactionContext();
100
- if (transactionContext.currentTransaction) {
101
- this.currentTransaction = transactionContext.currentTransaction;
102
- }
103
99
  }
104
100
 
105
101
  static getDefaultDbDriver(): DbDriver {
@@ -159,7 +155,7 @@ export class Db<R extends Record = Record> implements DbService<R> {
159
155
  serializedRecord as Partial<T>,
160
156
  this.statementConfigFactory.getStatementConfig(config)
161
157
  );
162
- await this.dbDriver.runDml(generateInsert, this.currentTransaction);
158
+ await this.dbDriver.runDml(generateInsert, this.transactionForDriver());
163
159
  await this.tableWatcherRunner.runAfterInsertTableWatchers(table, recordCopy as T);
164
160
  return recordCopy as T;
165
161
  }
@@ -202,7 +198,7 @@ export class Db<R extends Record = Record> implements DbService<R> {
202
198
  qb,
203
199
  this.statementConfigFactory.getStatementConfig(config)
204
200
  );
205
- const recordUpdateCount = await this.dbDriver.runDml(generateUpdate, this.currentTransaction);
201
+ const recordUpdateCount = await this.dbDriver.runDml(generateUpdate, this.transactionForDriver());
206
202
  await this.tableWatcherRunner.runAfterUpdateTableWatchers(table, recordUpdateCount, recordCopy, qb);
207
203
  return recordUpdateCount;
208
204
  }
@@ -219,7 +215,7 @@ export class Db<R extends Record = Record> implements DbService<R> {
219
215
  * or the record no longer exists — a concurrently deleted record wins).
220
216
  */
221
217
  async updateArrayMembership<T extends R>(table: Table<T>, update: ArrayMembershipUpdate): Promise<number> {
222
- if (!this.currentTransaction) {
218
+ if (!this.transactionContextFactory.getTransactionContext().currentTransaction) {
223
219
  const db = this.newSelfWrapDb();
224
220
  return await db.runTransaction(async () => await db.updateArrayMembership(table, update));
225
221
  }
@@ -261,7 +257,7 @@ export class Db<R extends Record = Record> implements DbService<R> {
261
257
  * Self-wraps in a transaction when called outside one. Plain-JSON columns only.
262
258
  */
263
259
  async updatePreserving<T extends R>(table: Table<T>, record: Partial<T>, preserve: PreservedPath[]): Promise<number> {
264
- if (!this.currentTransaction) {
260
+ if (!this.transactionContextFactory.getTransactionContext().currentTransaction) {
265
261
  const db = this.newSelfWrapDb();
266
262
  return await db.runTransaction(async () => await db.updatePreserving(table, record, preserve));
267
263
  }
@@ -316,7 +312,7 @@ export class Db<R extends Record = Record> implements DbService<R> {
316
312
  new StatementFactory<T>().delete(table.name, deleteQb, this.statementConfigFactory.getStatementConfig(config));
317
313
  await this.runColumnBeforeDeletes(table, recordsToDelete);
318
314
  await this.tableWatcherRunner.runBeforeDeleteTableWatchers(table, recordsToDelete, qb, deleteQb);
319
- const recordDeleteCount = await this.dbDriver.runDml(generateDelete, this.currentTransaction);
315
+ const recordDeleteCount = await this.dbDriver.runDml(generateDelete, this.transactionForDriver());
320
316
  await this.runCascadeDeletions(table, recordsToDelete);
321
317
  await this.runColumnReverseCascadeDeletions(table, recordsToDelete);
322
318
  await this.tableWatcherRunner.runAfterDeleteTableWatchers(table, recordDeleteCount, recordsToDelete, qb, deleteQb);
@@ -536,7 +532,7 @@ export class Db<R extends Record = Record> implements DbService<R> {
536
532
 
537
533
  const generateQuery = (config: DbDriverQueryStatementConfig) =>
538
534
  qb.toSql(this.statementConfigFactory.getStatementConfig(config));
539
- const serializedRecords = await this.dbDriver.runQuery(generateQuery, this.currentTransaction);
535
+ const serializedRecords = await this.dbDriver.runQuery(generateQuery, this.transactionForDriver());
540
536
  const recordSerializer = new RecordSerializer(table);
541
537
  const records = await Promise.all(
542
538
  serializedRecords.map(async (serializedRecord) => recordSerializer.deserialize(serializedRecord))
@@ -579,7 +575,7 @@ export class Db<R extends Record = Record> implements DbService<R> {
579
575
  await this.addColumnQueries(table, qb);
580
576
  const generateQuery = (config: DbDriverQueryStatementConfig) =>
581
577
  qb.toSql(this.statementConfigFactory.getStatementConfig(config));
582
- const result = await this.dbDriver.runQuery(generateQuery, this.currentTransaction);
578
+ const result = await this.dbDriver.runQuery(generateQuery, this.transactionForDriver());
583
579
  return result[0]['count'];
584
580
  }
585
581
 
@@ -608,13 +604,19 @@ export class Db<R extends Record = Record> implements DbService<R> {
608
604
  /**
609
605
  * Run a transaction.
610
606
  *
611
- * Use this db instance for any operation you want to include in the transaction.
612
- *
613
- * Note: This method uses Db instance state. Usually it is best to create a new instance
614
- * of Db to run a transaction.
607
+ * Db instances are STATELESS with respect to transactions: every operation resolves the
608
+ * ambient transaction (AsyncLocalStorage) at call time, so any Db instance used inside the
609
+ * transaction body whenever it was constructed rides the transaction. There is no way
610
+ * to issue an operation outside a transaction from inside its body, and no second session
611
+ * is ever acquired inside one (the historical pool-wedge class is unrepresentable —
612
+ * plans/DB_PERF_PLAN.md P2, in the consumer repo).
615
613
  *
616
614
  * Note: Nested transactions are not supported; will throw.
617
615
  *
616
+ * Note: work spawned inside the body but NOT awaited by it escapes the transaction's
617
+ * lifetime while still holding its context — such work fails loudly on its next db
618
+ * operation (see TransactionContextData.ended). Await everything inside the body, or run
619
+ * it outside the transaction.
618
620
  *
619
621
  * Example:
620
622
  *
@@ -630,8 +632,8 @@ export class Db<R extends Record = Record> implements DbService<R> {
630
632
  * ```
631
633
  */
632
634
  async runTransaction<T>(fn: () => Promise<T>): Promise<T> {
633
- if (this.currentTransaction) {
634
- throw new Error(`Nested transactions are not supported. A transaction is already running on this Db instance.`);
635
+ if (this.transactionContextFactory.getTransactionContext().currentTransaction) {
636
+ throw new Error(`Nested transactions are not supported. A transaction is already running in this context.`);
635
637
  }
636
638
 
637
639
  // Reassigned fresh per driver attempt: drivers may retry `fn` on transient aborts (Spanner's
@@ -639,19 +641,18 @@ export class Db<R extends Record = Record> implements DbService<R> {
639
641
  // the attempt that actually commits. Only the committed attempt's queue is drained below.
640
642
  let postCommitHooks: PostCommitHook[] = [];
641
643
  const result = await this.dbDriver.runTransaction(async (transaction) => {
642
- this.currentTransaction = transaction;
644
+ // A fresh ambient store per attempt: the transaction, the attempt's post-commit hook
645
+ // queue (shared by every Db instance created inside — see runAfterCommit), and the
646
+ // ended-tombstone below.
643
647
  postCommitHooks = [];
644
-
648
+ const contextData: TransactionContextData = { currentTransaction: transaction, postCommitHooks };
645
649
  try {
646
- return await this.transactionContextFactory.runInContext(transaction, async () => {
647
- // Seed the post-commit hook queue on the ambient context so every Db instance created
648
- // inside this transaction shares it (see runAfterCommit).
649
- this.transactionContextFactory.getTransactionContext().postCommitHooks = postCommitHooks;
650
- const result = await fn();
651
- return result;
652
- });
650
+ return await this.transactionContextFactory.runInContext(contextData, fn);
653
651
  } finally {
654
- this.currentTransaction = undefined;
652
+ // Tombstone the store: detached work spawned inside the body still holds it by
653
+ // reference — the flag turns its next db operation into a loud error instead of a
654
+ // silent op on a finished transaction (see transactionForDriver).
655
+ contextData.ended = true;
655
656
  }
656
657
  });
657
658
 
@@ -714,6 +715,24 @@ export class Db<R extends Record = Record> implements DbService<R> {
714
715
  return new Db<R>(this.dbDriver, this.getTable, this.transactionContextFactory, this.runAsSystem);
715
716
  }
716
717
 
718
+ /**
719
+ * The transaction every driver call rides, resolved from the ambient context AT CALL TIME
720
+ * (statelessness is the safety property: operations inside a transaction body always ride
721
+ * it; operations outside always use the pool). The one remaining failure shape — work that
722
+ * escaped a finished transaction's body while holding its context — throws here by name;
723
+ * handing the driver an ended transaction would fail anyway, with a far worse error.
724
+ */
725
+ private transactionForDriver(): any {
726
+ const context = this.transactionContextFactory.getTransactionContext();
727
+ if (context.ended) {
728
+ throw new Error(
729
+ `Db operation issued in the context of a transaction that already ended: work spawned inside a runTransaction body (and not awaited by it) survived the transaction. Await the work inside the body, or run it outside the transaction.`
730
+ );
731
+ }
732
+
733
+ return context.currentTransaction;
734
+ }
735
+
717
736
  // Utility: simple chunker
718
737
  private chunk<T>(arr: T[], size: number): T[][] {
719
738
  if (size <= 0) {
@@ -13,22 +13,34 @@ export const getMigrationRunner = () =>
13
13
  export class MigrationRunner implements MigrationRunnerService {
14
14
  private logger = new Logger({ name: this.constructor.name });
15
15
  public serviceMetadata: Service['serviceMetadata'] = {
16
- // Explicit, not implicit: running migrations is admin-only. Omitting the auth block would
17
- // fall back to the same default-deny (admin role), but every service is required to declare
18
- // its auth intent explicitly.
16
+ // Running migrations rides the abstract 'dev' PERMISSION (the developer-surface slug the
17
+ // db-ui dev pages also declare), resolved through the consumer app's PermissionRolesMapping.
18
+ // Admin still passes as break-glass. Matches the migration table's doors below the service.
19
19
  auth: {
20
- roles: ['admin'],
20
+ permission: 'dev',
21
21
  },
22
22
  doNotAwait: true,
23
23
  };
24
24
 
25
- async runMigration(id: string): Promise<void> {
25
+ /**
26
+ * The service dispatches this fire-and-forget (`doNotAwait`). The method is split along that
27
+ * seam: everything knowable before the run starts (a bogus id) throws synchronously — the only
28
+ * path on which an error can still reach the client (the executor wraps it into a
29
+ * ServiceError -> 400). The returned promise MAY REJECT (infrastructure failure while recording
30
+ * run state); the caller owns that rejection — on the service path the executor terminally
31
+ * observes every doNotAwait rejection (logs with method identity, never process death).
32
+ */
33
+ runMigration(id: string): Promise<void> {
26
34
  const migrationTable: Table<Migration> = new MigrationTable();
27
35
  const migration = new SourceRecordRepo().getSourceRecord<Migration>(migrationTable.name, id);
28
36
  if (!migration) {
29
37
  throw new Error(`Unable to find migration source record for id: ${id}`);
30
38
  }
31
39
 
40
+ return this.runAndRecord(migrationTable, migration);
41
+ }
42
+
43
+ private async runAndRecord(migrationTable: Table<Migration>, migration: Migration): Promise<void> {
32
44
  const db = getDb();
33
45
  migration.status = 'running';
34
46
  migration.startTime = moment();
@@ -38,6 +50,9 @@ export class MigrationRunner implements MigrationRunnerService {
38
50
  migration.output = await migration.run();
39
51
  migration.status = 'success';
40
52
  } catch (error: any) {
53
+ // Domain bookkeeping, not containment: a migration that throws is a run OUTCOME, recorded
54
+ // as failure status on the record. Only infrastructure failures (the db.update calls
55
+ // themselves) reject the returned promise.
41
56
  migration.failureMessage = error.message;
42
57
  migration.failureStack = error.stack;
43
58
  migration.status = 'failure';
@@ -5,8 +5,16 @@ import { Table } from '../Table';
5
5
  * @public all users, including guests (do not need to be logged in), do not need any roles
6
6
  * @authenticated - users need to be logged in, do not need any roles
7
7
  * @roles string[] - authenticated users, having at least one of these roles
8
+ * @permission - authenticated users holding an abstract permission slug, resolved to roles at
9
+ * runtime through the consumer app's `PermissionRolesMapping` (see `UserAuth.hasPermission`).
10
+ * Generic tables declare permissions; only the consumer names roles.
8
11
  */
9
- export type Identity = 'public' | 'authenticated' | string[];
12
+ export type PermissionIdentity = { permission: string };
13
+
14
+ export type Identity = 'public' | 'authenticated' | string[] | PermissionIdentity;
15
+
16
+ const isPermissionIdentity = (identity: Identity | undefined): identity is PermissionIdentity =>
17
+ !!identity && typeof identity === 'object' && !Array.isArray(identity) && typeof identity.permission === 'string';
10
18
 
11
19
  /**
12
20
  * These Identities can perform these operations on this table.
@@ -53,7 +61,10 @@ export class TableAuth {
53
61
  (tableAuth.all === 'authenticated' && UserAuth.isLoggedIn()) ||
54
62
  (tableAuth[operation] === 'authenticated' && UserAuth.isLoggedIn()) ||
55
63
  (Array.isArray(tableAuth.all) && UserAuth.hasRoles(tableAuth.all, 'at least one')) ||
56
- (Array.isArray(tableAuth[operation]) && UserAuth.hasRoles(tableAuth[operation] as string[], 'at least one'))
64
+ (Array.isArray(tableAuth[operation]) && UserAuth.hasRoles(tableAuth[operation] as string[], 'at least one')) ||
65
+ (isPermissionIdentity(tableAuth.all) && UserAuth.hasPermission(tableAuth.all.permission)) ||
66
+ (isPermissionIdentity(tableAuth[operation]) &&
67
+ UserAuth.hasPermission((tableAuth[operation] as PermissionIdentity).permission))
57
68
  );
58
69
  }
59
70
 
@@ -17,6 +17,16 @@ export interface Migration extends SourceRecord {
17
17
 
18
18
  export class MigrationTable extends Table<Migration> {
19
19
  public name = 'migration';
20
+ /**
21
+ * Both doors ride the 'dev' permission (consumer-mapped; admin passes as break-glass),
22
+ * matching MigrationRunner's serviceMetadata: the Migrations record table reads/edits via
23
+ * the service api, and the runner writes run state via the db api as the calling user.
24
+ * Boot-time source-record loading is a system path (getDbAsSystem) and bypasses doors.
25
+ */
26
+ public auth: Table<Migration>['auth'] = {
27
+ db: { all: { permission: 'dev' } },
28
+ service: { all: { permission: 'dev' } },
29
+ };
20
30
  public columns = withSourceRecordColumns<Migration>({
21
31
  description: new StringColumn('description', {}, 4000),
22
32
  status: new StringColumn('status', { defaultValue: async () => 'proposed' }),
@@ -16,9 +16,16 @@ export interface TransactionContextData {
16
16
  * `Db.runTransaction` once the driver reports the commit durable.
17
17
  */
18
18
  postCommitHooks?: PostCommitHook[];
19
+ /**
20
+ * Set by `Db.runTransaction` when the transaction completes. Work spawned inside the
21
+ * transaction body but not awaited by it still holds this store by reference (the async
22
+ * context propagates) — the flag turns any later db operation from that escaped context
23
+ * into a loud, named error instead of silently handing the driver a finished transaction.
24
+ */
25
+ ended?: boolean;
19
26
  }
20
27
 
21
28
  export interface DefaultTransactionContextFactory extends Loadable {
22
29
  getTransactionContext(): TransactionContextData;
23
- runInContext<T>(transaction: any, fn: () => Promise<T>): Promise<T>;
30
+ runInContext<T>(context: TransactionContextData, fn: () => Promise<T>): Promise<T>;
24
31
  }
@@ -0,0 +1,87 @@
1
+ import { UserAuth } from '@proteinjs/user-auth';
2
+ // Deep dist path on purpose: the package index doesn't export the auth gate (it is framework
3
+ // plumbing), but the test must exercise the REAL gate, not a re-implementation of its rules.
4
+ import { ServiceAuth } from '@proteinjs/service/dist/src/ServiceAuth';
5
+ import { Method } from '@proteinjs/reflection';
6
+ import { MigrationRunner } from '../src/MigrationRunner';
7
+ import { MigrationTable } from '../src/tables/MigrationTable';
8
+ import { TableAuth } from '../src/auth/TableAuth';
9
+
10
+ /**
11
+ * Migrations ride the 'dev' PERMISSION (plans/ROLES_AND_PERMISSIONS.md decision 4), not the
12
+ * admin role: the MigrationRunner service and the migration table doors resolve 'dev' through
13
+ * the consumer's PermissionRolesMapping, so a consumer-mapped dev-role holder can run
14
+ * migrations while admin still passes everything as break-glass. The mapping deliberately names
15
+ * a role that is NOT the slug ('dev-crew') to prove the indirection, mirroring the
16
+ * TableServiceAuth suite's stubbing idiom.
17
+ */
18
+
19
+ type UserAuthInternals = {
20
+ userRepo?: { getUser: () => { email: string; roles: string[] } };
21
+ permissionRolesMapping?: { getRoles: (permission: string) => string[] | undefined };
22
+ };
23
+
24
+ const setUser = (roles: string[]) => {
25
+ (UserAuth as unknown as UserAuthInternals).userRepo = {
26
+ getUser: () => ({ email: 'user@test.local', roles }),
27
+ };
28
+ };
29
+
30
+ const setMapping = (mapping: { [permission: string]: string[] }) => {
31
+ (UserAuth as unknown as UserAuthInternals).permissionRolesMapping = {
32
+ getRoles: (permission: string) => mapping[permission],
33
+ };
34
+ };
35
+
36
+ const canRunMigrations = () => {
37
+ const method = new Method('runMigration', undefined, true, false, false, false, 'public', []);
38
+ return ServiceAuth.canRunService(new MigrationRunner(), method, ['some-migration-id']);
39
+ };
40
+
41
+ describe('migrations ride the dev permission', () => {
42
+ beforeEach(() => {
43
+ setMapping({ dev: ['dev-crew'] });
44
+ });
45
+
46
+ afterEach(() => {
47
+ (UserAuth as unknown as UserAuthInternals).userRepo = undefined;
48
+ (UserAuth as unknown as UserAuthInternals).permissionRolesMapping = undefined;
49
+ });
50
+
51
+ it(`a consumer-mapped dev-role holder can run migrations and use the migration table's doors`, () => {
52
+ setUser(['dev-crew']);
53
+ expect(canRunMigrations()).toBe(true);
54
+
55
+ const auth = new TableAuth();
56
+ const table = new MigrationTable();
57
+ // The record-table page reads via the service api; the runner updates via the db api.
58
+ for (const api of ['db', 'service'] as const) {
59
+ expect(() => auth.canQuery(table, api)).not.toThrow();
60
+ expect(() => auth.canUpdate(table, api)).not.toThrow();
61
+ }
62
+ });
63
+
64
+ it('a permissionless user is denied the service and both table doors', () => {
65
+ setUser(['ops-team']);
66
+ expect(canRunMigrations()).toBe(false);
67
+
68
+ const auth = new TableAuth();
69
+ const table = new MigrationTable();
70
+ for (const api of ['db', 'service'] as const) {
71
+ expect(() => auth.canQuery(table, api)).toThrow('User is not authorized to query table: migration');
72
+ expect(() => auth.canUpdate(table, api)).toThrow('User is not authorized to update records in table: migration');
73
+ }
74
+ });
75
+
76
+ it('admin passes everything as break-glass', () => {
77
+ setUser(['admin']);
78
+ expect(canRunMigrations()).toBe(true);
79
+
80
+ const auth = new TableAuth();
81
+ const table = new MigrationTable();
82
+ for (const api of ['db', 'service'] as const) {
83
+ expect(() => auth.canQuery(table, api)).not.toThrow();
84
+ expect(() => auth.canUpdate(table, api)).not.toThrow();
85
+ }
86
+ });
87
+ });
@@ -0,0 +1,104 @@
1
+ // EnvInfo.isDev() requires a GlobalDataStorage implementation that only exists in a running app
2
+ // (same mock the @proteinjs/service executor suite uses).
3
+ jest.mock('@proteinjs/server-api', () => ({
4
+ EnvInfo: { isDev: () => true },
5
+ }));
6
+
7
+ import { Logger, Log, DefaultLogWriter } from '@proteinjs/logger';
8
+ import { Interface, Method } from '@proteinjs/reflection';
9
+ import { Serializer } from '@proteinjs/serializer';
10
+ // Deep dist path on purpose: the package index doesn't export the executor (framework plumbing),
11
+ // but the executor check below must run the REAL BUILT owner of the doNotAwait seam.
12
+ import { ServiceExecutor } from '@proteinjs/service/dist/src/ServiceExecutor';
13
+ import { MigrationRunner } from '../src/MigrationRunner';
14
+ import { Migration, MigrationTable } from '../src/tables/MigrationTable';
15
+ import { SourceRecordRepo } from '../src/source/SourceRecordRepo';
16
+ import { registerTestUser, clearTestUser } from './util/testUser';
17
+
18
+ /**
19
+ * MigrationRunnerService is dispatched fire-and-forget (`doNotAwait`): ServiceExecutor calls
20
+ * runMigration WITHOUT awaiting, so its try/catch sees only SYNCHRONOUS throws and the detached
21
+ * promise's rejections are terminally observed by the executor itself (service c16645d — logged
22
+ * with method identity, never an unhandled rejection).
23
+ *
24
+ * MigrationRunner's own contract along that seam:
25
+ * - everything knowable BEFORE the run starts (a bogus id) throws synchronously, the only
26
+ * path on which an error can still reach the client (executor wraps it -> 400);
27
+ * - migration.run() failures are run OUTCOMES, recorded as failure status on the migration
28
+ * record (pinned on the emulator in @proteinjs/db-driver-spanner's MigrationRunner.test.ts);
29
+ * - infrastructure failures (recording run state itself) REJECT the returned promise — the
30
+ * caller owns them. On the service path that caller is the executor.
31
+ */
32
+
33
+ type ExecutorInternals = { logger: Logger };
34
+
35
+ /**
36
+ * This harness loads no reflection graph and registers no DefaultDbDriverFactory, so the body's
37
+ * FIRST db access (`new Db()` constructing its table-watcher runner) throws this. The specific
38
+ * message is a harness accident standing in for any mid-run infrastructure failure (db.update
39
+ * rejecting); the contract under test is that the failure escapes as a rejection at all.
40
+ */
41
+ const infrastructureFailure = 'Unable to find type: @proteinjs/db/TableWatcher';
42
+
43
+ const plantMigration = (id: string): Migration => {
44
+ const migration = {
45
+ id,
46
+ description: 'containment test migration',
47
+ run: async () => undefined,
48
+ } as Migration;
49
+ new SourceRecordRepo().loadSourceRecord(new MigrationTable().name, migration);
50
+ return migration;
51
+ };
52
+
53
+ describe('MigrationRunner containment (doNotAwait dispatch)', () => {
54
+ it('throws synchronously on a bogus migration id, not through the detached promise', () => {
55
+ const runner = new MigrationRunner();
56
+ expect(() => runner.runMigration('bogus-migration-id')).toThrow(
57
+ 'Unable to find migration source record for id: bogus-migration-id'
58
+ );
59
+ });
60
+
61
+ it('rejects the detached promise on a mid-run infrastructure failure — the caller owns it', async () => {
62
+ // The runner no longer swallows infrastructure failures: the rejection escapes to whoever
63
+ // holds the promise.
64
+ plantMigration('containment-test-migration');
65
+
66
+ const runner = new MigrationRunner();
67
+ await expect(runner.runMigration('containment-test-migration')).rejects.toThrow(infrastructureFailure);
68
+ });
69
+
70
+ it('through the real built executor, an infrastructure-failure rejection is terminally logged — not process death', async () => {
71
+ // Admin identity passes the service's 'dev' permission as break-glass (MigrationAuth.test.ts
72
+ // pins the mapping itself).
73
+ registerTestUser();
74
+ try {
75
+ plantMigration('containment-test-migration-executor');
76
+
77
+ const runner = new MigrationRunner();
78
+ const method = new Method('runMigration', undefined, true, false, false, false, 'public', []);
79
+ const _interface = new Interface('@proteinjs/db', 'MigrationRunnerService', [], [method]);
80
+ const executor = new ServiceExecutor(runner, _interface, method);
81
+ const entries: Log[] = [];
82
+ (executor as unknown as ExecutorInternals).logger = new Logger({
83
+ name: 'MigrationRunnerService.runMigration',
84
+ logWriter: { write: (log: Log) => entries.push(log) } as unknown as DefaultLogWriter,
85
+ });
86
+
87
+ // The client response does not carry the failure — execute resolves immediately.
88
+ await expect(
89
+ executor.execute(Serializer.serialize(['containment-test-migration-executor']))
90
+ ).resolves.toBeUndefined();
91
+ // The detached rejection settles after the microtask queue drains; flush macrotasks so the
92
+ // executor's terminal catch has written its log entry. Were the rejection unobserved, jest
93
+ // would surface it as a failure here — and a bare node process would die.
94
+ await new Promise((resolve) => setImmediate(resolve));
95
+ await new Promise((resolve) => setImmediate(resolve));
96
+
97
+ const errorEntry = entries.find((entry) => entry.logLevel === 'error');
98
+ expect(errorEntry?.error?.message).toContain(infrastructureFailure);
99
+ expect(errorEntry?.obj?.functionName).toBe('MigrationRunnerService.runMigration');
100
+ } finally {
101
+ clearTestUser();
102
+ }
103
+ });
104
+ });
@@ -63,6 +63,25 @@ class NoAuthTable extends Table<Doc> {
63
63
  });
64
64
  }
65
65
 
66
+ /**
67
+ * Mirrors the user table's shape: doors keyed to an abstract PERMISSION resolved through the
68
+ * consumer mapping, with a service-protected column reserved to a dedicated service.
69
+ */
70
+ class PermissionGatedTable extends Table<Doc> {
71
+ public name = 'permission_gated_test';
72
+ public auth: Table<Doc>['auth'] = {
73
+ service: {
74
+ query: { permission: 'users' },
75
+ update: { permission: 'users' },
76
+ },
77
+ serviceProtectedColumns: ['owner'],
78
+ };
79
+ public columns = withRecordColumns<Doc>({
80
+ title: new StringColumn('title'),
81
+ owner: new StringColumn('owner'),
82
+ });
83
+ }
84
+
66
85
  /** Read-only intent: only `query` granted (the canDelete-regression shape). */
67
86
  class ReadOnlyTable extends Table<Doc> {
68
87
  public name = 'read_only_test';
@@ -76,7 +95,10 @@ class ReadOnlyTable extends Table<Doc> {
76
95
  });
77
96
  }
78
97
 
79
- type UserAuthInternals = { userRepo?: { getUser: () => { email: string; roles: string[] } } };
98
+ type UserAuthInternals = {
99
+ userRepo?: { getUser: () => { email: string; roles: string[] } };
100
+ permissionRolesMapping?: { getRoles: (permission: string) => string[] | undefined };
101
+ };
80
102
 
81
103
  const setUser = (roles: string[]) => {
82
104
  (UserAuth as unknown as UserAuthInternals).userRepo = {
@@ -84,6 +106,12 @@ const setUser = (roles: string[]) => {
84
106
  };
85
107
  };
86
108
 
109
+ const setMapping = (mapping: { [permission: string]: string[] }) => {
110
+ (UserAuth as unknown as UserAuthInternals).permissionRolesMapping = {
111
+ getRoles: (permission: string) => mapping[permission],
112
+ };
113
+ };
114
+
87
115
  const auth = () => new TableServiceAuth();
88
116
 
89
117
  /** A denial must be a client-safe ServiceError naming the table and operation. */
@@ -142,6 +170,67 @@ describe('TableServiceAuth — per-operation service grants', () => {
142
170
  });
143
171
  });
144
172
 
173
+ describe('TableServiceAuth — permission-keyed doors (the user table shape)', () => {
174
+ afterEach(() => {
175
+ (UserAuth as unknown as UserAuthInternals).userRepo = undefined;
176
+ (UserAuth as unknown as UserAuthInternals).permissionRolesMapping = undefined;
177
+ });
178
+
179
+ it(`grants a holder of the consumer's mapped role; denies without the role`, () => {
180
+ const table = new PermissionGatedTable();
181
+ setMapping({ users: ['support'] });
182
+
183
+ setUser(['support']);
184
+ expect(auth().canAccess('query', [table, {}])).toBe(true);
185
+ expect(auth().canAccess('update', [table, { id: 'x', title: 't' }])).toBe(true);
186
+
187
+ setUser(['dev']);
188
+ expectDenied(
189
+ () => auth().canAccess('query', [table, {}]),
190
+ 'User is not authorized to query table: permission_gated_test'
191
+ );
192
+ });
193
+
194
+ it('denies everyone but admin when no mapping is registered (default deny)', () => {
195
+ const table = new PermissionGatedTable();
196
+ setUser(['support']);
197
+ expectDenied(
198
+ () => auth().canAccess('query', [table, {}]),
199
+ 'User is not authorized to query table: permission_gated_test'
200
+ );
201
+
202
+ setUser(['admin']);
203
+ expect(auth().canAccess('query', [table, {}])).toBe(true);
204
+ });
205
+
206
+ it('operations without a grant stay closed even for permission holders (and admin)', () => {
207
+ const table = new PermissionGatedTable();
208
+ setMapping({ users: ['support'] });
209
+ setUser(['support']);
210
+ expectDenied(
211
+ () => auth().canAccess('insert', [table, { title: 't' }]),
212
+ 'User is not authorized to insert records into table: permission_gated_test'
213
+ );
214
+ // An auth block with no grant for the operation is a deliberate lock, not an admin default —
215
+ // the audit-table shape (role_grant_event) depends on this.
216
+ setUser(['admin']);
217
+ expectDenied(
218
+ () => auth().canAccess('delete', [table, { id: 'x' }]),
219
+ 'User is not authorized to delete records from table: permission_gated_test'
220
+ );
221
+ });
222
+
223
+ it('the protected column stays unwritable even for a permission holder with the update grant', () => {
224
+ const table = new PermissionGatedTable();
225
+ setMapping({ users: ['support'] });
226
+ setUser(['support']);
227
+ expectDenied(
228
+ () => auth().canAccess('update', [table, { id: 'x', owner: 'someone' }]),
229
+ "Column 'owner' cannot be written via the db service on table: permission_gated_test"
230
+ );
231
+ });
232
+ });
233
+
145
234
  describe('TableServiceAuth — default deny (no auth block)', () => {
146
235
  afterEach(() => {
147
236
  (UserAuth as unknown as UserAuthInternals).userRepo = undefined;
package/test/index.ts CHANGED
@@ -5,6 +5,8 @@ export * from './reusable/DynamicReferenceColumnTests';
5
5
  export * from './reusable/TransactionTests';
6
6
  export * from './reusable/CascadeDeleteTests';
7
7
 
8
+ export * from './util/testUser';
9
+
8
10
  export * from './util/tables/cascadeDeleteTestTables';
9
11
  export * from './util/tables/columnTypesTestTables';
10
12
  export * from './util/tables/crudTestTables';