@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.
- package/CHANGELOG.md +22 -0
- package/dist/generated/index.js +1 -1
- package/dist/generated/index.js.map +1 -1
- package/dist/generated/test/index.js +1 -1
- package/dist/generated/test/index.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/src/DetachedDbOps.d.ts +27 -0
- package/dist/src/DetachedDbOps.d.ts.map +1 -0
- package/dist/src/DetachedDbOps.js +60 -0
- package/dist/src/DetachedDbOps.js.map +1 -0
- package/dist/src/Table.d.ts +19 -0
- package/dist/src/Table.d.ts.map +1 -1
- package/dist/src/Table.js.map +1 -1
- package/dist/src/auth/TableAuth.d.ts +11 -0
- package/dist/src/auth/TableAuth.d.ts.map +1 -1
- package/dist/src/auth/TableAuth.js +19 -0
- package/dist/src/auth/TableAuth.js.map +1 -1
- package/dist/src/tables/MigrationTable.d.ts +12 -2
- package/dist/src/tables/MigrationTable.d.ts.map +1 -1
- package/dist/src/tables/MigrationTable.js +18 -4
- package/dist/src/tables/MigrationTable.js.map +1 -1
- package/dist/test/DetachedDbOps.test.d.ts +2 -0
- package/dist/test/DetachedDbOps.test.d.ts.map +1 -0
- package/dist/test/DetachedDbOps.test.js +158 -0
- package/dist/test/DetachedDbOps.test.js.map +1 -0
- package/dist/test/MigrationAuth.test.js +30 -0
- package/dist/test/MigrationAuth.test.js.map +1 -1
- package/generated/index.ts +1 -1
- package/generated/test/index.ts +1 -1
- package/index.ts +1 -0
- package/package.json +2 -2
- package/src/DetachedDbOps.ts +47 -0
- package/src/Table.ts +19 -0
- package/src/auth/TableAuth.ts +22 -0
- package/src/tables/MigrationTable.ts +18 -4
- package/test/DetachedDbOps.test.ts +90 -0
- package/test/MigrationAuth.test.ts +25 -0
package/index.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@proteinjs/db",
|
|
3
|
-
"version": "1.
|
|
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": "
|
|
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;
|
package/src/auth/TableAuth.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
*
|
|
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: {
|
|
68
|
-
service: {
|
|
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
|
});
|