@proteinjs/db 1.40.0 → 1.42.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 (40) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/generated/index.js +1 -1
  3. package/dist/generated/index.js.map +1 -1
  4. package/dist/generated/test/index.js +1 -1
  5. package/dist/generated/test/index.js.map +1 -1
  6. package/dist/index.d.ts +1 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +1 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/src/DetachedDbOps.d.ts +27 -0
  11. package/dist/src/DetachedDbOps.d.ts.map +1 -0
  12. package/dist/src/DetachedDbOps.js +60 -0
  13. package/dist/src/DetachedDbOps.js.map +1 -0
  14. package/dist/src/Table.d.ts +19 -0
  15. package/dist/src/Table.d.ts.map +1 -1
  16. package/dist/src/Table.js.map +1 -1
  17. package/dist/src/auth/TableAuth.d.ts +11 -0
  18. package/dist/src/auth/TableAuth.d.ts.map +1 -1
  19. package/dist/src/auth/TableAuth.js +19 -0
  20. package/dist/src/auth/TableAuth.js.map +1 -1
  21. package/dist/src/tables/MigrationTable.d.ts +12 -2
  22. package/dist/src/tables/MigrationTable.d.ts.map +1 -1
  23. package/dist/src/tables/MigrationTable.js +18 -4
  24. package/dist/src/tables/MigrationTable.js.map +1 -1
  25. package/dist/test/DetachedDbOps.test.d.ts +2 -0
  26. package/dist/test/DetachedDbOps.test.d.ts.map +1 -0
  27. package/dist/test/DetachedDbOps.test.js +158 -0
  28. package/dist/test/DetachedDbOps.test.js.map +1 -0
  29. package/dist/test/MigrationAuth.test.js +30 -0
  30. package/dist/test/MigrationAuth.test.js.map +1 -1
  31. package/generated/index.ts +1 -1
  32. package/generated/test/index.ts +1 -1
  33. package/index.ts +1 -0
  34. package/package.json +2 -2
  35. package/src/DetachedDbOps.ts +47 -0
  36. package/src/Table.ts +19 -0
  37. package/src/auth/TableAuth.ts +22 -0
  38. package/src/tables/MigrationTable.ts +18 -4
  39. package/test/DetachedDbOps.test.ts +90 -0
  40. package/test/MigrationAuth.test.ts +25 -0
package/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from './src/Db';
2
+ export * from './src/DetachedDbOps';
2
3
  export * from './src/Table';
3
4
  export * from './src/RecordAccessError';
4
5
  export * from './src/auth/TableAuth';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proteinjs/db",
3
- "version": "1.40.0",
3
+ "version": "1.42.0",
4
4
  "main": "./dist/generated/index.js",
5
5
  "types": "./dist/generated/index.d.ts",
6
6
  "exports": {
@@ -66,5 +66,5 @@
66
66
  "ts-jest": "29.1.1",
67
67
  "typescript": "5.2.2"
68
68
  },
69
- "gitHead": "6717117d3893b6c915fe59c86ce8f65c29c49e14"
69
+ "gitHead": "bbbbd77251d6c48bd4dd31331c2bbb8113337597"
70
70
  }
@@ -0,0 +1,47 @@
1
+ import { Logger } from '@proteinjs/logger';
2
+
3
+ /**
4
+ * THE detachment idiom for deliberately fire-and-forget db work — one owner.
5
+ *
6
+ * Under node's default unhandled-rejection policy (node 15+ `--unhandled-rejections=throw`;
7
+ * node 24 in production), a detached db operation that REJECTS — e.g. a write that trips the
8
+ * driver's op deadline (`SpannerConfig.opDeadlineMs`, default 60s) — is an unhandled rejection
9
+ * that KILLS THE PROCESS (observed live: a detached write past the 60s driver deadline aborted
10
+ * the server). Detaching is a legitimate, named decision — the caller does not need the outcome
11
+ * to proceed — but the rejection still needs a terminal observer. This class is that observer:
12
+ * every deliberately un-awaited db operation routes through `run`, which invokes the work,
13
+ * terminally catches (synchronous throws included), and logs the failure WITH the caller's
14
+ * context — never process death, never a silent swallow.
15
+ *
16
+ * NOT for outcomes the caller depends on: work whose failure must alter behavior is awaited at
17
+ * the call site. `run` returns void by design, so a detached result cannot be accidentally
18
+ * consumed.
19
+ */
20
+ export class DetachedDbOps {
21
+ private static logger = new Logger({ name: 'DetachedDbOps' });
22
+
23
+ /**
24
+ * Run `work` detached. Failures — a rejected promise or a synchronous throw — are terminally
25
+ * logged under `description` with `context`, never rethrown and never left unhandled.
26
+ */
27
+ static run(description: string, work: () => Promise<unknown>, context?: Record<string, unknown>): void {
28
+ let result: Promise<unknown>;
29
+ try {
30
+ result = work();
31
+ } catch (error) {
32
+ this.observe(description, error, context);
33
+ return;
34
+ }
35
+ result.catch((error) => this.observe(description, error, context));
36
+ }
37
+
38
+ // --- helpers last ---
39
+
40
+ private static observe(description: string, error: unknown, context?: Record<string, unknown>): void {
41
+ this.logger.error({
42
+ message: `Detached db operation failed: ${description}`,
43
+ error,
44
+ ...(context ? { obj: context } : {}),
45
+ });
46
+ }
47
+ }
package/src/Table.ts CHANGED
@@ -115,6 +115,25 @@ export abstract class Table<T extends Record> implements Loadable, CustomSeriali
115
115
  public cascadeDeleteReferences: () => { table: string; referenceColumn: string }[] = () => [];
116
116
  /** Options for configuring SourceRecords (see {@link SourceRecordOptions}) */
117
117
  public sourceRecordOptions: SourceRecordOptions<T> = {};
118
+ /**
119
+ * Presentation the table declares for the GENERIC record surfaces (@proteinjs/db-ui) — the
120
+ * framework renders what tables declare; per-table display choices never hard-code into the
121
+ * generic components. Distinct from `auth.ui`, which gates WHO may see those surfaces.
122
+ */
123
+ public ui?: {
124
+ recordTable?: {
125
+ /**
126
+ * The columns rendered as the record table's row columns, in this order. `created` and
127
+ * `updated` are appended automatically (the record family's shared face) unless already
128
+ * listed. Undeclared tables get the meaningful-data default pick
129
+ * (db-ui `defaultRecordTableColumns`). Declare when the default pick misses a column a
130
+ * human scans for (e.g. the migration ledger's `duration`) or surfaces one that has no
131
+ * business in a row scan (e.g. an invite's redeemable `token` — row hygiene; the record
132
+ * form still carries the full record).
133
+ */
134
+ columns?: (keyof T & string)[];
135
+ };
136
+ };
118
137
  public auth?: {
119
138
  db?: TableOperationsAuth;
120
139
  service?: TableOperationsAuth;
@@ -45,6 +45,28 @@ export class TableAuthError extends Error {
45
45
  * Util to check which table operations a user can perform
46
46
  */
47
47
  export class TableAuth {
48
+ /**
49
+ * Non-throwing capability read: whether the CURRENT user clears this operation's declared
50
+ * door on this api. The seam UI derivation rides (db-ui's record-table affordances render
51
+ * only the operations the declaration actually opens — a table with no insert door draws no
52
+ * create button); the throwing guards below stay the enforcement path.
53
+ *
54
+ * Answers false — never throws — when the auth machinery itself cannot resolve a user (e.g.
55
+ * a context whose source graph never loaded `AuthenticatedUserRepo`): the same fail-closed
56
+ * reading UserAuth documents for a missing repo, applied to a capability READ.
57
+ */
58
+ canPerform(
59
+ table: Table<any>,
60
+ operation: 'query' | 'insert' | 'update' | 'delete',
61
+ api: 'db' | 'service' = 'db'
62
+ ): boolean {
63
+ try {
64
+ return this.canAccess(table, api, operation);
65
+ } catch {
66
+ return false;
67
+ }
68
+ }
69
+
48
70
  private canAccess(
49
71
  table: Table<any>,
50
72
  api: 'db' | 'service',
@@ -58,14 +58,28 @@ export interface Migration extends SourceRecord {
58
58
  export class MigrationTable extends Table<Migration> {
59
59
  public name = 'migration';
60
60
  /**
61
- * Both doors ride the 'dev' permission (consumer-mapped; admin passes as break-glass),
61
+ * The doors ride the 'dev' permission (consumer-mapped; admin passes as break-glass),
62
62
  * matching MigrationRunner's serviceMetadata: the Migrations record table reads/edits via
63
63
  * the service api, and the runner writes run state via the db api as the calling user.
64
- * Boot-time source-record loading is a system path (getDbAsSystem) and bypasses doors.
64
+ * INSERT deliberately has no door — for anyone, break-glass included: ledger rows are born
65
+ * from source declarations only (boot-time loading rides getDbAsSystem, which bypasses
66
+ * doors), so a caller-path insert could only mint a row no source owns. The generic record
67
+ * surfaces derive their affordances from these doors (db-ui renders no create button here).
65
68
  */
66
69
  public auth: Table<Migration>['auth'] = {
67
- db: { all: { permission: 'dev' } },
68
- service: { all: { permission: 'dev' } },
70
+ db: { query: { permission: 'dev' }, update: { permission: 'dev' }, delete: { permission: 'dev' } },
71
+ service: { query: { permission: 'dev' }, update: { permission: 'dev' }, delete: { permission: 'dev' } },
72
+ };
73
+ /**
74
+ * The row scan the founder actually reads: what ran, how it went, HOW LONG it took
75
+ * (duration — stamped by the runner at completion; the default pick's five-column cap
76
+ * dropped it), then the declaration flags. Failure detail and timestamps stay on the
77
+ * record form.
78
+ */
79
+ public ui: Table<Migration>['ui'] = {
80
+ recordTable: {
81
+ columns: ['description', 'status', 'duration', 'manual', 'preSchemaSync', 'retired'],
82
+ },
69
83
  };
70
84
  public columns = withSourceRecordColumns<Migration>({
71
85
  description: new StringColumn('description', {}, 4000),
@@ -0,0 +1,90 @@
1
+ import { Logger } from '@proteinjs/logger';
2
+ import { DetachedDbOps } from '../src/DetachedDbOps';
3
+
4
+ /**
5
+ * The detachment idiom's contract (the node-24 crash class): a deliberately fire-and-forget db
6
+ * operation that REJECTS — e.g. a write that trips the driver's 60s op deadline — must be
7
+ * terminally observed (logged with the caller's context), never left as an unhandled rejection
8
+ * for node's default policy to kill the process over. These tests run with no emulator: the
9
+ * simulated driver rejection IS the deadline failure's shape.
10
+ *
11
+ * Note: jest itself surfaces unhandled rejections as failures — so each green run here doubles
12
+ * as the no-escape assertion (a dropped catch turns the log pins red AND leaks the rejection).
13
+ */
14
+
15
+ const flushDetached = async () => {
16
+ // The terminal catch attaches synchronously; the rejection lands on the microtask queue.
17
+ await Promise.resolve();
18
+ await new Promise((resolve) => setImmediate(resolve));
19
+ };
20
+
21
+ describe('DetachedDbOps', () => {
22
+ let errorLogs: Array<{ message: string; obj?: Record<string, unknown>; error?: unknown }>;
23
+ let errorSpy: jest.SpyInstance;
24
+
25
+ beforeEach(() => {
26
+ errorLogs = [];
27
+ errorSpy = jest.spyOn(Logger.prototype, 'error').mockImplementation(function (log: any) {
28
+ errorLogs.push(log);
29
+ });
30
+ });
31
+
32
+ afterEach(() => {
33
+ errorSpy.mockRestore();
34
+ });
35
+
36
+ test('a rejecting detached operation is terminally observed — logged with the caller context, no escaping rejection', async () => {
37
+ const deadlineRejection = new Error(
38
+ '4 DEADLINE_EXCEEDED: spanner dml transaction (runTransactionAsync) exceeded the 60000ms op deadline'
39
+ );
40
+ DetachedDbOps.run('write flow-run heartbeat', () => Promise.reject(deadlineRejection), {
41
+ flowRunId: 'run-1',
42
+ table: 'flow_run',
43
+ });
44
+ await flushDetached();
45
+
46
+ expect(errorLogs).toHaveLength(1);
47
+ expect(errorLogs[0].message).toContain('Detached db operation failed');
48
+ expect(errorLogs[0].message).toContain('write flow-run heartbeat');
49
+ expect(errorLogs[0].obj).toEqual({ flowRunId: 'run-1', table: 'flow_run' });
50
+ expect(errorLogs[0].error).toBe(deadlineRejection);
51
+ });
52
+
53
+ test('a synchronously-throwing work function is contained the same way — the caller never sees the throw', async () => {
54
+ const boom = new Error('driver construction failed before any promise existed');
55
+ expect(() =>
56
+ DetachedDbOps.run(
57
+ 'sync-throwing detached op',
58
+ () => {
59
+ throw boom;
60
+ },
61
+ { site: 'test' }
62
+ )
63
+ ).not.toThrow();
64
+ await flushDetached();
65
+
66
+ expect(errorLogs).toHaveLength(1);
67
+ expect(errorLogs[0].message).toContain('sync-throwing detached op');
68
+ expect(errorLogs[0].error).toBe(boom);
69
+ });
70
+
71
+ test('a resolving detached operation logs nothing', async () => {
72
+ let ran = false;
73
+ DetachedDbOps.run('healthy detached op', async () => {
74
+ ran = true;
75
+ return 'ok';
76
+ });
77
+ await flushDetached();
78
+
79
+ expect(ran).toBe(true);
80
+ expect(errorLogs).toHaveLength(0);
81
+ });
82
+
83
+ test('context is optional — a rejection without context still logs the description', async () => {
84
+ DetachedDbOps.run('context-free detached op', () => Promise.reject(new Error('deadline')));
85
+ await flushDetached();
86
+
87
+ expect(errorLogs).toHaveLength(1);
88
+ expect(errorLogs[0].message).toContain('context-free detached op');
89
+ });
90
+ });
@@ -84,4 +84,29 @@ describe('migrations ride the dev permission', () => {
84
84
  expect(() => auth.canUpdate(table, api)).not.toThrow();
85
85
  }
86
86
  });
87
+
88
+ it('insert has no door — for anyone, break-glass included: ledger rows are born from source declarations only', () => {
89
+ const auth = new TableAuth();
90
+ const table = new MigrationTable();
91
+ for (const roles of [['dev-crew'], ['admin']]) {
92
+ setUser(roles);
93
+ for (const api of ['db', 'service'] as const) {
94
+ expect(() => auth.canInsert(table, api)).toThrow(
95
+ 'User is not authorized to insert records into table: migration'
96
+ );
97
+ // The capability read the record surfaces derive affordances from agrees with the gate.
98
+ expect(auth.canPerform(table, 'insert', api)).toBe(false);
99
+ }
100
+ }
101
+ });
102
+
103
+ it('delete keeps its dev door (ledger hygiene stays possible)', () => {
104
+ setUser(['dev-crew']);
105
+ const auth = new TableAuth();
106
+ 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);
110
+ }
111
+ });
87
112
  });