@everystack/server 0.4.15 → 0.4.17
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/package.json +3 -3
- package/src/db-doctor.ts +66 -5
- package/src/db-session.ts +252 -0
- package/src/plugin.ts +30 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/server",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.17",
|
|
4
4
|
"description": "Server runtime primitives for Lambda — event adapters, routing, SSR, image processing",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"author": "Scalable Technology, Inc. <licensing@scalable.technology>",
|
|
@@ -163,8 +163,8 @@
|
|
|
163
163
|
"sst": "4.13.1",
|
|
164
164
|
"ts-jest": "29.4.9",
|
|
165
165
|
"typescript": "5.9.3",
|
|
166
|
-
"@everystack/
|
|
167
|
-
"@everystack/
|
|
166
|
+
"@everystack/cli": "0.4.47",
|
|
167
|
+
"@everystack/auth": "0.4.1"
|
|
168
168
|
},
|
|
169
169
|
"scripts": {
|
|
170
170
|
"test": "jest",
|
package/src/db-doctor.ts
CHANGED
|
@@ -94,6 +94,14 @@ export interface PublicExecutableFn {
|
|
|
94
94
|
schema: string;
|
|
95
95
|
name: string;
|
|
96
96
|
secdef: boolean;
|
|
97
|
+
/**
|
|
98
|
+
* True when some RLS policy expression names this function. Such a function is evaluated as
|
|
99
|
+
* the QUERYING role, so a bare `REVOKE ... FROM PUBLIC` with no grant-back breaks every policy
|
|
100
|
+
* that calls it — `auth.uid()` being the canonical case. Measured from pg_policy rather than
|
|
101
|
+
* guessed from a name list, so it stays right for an app whose policies call its own helpers.
|
|
102
|
+
* Absent on an older probe (treated as unknown, never as false).
|
|
103
|
+
*/
|
|
104
|
+
policyReferenced?: boolean;
|
|
97
105
|
}
|
|
98
106
|
|
|
99
107
|
/** A view ('v') or matview ('m') with no grants to any non-owner role. */
|
|
@@ -187,7 +195,14 @@ export const PUBLIC_EXECUTABLE_FUNCTIONS_SQL = `
|
|
|
187
195
|
SELECT
|
|
188
196
|
n.nspname AS schema,
|
|
189
197
|
p.proname AS name,
|
|
190
|
-
p.prosecdef AS secdef
|
|
198
|
+
p.prosecdef AS secdef,
|
|
199
|
+
EXISTS (
|
|
200
|
+
SELECT 1
|
|
201
|
+
FROM pg_policy pol
|
|
202
|
+
WHERE COALESCE(pg_get_expr(pol.polqual, pol.polrelid), '')
|
|
203
|
+
|| ' ' || COALESCE(pg_get_expr(pol.polwithcheck, pol.polrelid), '')
|
|
204
|
+
~ ('\\m' || n.nspname || '\\.' || p.proname || '\\M')
|
|
205
|
+
) AS policy_referenced
|
|
191
206
|
FROM pg_proc p
|
|
192
207
|
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
193
208
|
CROSS JOIN LATERAL aclexplode(COALESCE(p.proacl, acldefault('f', p.proowner))) a
|
|
@@ -411,6 +426,17 @@ export function buildDoctorReport(probe: DoctorProbe, options: DoctorReportOptio
|
|
|
411
426
|
// (deriveForceRlsCarveouts): auth.users and the six other auth.* tables (functions),
|
|
412
427
|
// ops.runs and dist.blobs (worker), the notification tables. No hand-list, no framework
|
|
413
428
|
// default that could drift from — or fall short of — the modeled set.
|
|
429
|
+
//
|
|
430
|
+
// ABSENT IS NOT EMPTY. `forceRlsCarveouts` comes from the app's models via its dbPlugin. A
|
|
431
|
+
// deploy that never passes it is not a deploy with no owner-written tables — it is a deploy
|
|
432
|
+
// whose declarations we cannot see. Collapsing the two with `?? []` made db:doctor red-flag
|
|
433
|
+
// seven owner-written auth tables and print `FORCE ROW LEVEL SECURITY` for each, unqualified.
|
|
434
|
+
// Running that paste breaks the auth paths: FORCE applies RLS to the OWNER, which is exactly
|
|
435
|
+
// the SECURITY DEFINER function that has to write those tables. The check has to say what it
|
|
436
|
+
// could not see rather than assert a fix it cannot justify. (Consumer report 2026-08-04: they
|
|
437
|
+
// read the remediation, recognised the trap themselves, and refused to run it. The next
|
|
438
|
+
// operator will not.)
|
|
439
|
+
const carveoutsDeclared = options.forceRlsCarveouts !== undefined;
|
|
414
440
|
const FORCE_RLS_CARVEOUTS = new Set(options.forceRlsCarveouts ?? []);
|
|
415
441
|
const allUnforced = probe.forceRls.filter((r) => r.enabled && !r.forced);
|
|
416
442
|
const carved = allUnforced.filter((r) => FORCE_RLS_CARVEOUTS.has(`${r.schema}.${r.table}`));
|
|
@@ -425,11 +451,23 @@ export function buildDoctorReport(probe: DoctorProbe, options: DoctorReportOptio
|
|
|
425
451
|
detail: 'no tables have RLS enabled yet (nothing to force)',
|
|
426
452
|
});
|
|
427
453
|
} else if (unforced.length > 0) {
|
|
454
|
+
const names = unforced.map((r) => `${r.schema}.${r.table}`).join(', ');
|
|
455
|
+
const fix = unforced.map((r) => `ALTER TABLE ${r.schema}.${r.table} FORCE ROW LEVEL SECURITY;`).join(' ');
|
|
456
|
+
// The STATUS stays 'fail' whether or not the carveouts were declared. An unforced RLS table
|
|
457
|
+
// is a real finding, and an app with genuinely nothing to carve out passes no carveouts —
|
|
458
|
+
// indistinguishable from one that never wired them, so downgrading on absence would weaken
|
|
459
|
+
// the check for everyone to spare the misconfigured. What changes is the REMEDIATION, which
|
|
460
|
+
// is where the actual harm was: a flat list of FORCE statements that breaks owner-mediated
|
|
461
|
+
// writes if any of these tables is one.
|
|
428
462
|
checks.push({
|
|
429
463
|
name: 'FORCE ROW LEVEL SECURITY on RLS tables',
|
|
430
464
|
status: 'fail',
|
|
431
|
-
detail:
|
|
432
|
-
|
|
465
|
+
detail: carveoutsDeclared
|
|
466
|
+
? `RLS enabled but not FORCEd (owner bypasses policies): ${names}`
|
|
467
|
+
: `RLS enabled but not FORCEd: ${names}. NOTE: this deploy's dbPlugin passes no forceRlsCarveouts, so db:doctor cannot tell an unprotected table from one deliberately ENABLE-not-FORCE because SECURITY DEFINER functions or a worker own its writes.`,
|
|
468
|
+
remediation: carveoutsDeclared
|
|
469
|
+
? fix
|
|
470
|
+
: `Do NOT paste this blind — FORCE applies RLS to the table OWNER, so forcing a table whose writes are owner-mediated breaks those writes, both before and after the mediating functions exist. First pass forceRlsCarveouts (and functionSchemas) from your models into dbPlugin and re-run: the answer may be that nothing is wrong. Then, for the tables that remain and are NOT owner-written: ${fix}`,
|
|
433
471
|
});
|
|
434
472
|
} else {
|
|
435
473
|
checks.push({
|
|
@@ -486,12 +524,34 @@ export function buildDoctorReport(probe: DoctorProbe, options: DoctorReportOptio
|
|
|
486
524
|
const offenders = probe.publicExecutableFunctions;
|
|
487
525
|
if (offenders.length > 0) {
|
|
488
526
|
const label = (f: PublicExecutableFn): string => `${f.schema}.${f.name}${f.secdef ? ' (SECURITY DEFINER)' : ''}`;
|
|
527
|
+
// SPLIT BY WHO CALLS IT, because one remediation is not safe for both groups.
|
|
528
|
+
//
|
|
529
|
+
// A function named in an RLS policy expression is evaluated as the QUERYING role. Revoking
|
|
530
|
+
// PUBLIC without granting the request roles back breaks every policy that calls it — the
|
|
531
|
+
// canonical case being `auth.uid()`. This used to print one flat REVOKE list covering
|
|
532
|
+
// SECDEF internals and policy helpers together, and a consumer who pasted it would have
|
|
533
|
+
// taken their own policies down. They spotted the trap and refused to run it; the next
|
|
534
|
+
// operator will not. `policyReferenced` is MEASURED from pg_policy, not guessed from a
|
|
535
|
+
// name list, so it stays correct for an app whose policies call its own helpers.
|
|
536
|
+
const policyFns = offenders.filter((f) => f.policyReferenced === true);
|
|
537
|
+
const plainFns = offenders.filter((f) => f.policyReferenced !== true);
|
|
538
|
+
const revoke = (f: PublicExecutableFn): string => `REVOKE ALL ON FUNCTION ${f.schema}.${f.name} FROM PUBLIC;`;
|
|
539
|
+
const parts: string[] = [];
|
|
540
|
+
if (plainFns.length > 0) {
|
|
541
|
+
parts.push(
|
|
542
|
+
`${plainFns.map(revoke).join(' ')} Then grant the intended callers — or declare the function with defineFunction + can('execute', { role }) so the compiler emits both. (A SECURITY DEFINER internal called only from other SECDEF functions needs NO grant back: those run as the owner.)`,
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
if (policyFns.length > 0) {
|
|
546
|
+
parts.push(
|
|
547
|
+
`CAUTION — ${policyFns.map((f) => `${f.schema}.${f.name}`).join(', ')} ${policyFns.length === 1 ? 'is' : 'are'} referenced by an RLS POLICY and evaluated as the querying role. Revoking alone will break every policy that calls ${policyFns.length === 1 ? 'it' : 'them'}. Revoke AND grant the request roles back in the same change: ${policyFns.map((f) => `${revoke(f)} GRANT EXECUTE ON FUNCTION ${f.schema}.${f.name} TO anon, authenticated, admin;`).join(' ')}`,
|
|
548
|
+
);
|
|
549
|
+
}
|
|
489
550
|
checks.push({
|
|
490
551
|
name: 'Functions are not PUBLIC-executable',
|
|
491
552
|
status: offenders.some((f) => f.secdef) ? 'fail' : 'warn',
|
|
492
553
|
detail: `PUBLIC can EXECUTE (PostgreSQL's silent-open default — a NULL proacl grants it): ${offenders.map(label).join(', ')}`,
|
|
493
|
-
remediation:
|
|
494
|
-
+ ` Then grant the intended callers — or declare the function with defineFunction + can('execute', { role }) so the compiler emits both.`,
|
|
554
|
+
remediation: parts.join(' '),
|
|
495
555
|
});
|
|
496
556
|
} else {
|
|
497
557
|
checks.push({
|
|
@@ -638,6 +698,7 @@ export function rowToPublicExecutableFn(row: any): PublicExecutableFn {
|
|
|
638
698
|
schema: String(row.schema),
|
|
639
699
|
name: String(row.name),
|
|
640
700
|
secdef: pgBool(row.secdef),
|
|
701
|
+
...(row.policy_referenced === undefined ? {} : { policyReferenced: pgBool(row.policy_referenced) }),
|
|
641
702
|
};
|
|
642
703
|
}
|
|
643
704
|
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* db:session — N statements, ONE connection, ONE transaction, N result sets.
|
|
3
|
+
*
|
|
4
|
+
* The problem it exists to solve: the ops Lambda holds one database connection per
|
|
5
|
+
* container and containers are ambient. `db:query` is one statement per invoke, so a
|
|
6
|
+
* caller that needs several statements to describe ONE moment (a catalog
|
|
7
|
+
* introspection, say) gets them answered by several containers on several
|
|
8
|
+
* connections. Any session state one statement sets is invisible to the next, and
|
|
9
|
+
* the assembled answer can be a collage of two renderings of one database. That is
|
|
10
|
+
* not a tuning problem; it is the absence of a session.
|
|
11
|
+
*
|
|
12
|
+
* So the session is the primitive. The caller sends its statements together, the
|
|
13
|
+
* Lambda runs them on one connection inside one transaction, and sends the result
|
|
14
|
+
* sets back in input order. The SQL stays in the caller (no version skew between a
|
|
15
|
+
* deployed server and the CLI that asks it questions), and the policy — read-only,
|
|
16
|
+
* isolation level, search_path — is the caller's too. This module hardcodes none of it.
|
|
17
|
+
*
|
|
18
|
+
* Three properties are load bearing:
|
|
19
|
+
*
|
|
20
|
+
* - **`searchPath` emits `SET LOCAL`, never a bare `SET`.** A bare `SET` on a pooled,
|
|
21
|
+
* persistent connection outlives its command and pins whichever connection answered.
|
|
22
|
+
* `SET LOCAL` reverts with the transaction, so reversion is structural rather than a
|
|
23
|
+
* compensating statement that may not run. The pin consumes NO result slot — the
|
|
24
|
+
* caller indexes results by statement, and a hidden extra slot would shift every one.
|
|
25
|
+
* - **Per-statement `allowFailure`** wraps a statement in a savepoint, so "this catalog
|
|
26
|
+
* table may not exist yet" (`derived_provenance`, `backfill_log`, `schema_log`) costs
|
|
27
|
+
* the one slot rather than the whole transaction. Same semantics as psql's
|
|
28
|
+
* `ON_ERROR_ROLLBACK`. A statement NOT flagged is fatal: it aborts the transaction and
|
|
29
|
+
* the whole session fails, because a partial answer to a consistency question is worse
|
|
30
|
+
* than no answer. The savepoint is taken through the DRIVER's nested transaction, not
|
|
31
|
+
* by issuing `SAVEPOINT` as SQL: postgres.js records any query error inside a
|
|
32
|
+
* transaction scope and re-throws it after the callback returns, so a hand-rolled
|
|
33
|
+
* `ROLLBACK TO SAVEPOINT` recovers the database and still loses the session. Only a
|
|
34
|
+
* nested scope gets its own error bookkeeping. (Proven on a live PostgreSQL — the
|
|
35
|
+
* hand-rolled version passed against a fake and failed against the driver.)
|
|
36
|
+
* - **The response is gzipped and size-checked.** Lambda's invoke response limit is 6 MB
|
|
37
|
+
* and catalog JSON compresses 10-20x, so gzip alone carries any real schema. When it
|
|
38
|
+
* does not, this HARD ERRORS with the size — it never truncates, and it never returns
|
|
39
|
+
* a partial read that a fingerprint would silently be computed from.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import { gzipSync } from 'node:zlib';
|
|
43
|
+
|
|
44
|
+
/** One statement in a session. A bare string is shorthand for `{ sql, allowFailure: false }`. */
|
|
45
|
+
export interface DbSessionStatement {
|
|
46
|
+
sql: string;
|
|
47
|
+
/** Survive a failure of THIS statement (savepoint), leaving the other slots intact. */
|
|
48
|
+
allowFailure?: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface DbSessionPayload {
|
|
52
|
+
statements?: Array<DbSessionStatement | string>;
|
|
53
|
+
/** `SET TRANSACTION READ ONLY` — the caller's policy, never assumed here. */
|
|
54
|
+
readOnly?: boolean;
|
|
55
|
+
/** Transaction isolation level, e.g. 'repeatable read' for a single-snapshot read. */
|
|
56
|
+
isolation?: string;
|
|
57
|
+
/** Pinned via `SET LOCAL search_path`; consumes no result slot. */
|
|
58
|
+
searchPath?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A statement that failed under `allowFailure`. Occupies its slot; carries the cause. */
|
|
62
|
+
export interface DbSessionStatementError {
|
|
63
|
+
everystackSessionError: true;
|
|
64
|
+
message: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** One slot: the statement's rows, or the marker for an allowed failure. */
|
|
68
|
+
export type DbSessionResult = any[] | DbSessionStatementError;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Compressed-response ceiling. Lambda's synchronous invoke response limit is 6 MB;
|
|
72
|
+
* this leaves headroom for the JSON envelope and base64 expansion of the gzip payload.
|
|
73
|
+
*/
|
|
74
|
+
export const SESSION_MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
|
|
75
|
+
|
|
76
|
+
/** The isolation levels PostgreSQL accepts. Anything else is refused, never coerced. */
|
|
77
|
+
const ISOLATION_LEVELS = new Set([
|
|
78
|
+
'read uncommitted',
|
|
79
|
+
'read committed',
|
|
80
|
+
'repeatable read',
|
|
81
|
+
'serializable',
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
/** A bare SQL identifier — the only shape a search_path element may take. */
|
|
85
|
+
const PLAIN_IDENT = /^[A-Za-z_][A-Za-z0-9_$]*$/;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Build the `SET LOCAL search_path` statement for a caller-supplied path.
|
|
89
|
+
*
|
|
90
|
+
* The path arrives as a string over an IAM-gated invoke and is interpolated into SQL,
|
|
91
|
+
* so it is validated as a comma-separated list of plain identifiers and rejected
|
|
92
|
+
* otherwise. `$user` is deliberately NOT accepted: this pin exists to make deparsed
|
|
93
|
+
* expressions canonical, and a role-dependent element would make the capture depend on
|
|
94
|
+
* who read it.
|
|
95
|
+
*/
|
|
96
|
+
export function buildSearchPathSql(searchPath: string): string {
|
|
97
|
+
const parts = searchPath.split(',').map((p) => p.trim()).filter((p) => p.length > 0);
|
|
98
|
+
if (parts.length === 0) {
|
|
99
|
+
throw new Error('searchPath must name at least one schema');
|
|
100
|
+
}
|
|
101
|
+
for (const part of parts) {
|
|
102
|
+
if (!PLAIN_IDENT.test(part)) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`searchPath element ${JSON.stringify(part)} is not a plain identifier — db:session pins only bare schema names`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return `SET LOCAL search_path = ${parts.map((p) => `"${p}"`).join(', ')}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Normalize the payload's statements, or explain what is wrong with them. */
|
|
112
|
+
export function normalizeStatements(
|
|
113
|
+
statements: Array<DbSessionStatement | string> | undefined,
|
|
114
|
+
): DbSessionStatement[] {
|
|
115
|
+
if (!Array.isArray(statements) || statements.length === 0) {
|
|
116
|
+
throw new Error('db:session requires a non-empty { statements } array');
|
|
117
|
+
}
|
|
118
|
+
return statements.map((s, i) => {
|
|
119
|
+
const stmt = typeof s === 'string' ? { sql: s } : s;
|
|
120
|
+
if (!stmt || typeof stmt.sql !== 'string' || stmt.sql.trim() === '') {
|
|
121
|
+
throw new Error(`db:session statement ${i} has no sql`);
|
|
122
|
+
}
|
|
123
|
+
return { sql: stmt.sql, allowFailure: stmt.allowFailure === true };
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The refusal when the compressed result set will not fit in an invoke response. */
|
|
128
|
+
export function oversizeResponseError(bytes: number, statements: number): string {
|
|
129
|
+
const mb = (bytes / (1024 * 1024)).toFixed(1);
|
|
130
|
+
const capMb = (SESSION_MAX_RESPONSE_BYTES / (1024 * 1024)).toFixed(0);
|
|
131
|
+
return [
|
|
132
|
+
`db:session response too large: ${mb} MB compressed from ${statements} statements exceeds the ${capMb} MB invoke-response ceiling.`,
|
|
133
|
+
'Nothing was truncated and nothing partial was returned — a partial read is not a state anything may be computed from.',
|
|
134
|
+
'Split the read into more sessions, or narrow it (fewer schemas, fewer statements).',
|
|
135
|
+
].join(' ');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The transaction handle: statements, plus a NESTED transaction — which drizzle maps to
|
|
140
|
+
* the driver's savepoint, the only mechanism that survives a failed statement.
|
|
141
|
+
*/
|
|
142
|
+
export interface SessionTx {
|
|
143
|
+
execute: (query: any) => Promise<any>;
|
|
144
|
+
transaction: <T>(fn: (sp: SessionTx) => Promise<T>) => Promise<T>;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The minimal database surface this needs: drizzle's `transaction`, nothing more. */
|
|
148
|
+
export interface SessionCapableDb {
|
|
149
|
+
transaction: <T>(
|
|
150
|
+
fn: (tx: SessionTx) => Promise<T>,
|
|
151
|
+
config?: { isolationLevel?: any; accessMode?: any },
|
|
152
|
+
) => Promise<T>;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const rowsOf = (result: any): any[] =>
|
|
156
|
+
Array.isArray(result) ? result : result?.rows ?? [];
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Run N statements as ONE session and return the result sets, in input order.
|
|
160
|
+
*
|
|
161
|
+
* The core, shared by the `db:session` action (which gzips this) and by the in-Lambda
|
|
162
|
+
* verbs that need a consistent multi-statement read on the operator connection —
|
|
163
|
+
* `db:apply`'s fingerprint endpoints and `db:reconcile`'s catalog read. One
|
|
164
|
+
* implementation, so the wire path and the in-process path cannot mean different things
|
|
165
|
+
* by "one session".
|
|
166
|
+
*/
|
|
167
|
+
export async function runSessionStatements(
|
|
168
|
+
db: SessionCapableDb,
|
|
169
|
+
statements: Array<DbSessionStatement | string> | undefined,
|
|
170
|
+
opts: Omit<DbSessionPayload, 'statements'> | undefined,
|
|
171
|
+
raw: (query: string) => any,
|
|
172
|
+
): Promise<DbSessionResult[]> {
|
|
173
|
+
const stmts = normalizeStatements(statements);
|
|
174
|
+
const searchPathSql =
|
|
175
|
+
opts?.searchPath !== undefined ? buildSearchPathSql(String(opts.searchPath)) : undefined;
|
|
176
|
+
|
|
177
|
+
let isolationLevel: string | undefined;
|
|
178
|
+
if (opts?.isolation !== undefined) {
|
|
179
|
+
const level = String(opts.isolation).toLowerCase().trim();
|
|
180
|
+
if (!ISOLATION_LEVELS.has(level)) {
|
|
181
|
+
throw new Error(
|
|
182
|
+
`db:session isolation ${JSON.stringify(opts.isolation)} is not a PostgreSQL isolation level`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
isolationLevel = level;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// An EMPTY config is not the same as no config: drizzle treats any object as a request
|
|
189
|
+
// to configure the transaction and emits a chunk-less `SET TRANSACTION`, which is a
|
|
190
|
+
// syntax error. Declare nothing by passing nothing.
|
|
191
|
+
const txConfig = {
|
|
192
|
+
...(isolationLevel ? { isolationLevel } : {}),
|
|
193
|
+
...(opts?.readOnly ? { accessMode: 'read only' } : {}),
|
|
194
|
+
};
|
|
195
|
+
const hasConfig = Object.keys(txConfig).length > 0;
|
|
196
|
+
|
|
197
|
+
const results: DbSessionResult[] = [];
|
|
198
|
+
await db.transaction(
|
|
199
|
+
async (tx) => {
|
|
200
|
+
// The pin runs FIRST and its result is discarded — callers index by statement.
|
|
201
|
+
if (searchPathSql) await tx.execute(raw(searchPathSql));
|
|
202
|
+
|
|
203
|
+
for (const stmt of stmts) {
|
|
204
|
+
if (!stmt.allowFailure) {
|
|
205
|
+
// Fatal by default: a failure here aborts the transaction and the session.
|
|
206
|
+
results.push(rowsOf(await tx.execute(raw(stmt.sql))));
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
try {
|
|
210
|
+
// A nested transaction IS the savepoint, taken by the driver so its own error
|
|
211
|
+
// bookkeeping is scoped to it. Rolling back by hand would recover the database
|
|
212
|
+
// and still lose the session.
|
|
213
|
+
results.push(
|
|
214
|
+
await tx.transaction(async (sp) => rowsOf(await sp.execute(raw(stmt.sql)))),
|
|
215
|
+
);
|
|
216
|
+
} catch (err: any) {
|
|
217
|
+
results.push({
|
|
218
|
+
everystackSessionError: true,
|
|
219
|
+
message: err?.message || String(err),
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
hasConfig ? txConfig : undefined,
|
|
225
|
+
);
|
|
226
|
+
return results;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Run a session and return the gzipped result sets (or a typed error object).
|
|
231
|
+
*
|
|
232
|
+
* The return value is the action's response verbatim: `{ gzip }` on success,
|
|
233
|
+
* `{ error }` on any refusal or unrecovered failure. Never both.
|
|
234
|
+
*/
|
|
235
|
+
export async function runDbSession(
|
|
236
|
+
db: SessionCapableDb,
|
|
237
|
+
payload: DbSessionPayload | undefined,
|
|
238
|
+
raw: (query: string) => any,
|
|
239
|
+
): Promise<{ gzip: string } | { error: string }> {
|
|
240
|
+
let results: DbSessionResult[];
|
|
241
|
+
try {
|
|
242
|
+
results = await runSessionStatements(db, payload?.statements, payload, raw);
|
|
243
|
+
} catch (err: any) {
|
|
244
|
+
return { error: err?.message || String(err) };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const gzip = gzipSync(Buffer.from(JSON.stringify(results), 'utf8')).toString('base64');
|
|
248
|
+
if (gzip.length > SESSION_MAX_RESPONSE_BYTES) {
|
|
249
|
+
return { error: oversizeResponseError(gzip.length, results.length) };
|
|
250
|
+
}
|
|
251
|
+
return { gzip };
|
|
252
|
+
}
|
package/src/plugin.ts
CHANGED
|
@@ -706,6 +706,29 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
706
706
|
return { rows };
|
|
707
707
|
};
|
|
708
708
|
|
|
709
|
+
// A SessionRunner over THIS container's operator connection, for the in-Lambda verbs
|
|
710
|
+
// (db:apply's fingerprint endpoints, db:reconcile's catalog read). Same core the
|
|
711
|
+
// db:session action serves over the wire, so both mean the same thing by "one session".
|
|
712
|
+
const sessionOverOpsDb = (sql: { raw: (q: string) => any }) =>
|
|
713
|
+
async (statements: any, opts?: any) => {
|
|
714
|
+
const { runSessionStatements } = await import('./db-session');
|
|
715
|
+
return runSessionStatements(opsDb as any, statements, opts, (q: string) => sql.raw(q));
|
|
716
|
+
};
|
|
717
|
+
|
|
718
|
+
// --- db:session — N statements, ONE connection, ONE transaction ---
|
|
719
|
+
// The primitive that makes a multi-statement remote read describe ONE moment.
|
|
720
|
+
// `db:query` is one statement per invoke, so a five-query introspection can be
|
|
721
|
+
// answered by five containers holding five connections, and any session state one
|
|
722
|
+
// statement sets is invisible to the next. This runs the caller's statements
|
|
723
|
+
// together on the single ops connection inside one transaction and returns the
|
|
724
|
+
// result sets in input order, gzipped. Policy (read-only, isolation, search_path)
|
|
725
|
+
// is the caller's; the SQL is the caller's. See db-session.ts.
|
|
726
|
+
actions['db:session'] = async (payload, _ctx) => {
|
|
727
|
+
const { sql } = await import('drizzle-orm');
|
|
728
|
+
const { runDbSession } = await import('./db-session');
|
|
729
|
+
return runDbSession(opsDb as any, payload as any, (query: string) => sql.raw(query));
|
|
730
|
+
};
|
|
731
|
+
|
|
709
732
|
// --- db:doctor — "is my database actually secure?" ---
|
|
710
733
|
// Probes the api connection (createDb / DATABASE_URL) and the operator connection
|
|
711
734
|
// (opsDb / ADMIN_DATABASE_URL) from inside the VPC and reports whether the api path
|
|
@@ -852,6 +875,11 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
852
875
|
const result: any = await opsDb.execute(sql.raw(query));
|
|
853
876
|
return Array.isArray(result) ? result : result?.rows ?? [];
|
|
854
877
|
};
|
|
878
|
+
// The fingerprint endpoints this apply gates on are multi-statement reads, so they
|
|
879
|
+
// run as ONE session on this container's operator connection. Server-side that was
|
|
880
|
+
// always one connection; saying so in the type is what stops a future caller from
|
|
881
|
+
// quietly reintroducing a per-statement read.
|
|
882
|
+
const session = sessionOverOpsDb(sql);
|
|
855
883
|
|
|
856
884
|
const destructive = Number(plan.destructive) > 0;
|
|
857
885
|
const snapStage = stage ?? ctx.environment ?? 'default';
|
|
@@ -860,7 +888,7 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
860
888
|
const SNAPSHOT_FRESHNESS_MS = 60 * 60_000;
|
|
861
889
|
let snapshotId: string | undefined;
|
|
862
890
|
try {
|
|
863
|
-
const result = await executeApplyPlan(runner, plan, {
|
|
891
|
+
const result = await executeApplyPlan(runner, session, plan, {
|
|
864
892
|
actor: actor ?? null,
|
|
865
893
|
gitRef: gitRef ?? null,
|
|
866
894
|
// Descent/authority are decided by the CLI (checkout + caller identity)
|
|
@@ -930,7 +958,7 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
930
958
|
const result: any = await opsDb.execute(sql.raw(query));
|
|
931
959
|
return Array.isArray(result) ? result : result?.rows ?? [];
|
|
932
960
|
};
|
|
933
|
-
const run = await executeReconcile(runner, {
|
|
961
|
+
const run = await executeReconcile(runner, sessionOverOpsDb(sql), {
|
|
934
962
|
declared: p.declared,
|
|
935
963
|
renamedTables: p.renamedTables,
|
|
936
964
|
apply: p.apply,
|