@everystack/cli 0.4.97 → 0.4.99
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 +43 -0
- package/package.json +13 -3
- package/src/cli/apply-execute.ts +80 -23
- package/src/cli/authz-manifest.ts +93 -0
- package/src/cli/authz-redteam.ts +19 -10
- package/src/cli/backfill.ts +10 -4
- package/src/cli/backup.ts +21 -0
- package/src/cli/commands/db-apply.ts +4 -14
- package/src/cli/commands/db-backup.ts +56 -17
- package/src/cli/commands/db-check.ts +16 -3
- package/src/cli/commands/db-fork.ts +12 -4
- package/src/cli/commands/db-generate.ts +7 -3
- package/src/cli/commands/db-plan.ts +6 -0
- package/src/cli/commands/db.ts +3 -2
- package/src/cli/commands/runbook.ts +3 -0
- package/src/cli/connection-fence.ts +351 -0
- package/src/cli/db-source.ts +13 -6
- package/src/cli/declared-derived.ts +6 -1
- package/src/cli/derived-apply.ts +20 -5
- package/src/cli/derived-plan.ts +2 -2
- package/src/cli/edge-plan.ts +66 -2
- package/src/cli/index.ts +1 -1
- package/src/cli/mutation-lease.ts +5 -0
- package/src/cli/plain-output.ts +32 -10
- package/src/cli/runbook/model-section.ts +5 -2
- package/src/cli/select-grant-advisory.ts +132 -0
- package/src/cli/stage-backup.ts +57 -16
- package/src/cli/state-apply.ts +122 -6
- package/src/cli/state-function-deps.ts +91 -0
|
@@ -44,6 +44,7 @@ import { loadDeclaredDerived, retiredSqlDirAnywhere, retiredSqlDirFlagRefusal, t
|
|
|
44
44
|
import type { SequenceDescriptor } from '@everystack/model';
|
|
45
45
|
import type { SourceObject } from '../derived-source';
|
|
46
46
|
import { findReadAuthzGaps, findNakedGrants } from '../authz-lint';
|
|
47
|
+
import { findSelectGrantGaps } from '../select-grant-advisory';
|
|
47
48
|
import { parseBaseline, renderBaseline, BASELINE_FILE } from '../authz-baseline';
|
|
48
49
|
import {
|
|
49
50
|
findDerivedReadGaps, findSecdefExecuteGaps, findMatviewSnapshotWarnings, findPublicExecutableSecdef,
|
|
@@ -69,6 +70,14 @@ export interface CheckFinding {
|
|
|
69
70
|
message: string;
|
|
70
71
|
}
|
|
71
72
|
|
|
73
|
+
/** The quiet default's one line: failing findings are "pending", warnings ride along and never fail the gate. */
|
|
74
|
+
export function checkVerdictLines(findings: readonly CheckFinding[], ephemeralFailed: boolean): string[] {
|
|
75
|
+
return renderCheckVerdict(
|
|
76
|
+
findings.filter((f) => f.level === 'fail').length + (ephemeralFailed ? 1 : 0),
|
|
77
|
+
findings.filter((f) => f.level === 'warn').length,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
72
81
|
export interface StaticCheckInput {
|
|
73
82
|
modelsPath: string;
|
|
74
83
|
/** Barrel siblings and what each declares — the "on disk but ungoverned" scan. */
|
|
@@ -197,6 +206,12 @@ export function runStaticChecks(input: StaticCheckInput): CheckFinding[] {
|
|
|
197
206
|
findings.push({ level: 'warn', area: 'authz', message: naked.message });
|
|
198
207
|
}
|
|
199
208
|
|
|
209
|
+
// A write grant with no SELECT beside it: every UPDATE/DELETE ... WHERE (and any RETURNING) by
|
|
210
|
+
// that role is "permission denied". WARN: the grant set is legal, only its use is broken.
|
|
211
|
+
for (const gap of findSelectGrantGaps(input.models)) {
|
|
212
|
+
findings.push({ level: 'warn', area: 'authz', message: gap.message });
|
|
213
|
+
}
|
|
214
|
+
|
|
200
215
|
// The adoption baseline, ARTIFACT ONLY.
|
|
201
216
|
//
|
|
202
217
|
// This check cannot see the threat and must not pretend to. The foreign grantees it
|
|
@@ -494,9 +509,7 @@ export async function dbCheckCommand(flags: Record<string, string>): Promise<voi
|
|
|
494
509
|
// every failing finding, plus the from-scratch build when it did not land where the models
|
|
495
510
|
// say it should. `db:plan` is the verb that shows each one against a real target, so it is
|
|
496
511
|
// the only command the line names. Re-run with --verbose to read the findings themselves.
|
|
497
|
-
for (const text of
|
|
498
|
-
findings.filter((f) => f.level === 'fail').length + (ephemeralFailed ? 1 : 0),
|
|
499
|
-
)) printLine(text);
|
|
512
|
+
for (const text of checkVerdictLines(findings, ephemeralFailed)) printLine(text);
|
|
500
513
|
}
|
|
501
514
|
|
|
502
515
|
if (failed) process.exit(1);
|
|
@@ -21,9 +21,10 @@
|
|
|
21
21
|
|
|
22
22
|
import { resolveConfig, opsFunction, type CliConfig } from '../config';
|
|
23
23
|
import { invokeAction, presignGet } from '../aws';
|
|
24
|
-
import { keyForId, parseBackupRef, isProductionTier, crossStageGuard } from '../backup';
|
|
24
|
+
import { keyForId, metaKey, parseBackupRef, isProductionTier, crossStageGuard } from '../backup';
|
|
25
25
|
import { pollTaskUntilStopped } from '../task-poll';
|
|
26
26
|
import { runStageBackup, stageBackupDeps } from '../stage-backup';
|
|
27
|
+
import { printRestoreWitness } from './db-backup';
|
|
27
28
|
import { step, success, fail, info, warn } from '../output';
|
|
28
29
|
|
|
29
30
|
export type ForkGuardVerdict =
|
|
@@ -109,11 +110,16 @@ export async function dbForkCommand(flags: Record<string, string>): Promise<void
|
|
|
109
110
|
info(`Backup ${backupId}.`);
|
|
110
111
|
}
|
|
111
112
|
|
|
112
|
-
// Move 2: the presigned hand-off — the operator's credentials ARE the authorization.
|
|
113
|
-
|
|
113
|
+
// Move 2: the presigned hand-off — the operator's credentials ARE the authorization. The meta
|
|
114
|
+
// travels beside the dump: the target's Lambda reads the backup's origin commit from it (the
|
|
115
|
+
// restore witness), since it cannot read the source stage's bucket itself.
|
|
116
|
+
step('Presigning the dump and its meta (1 hour)...');
|
|
114
117
|
let url: string;
|
|
118
|
+
let metaUrl: string;
|
|
115
119
|
try {
|
|
116
|
-
|
|
120
|
+
const key = keyForId(backupId)!;
|
|
121
|
+
url = await presignGet(source.region, source.backupsBucket, key, 3600);
|
|
122
|
+
metaUrl = await presignGet(source.region, source.backupsBucket, metaKey(key), 3600);
|
|
117
123
|
} catch (err: any) {
|
|
118
124
|
fail(`Presign failed: ${err.message}`);
|
|
119
125
|
process.exit(1);
|
|
@@ -131,6 +137,7 @@ export async function dbForkCommand(flags: Record<string, string>): Promise<void
|
|
|
131
137
|
step(`Restoring into ${target} (pg_restore in the Task; replaces its database)...`);
|
|
132
138
|
const restored: any = await invokeAction(targetConfig.region, opsFunction(targetConfig), 'db:restore', {
|
|
133
139
|
url,
|
|
140
|
+
metaUrl,
|
|
134
141
|
confirm: true,
|
|
135
142
|
actor: process.env.USER ?? null,
|
|
136
143
|
});
|
|
@@ -138,6 +145,7 @@ export async function dbForkCommand(flags: Record<string, string>): Promise<void
|
|
|
138
145
|
fail(`Restore failed: ${restored.error}`);
|
|
139
146
|
process.exit(1);
|
|
140
147
|
}
|
|
148
|
+
printRestoreWitness(restored, target);
|
|
141
149
|
const rPoll = await pollTaskUntilStopped(targetConfig.region, opsFunction(targetConfig), { runId: restored.runId, taskArn: restored.taskArn });
|
|
142
150
|
if (rPoll.outcome !== 'stopped' || rPoll.status.exitCode !== 0) {
|
|
143
151
|
const why = rPoll.outcome === 'stopped' ? `exit ${rPoll.status.exitCode}${rPoll.status.stoppedReason ? ` — ${rPoll.status.stoppedReason}` : ''}` : rPoll.outcome;
|
|
@@ -23,6 +23,7 @@ import fs from 'node:fs/promises';
|
|
|
23
23
|
import path from 'node:path';
|
|
24
24
|
import { pathToFileURL } from 'node:url';
|
|
25
25
|
import type { ModelDescriptor, Module } from '@everystack/model';
|
|
26
|
+
import { linkModels } from '@everystack/model';
|
|
26
27
|
import { introspectSchema } from '../schema-introspect';
|
|
27
28
|
import { compileDrizzleSource } from '../schema-source';
|
|
28
29
|
import { compileModuleMigration } from '../migration-compile';
|
|
@@ -60,7 +61,7 @@ export async function loadModels(modelsPath: string): Promise<ModelDescriptor[]>
|
|
|
60
61
|
const models = mod.models ?? mod.default;
|
|
61
62
|
if (!Array.isArray(models)) throw new Error(`${modelsPath} must export a \`models\` array (got ${typeof models}).`);
|
|
62
63
|
assertNoHoles(modelsPath, 'models', models);
|
|
63
|
-
return models;
|
|
64
|
+
return linkModels(models);
|
|
64
65
|
} catch (err) {
|
|
65
66
|
throw asModelComposeError(modelsPath, err);
|
|
66
67
|
}
|
|
@@ -80,9 +81,12 @@ export async function loadModules(modelsPath: string): Promise<Module[]> {
|
|
|
80
81
|
} catch (err: any) {
|
|
81
82
|
throw new Error(`Could not load modules from ${modelsPath}: ${err.message}`);
|
|
82
83
|
}
|
|
83
|
-
if (Array.isArray(mod.modules))
|
|
84
|
+
if (Array.isArray(mod.modules)) {
|
|
85
|
+
linkModels(mod.modules.flatMap((module: Module) => module.models));
|
|
86
|
+
return mod.modules;
|
|
87
|
+
}
|
|
84
88
|
const models = mod.models ?? mod.default;
|
|
85
|
-
if (Array.isArray(models)) return [{ models, extensions: [], sequences: [], derived: [], governedRoles: [], functions: [] }];
|
|
89
|
+
if (Array.isArray(models)) return [{ models: linkModels(models), extensions: [], sequences: [], derived: [], governedRoles: [], functions: [] }];
|
|
86
90
|
throw new Error(`${modelsPath} must export a \`modules\` (or \`models\`) array.`);
|
|
87
91
|
}
|
|
88
92
|
|
|
@@ -76,6 +76,7 @@ import { reportPipelineLastRun } from './pipeline-run';
|
|
|
76
76
|
import { renderPlanReport } from '../plain-output';
|
|
77
77
|
import { fkOrphanChecks, fkOrphanSql, fkOrphanFindings, checkOrphanRowShape } from '../fk-preflight';
|
|
78
78
|
import { renderFkOrphanAdvisory, ownershipColumns } from '../fk-orphan-advisory';
|
|
79
|
+
import { mintStatePrerequisites } from '../state-function-deps';
|
|
79
80
|
import { step, success, fail, info, warn, line as printLine, reserveStdoutForData } from '../output';
|
|
80
81
|
|
|
81
82
|
/**
|
|
@@ -308,6 +309,11 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
|
|
|
308
309
|
process.exit(1);
|
|
309
310
|
}
|
|
310
311
|
|
|
312
|
+
// The edge is pure, but this decision belongs to mint: only this command holds both the
|
|
313
|
+
// checkout's declared functions and the live catalog. Embedding the exact missing CREATEs
|
|
314
|
+
// lets every apply lane enact the reviewed answer without reading source again.
|
|
315
|
+
plan.statePrerequisites = await mintStatePrerequisites(runner, session, plan.statements, declaredDerived?.objects ?? []);
|
|
316
|
+
|
|
311
317
|
// The plan lane's contract: what it mints, it can apply. A SET DATA TYPE on a
|
|
312
318
|
// column a view/matview/rule binds would fail MID-TRANSACTION at apply
|
|
313
319
|
// ("cannot alter type of a column used by a view or rule") — refuse at mint,
|
package/src/cli/commands/db.ts
CHANGED
|
@@ -1344,7 +1344,7 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
1344
1344
|
}
|
|
1345
1345
|
|
|
1346
1346
|
/** Render a doctor report — identical output whichever venue produced it. */
|
|
1347
|
-
function printDoctorReport(report: any): void {
|
|
1347
|
+
export function printDoctorReport(report: any): void {
|
|
1348
1348
|
console.log('');
|
|
1349
1349
|
info(`api connection (DATABASE_URL): role=${report.api.role} superuser=${report.api.isSuperuser} bypassrls=${report.api.bypassRls}`);
|
|
1350
1350
|
info(`operator connection (ADMIN_URL): role=${report.admin.role} superuser=${report.admin.isSuperuser} bypassrls=${report.admin.bypassRls}`);
|
|
@@ -1361,7 +1361,8 @@ function printDoctorReport(report: any): void {
|
|
|
1361
1361
|
if (report.ok) {
|
|
1362
1362
|
success('db:doctor — database is least-privilege and RLS-subject');
|
|
1363
1363
|
} else {
|
|
1364
|
-
|
|
1364
|
+
const failed = (report.checks as Array<{ status: string }>).filter((c) => c.status === 'fail').length;
|
|
1365
|
+
fail(`db:doctor — ${failed} check(s) failed (see failures above)`);
|
|
1365
1366
|
// Probing a local database as your own superuser makes these fail by construction:
|
|
1366
1367
|
// the checks describe the credential the API serves with, not the schema. A developer
|
|
1367
1368
|
// reading them as defects in their app will go hunting for a bug that is not there.
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* everystack runbook --force overwrite hand-edits inside generated blocks
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import { linkModels, ModelLinkError } from '@everystack/model';
|
|
16
17
|
import fs from 'node:fs';
|
|
17
18
|
import path from 'node:path';
|
|
18
19
|
import { pathToFileURL } from 'node:url';
|
|
@@ -46,8 +47,10 @@ async function loadDataModel(root: string, models: { dir: string | null; hasInde
|
|
|
46
47
|
if (!Array.isArray(models)) {
|
|
47
48
|
return { dataModel: null, warning: `models/index.ts exports no \`models\` array — data-model chapter skipped.` };
|
|
48
49
|
}
|
|
50
|
+
linkModels(models);
|
|
49
51
|
return { dataModel: renderDataModelSection(models), warning: null };
|
|
50
52
|
} catch (err: any) {
|
|
53
|
+
if (err instanceof ModelLinkError) throw err;
|
|
51
54
|
return {
|
|
52
55
|
dataModel: null,
|
|
53
56
|
warning: `Could not load models from ${barrel} (${err.message}) — data-model chapter skipped.`,
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The connection fence: a single-connection postgres.js client that fails CLOSED when its
|
|
3
|
+
* backend dies.
|
|
4
|
+
*
|
|
5
|
+
* WHY. A mutation holds a session advisory lock (the mutation lease) and often an open
|
|
6
|
+
* transaction on one backend. When that backend dies (failover, `pg_terminate_backend`, a
|
|
7
|
+
* network drop), the lock and the transaction die with it. Two driver behaviours then turn a
|
|
8
|
+
* clean failure into something worse (postgres.js 3.4.9, the pinned and latest version):
|
|
9
|
+
*
|
|
10
|
+
* 1. Inside `sql.begin` or `sql.reserve`, the next statement is handed to the CLOSED connection.
|
|
11
|
+
* The driver schedules a socket write on a null socket, which throws `Cannot read properties
|
|
12
|
+
* of null (reading 'write')` outside any promise (an uncaught exception), and the statement
|
|
13
|
+
* never settles. `sql.begin` does this even when our code issues nothing more: after the
|
|
14
|
+
* body throws it sends its own ROLLBACK down the same path. So no check inside a
|
|
15
|
+
* `sql.begin` body can prevent it; this fence never uses `sql.begin` or `sql.reserve`.
|
|
16
|
+
* 2. At the pool level, the next statement silently RECONNECTS onto a new backend. That
|
|
17
|
+
* backend holds no lease and is not in the transaction, so the rest of a "transaction"
|
|
18
|
+
* would autocommit, unleased.
|
|
19
|
+
*
|
|
20
|
+
* HOW. Every statement goes through the pool (`sql.unsafe`) on a client created with
|
|
21
|
+
* `max: 1`, so there is one connection and the pool path never writes to a closed socket.
|
|
22
|
+
* The driver's public `onclose` option trips the fence the moment the connection closes; from
|
|
23
|
+
* then on nothing is handed to the driver again, so the reconnect never happens. Statements
|
|
24
|
+
* are dispatched one at a time, and each waiting caller is also released by the loss itself,
|
|
25
|
+
* so a statement the driver never settles still ends in a named `ConnectionLostError`.
|
|
26
|
+
* Transactions are plain `BEGIN` / `COMMIT` / `ROLLBACK` statements through the same fence.
|
|
27
|
+
*
|
|
28
|
+
* Three more rules close what `onclose` alone leaves open:
|
|
29
|
+
* - A rejection that is not a PostgreSQL statement error (no `severity`: ECONNRESET, EPIPE, the
|
|
30
|
+
* driver's CONNECTION_* errors) or is a FATAL / PANIC trips the fence at once. On a TCP reset
|
|
31
|
+
* the driver can reject the statement before its onclose runs, and nothing may be sent in
|
|
32
|
+
* between. A failure of the connect phase (wrong database, role or password, refused port)
|
|
33
|
+
* trips it too, but surfaces the driver's own error: nothing ran. Every other loss message
|
|
34
|
+
* ends with the driver error that revealed it, `(cause: CODE: message)`.
|
|
35
|
+
* - What the loss says depends on WHEN it happened. Between statements: nothing was in flight,
|
|
36
|
+
* an open transaction rolled back. With a statement in flight: it MAY HAVE COMMITTED (an
|
|
37
|
+
* autocommit write, a hand-issued COMMIT), so `outcomeUnknown` is true. Inside `transaction()`
|
|
38
|
+
* before its COMMIT was sent: rolled back. Neither message claims the lease is already free:
|
|
39
|
+
* the server frees it when it notices the connection is gone.
|
|
40
|
+
* - A COMMIT that PostgreSQL answers with the ROLLBACK command tag (the transaction had already
|
|
41
|
+
* failed) is a `TransactionRolledBackError`, never a success.
|
|
42
|
+
*
|
|
43
|
+
* THE WINDOW, stated. The fence is checked, then the driver dispatches the statement one
|
|
44
|
+
* microtask later (postgres.js runs a query's handler after `await 1`). The driver trips the
|
|
45
|
+
* fence synchronously inside its own `closed()`, the same function that nulls the socket, and
|
|
46
|
+
* `closed()` runs from a socket 'close' event, which cannot run inside a microtask gap. So a
|
|
47
|
+
* statement that passes the check reaches a connection that is still open; if it dies an
|
|
48
|
+
* instant later, `closed()` cancels the pending write and rejects the statement. Only our own
|
|
49
|
+
* synchronous code could close the socket inside the gap, and nothing here does.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
import type { QueryRunner } from './authz-contract';
|
|
53
|
+
import { borrowedSessionRunner, type SessionOptions, type SessionRunner } from './session';
|
|
54
|
+
|
|
55
|
+
/** The backend behind a fenced connection is gone; nothing more ran on it. */
|
|
56
|
+
export class ConnectionLostError extends Error {
|
|
57
|
+
readonly code = 'CONNECTION_LOST';
|
|
58
|
+
/**
|
|
59
|
+
* True when a statement was IN FLIGHT as the connection died and it could have committed on
|
|
60
|
+
* its own: an autocommit write, or a COMMIT. The server may or may not have applied it. False
|
|
61
|
+
* when nothing was in flight, or when the lost statement sat inside a transaction whose
|
|
62
|
+
* COMMIT was never sent: that transaction rolled back with the backend.
|
|
63
|
+
*/
|
|
64
|
+
readonly outcomeUnknown: boolean;
|
|
65
|
+
constructor(message: string, options: { outcomeUnknown?: boolean; cause?: unknown } = {}) {
|
|
66
|
+
// The CLI prints the message, never `.cause`: the driver's own words ride in the text.
|
|
67
|
+
const driverError = rootCause(options.cause);
|
|
68
|
+
super(driverError === undefined ? message : `${message} (cause: ${describeCause(driverError)})`);
|
|
69
|
+
this.name = 'ConnectionLostError';
|
|
70
|
+
this.outcomeUnknown = options.outcomeUnknown === true;
|
|
71
|
+
if (options.cause !== undefined) (this as { cause?: unknown }).cause = options.cause;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Follow `.cause` through wrapping ConnectionLostErrors to the driver's own error, if any. */
|
|
76
|
+
function rootCause(cause: unknown): unknown {
|
|
77
|
+
let at = cause;
|
|
78
|
+
while (at instanceof ConnectionLostError) at = (at as { cause?: unknown }).cause;
|
|
79
|
+
return at;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** `CODE: message` for a driver error, or its message alone when it has no code. */
|
|
83
|
+
function describeCause(err: unknown): string {
|
|
84
|
+
if (!err || typeof err !== 'object') return String(err);
|
|
85
|
+
const { code, message } = err as { code?: unknown; message?: unknown };
|
|
86
|
+
const text = typeof message === 'string' ? message : String(err);
|
|
87
|
+
return typeof code === 'string' && code.length > 0 ? `${code}: ${text}` : text;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* PostgreSQL answered COMMIT with ROLLBACK: the transaction had already failed (a statement
|
|
92
|
+
* error the body caught and carried on past), so nothing in it committed. PostgreSQL reports
|
|
93
|
+
* this with a command tag, not an error; without this check it reads as success.
|
|
94
|
+
*/
|
|
95
|
+
export class TransactionRolledBackError extends Error {
|
|
96
|
+
constructor(query: string) {
|
|
97
|
+
super(
|
|
98
|
+
`PostgreSQL rolled back the transaction instead of committing it (${query.trim().split(/\s+/)[0]} ` +
|
|
99
|
+
'returned ROLLBACK): an earlier statement in it failed, so NOTHING in the transaction committed.',
|
|
100
|
+
);
|
|
101
|
+
this.name = 'TransactionRolledBackError';
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const LOST = 'database connection lost (the backend was terminated or the network dropped)';
|
|
106
|
+
const LEASE_NOTE =
|
|
107
|
+
'The server releases the mutation lease when it notices the connection is gone; if a re-run ' +
|
|
108
|
+
'reports the lease is still held, that session is still alive: wait for it or terminate it.';
|
|
109
|
+
|
|
110
|
+
/** Nothing was in flight: the statement was refused before it reached the driver. */
|
|
111
|
+
const LOST_BETWEEN_MESSAGE =
|
|
112
|
+
`${LOST} between statements. Nothing more ran on it: any transaction still open rolled back ` +
|
|
113
|
+
`with the backend; statements that finished before the loss stand. ${LEASE_NOTE} Re-run the command.`;
|
|
114
|
+
|
|
115
|
+
/** A statement was in flight and could have committed on its own. */
|
|
116
|
+
const LOST_IN_FLIGHT_MESSAGE =
|
|
117
|
+
`${LOST} while a statement was in flight. That statement MAY HAVE COMMITTED: read the target ` +
|
|
118
|
+
`before re-running. Nothing more ran on the connection. ${LEASE_NOTE}`;
|
|
119
|
+
|
|
120
|
+
/** Inside transaction(), before its COMMIT was sent: nothing in it can have committed. */
|
|
121
|
+
const LOST_IN_TRANSACTION_MESSAGE =
|
|
122
|
+
`${LOST} inside a transaction, before its COMMIT was sent: the transaction rolled back with ` +
|
|
123
|
+
`the backend. ${LEASE_NOTE} Re-run the command.`;
|
|
124
|
+
|
|
125
|
+
/** transaction()'s own COMMIT was in flight. */
|
|
126
|
+
const LOST_AT_COMMIT_MESSAGE =
|
|
127
|
+
`${LOST} while COMMIT was in flight: the transaction MAY HAVE COMMITTED or may not. Read the ` +
|
|
128
|
+
`target before re-running. ${LEASE_NOTE}`;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Does this rejection mean the CONNECTION is gone, not just the statement? A PostgreSQL error
|
|
132
|
+
* carries the server's `severity`; anything without one (ECONNRESET, EPIPE, ETIMEDOUT, the
|
|
133
|
+
* driver's CONNECTION_CLOSED / CONNECTION_DESTROYED / CONNECTION_ENDED / CONNECT_TIMEOUT) is the
|
|
134
|
+
* transport. A FATAL or PANIC is the server ending the session. A false positive fails closed,
|
|
135
|
+
* which is acceptable.
|
|
136
|
+
*/
|
|
137
|
+
function isConnectionFailure(err: unknown): boolean {
|
|
138
|
+
if (!err || typeof err !== 'object') return true;
|
|
139
|
+
const { severity } = err as { severity?: unknown };
|
|
140
|
+
if (typeof severity !== 'string') return true;
|
|
141
|
+
return severity === 'FATAL' || severity === 'PANIC';
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Failures of the CONNECT phase: the connection never opened, so no statement can have run.
|
|
146
|
+
* The server FATALs a login can end in (no such database 3D000, no such role 28000, bad
|
|
147
|
+
* password 28P01, too many connections 53300, starting up 57P03), and the transport errors of
|
|
148
|
+
* reaching it at all. These still trip the fence, but the caller gets the driver's ORIGINAL
|
|
149
|
+
* error: "may have committed" would be false, and the server's text names what is wrong.
|
|
150
|
+
*/
|
|
151
|
+
const CONNECT_PHASE_CODES = new Set([
|
|
152
|
+
'3D000', '28000', '28P01', '53300', '57P03',
|
|
153
|
+
'ECONNREFUSED', 'ENOTFOUND', 'EAI_AGAIN', 'CONNECT_TIMEOUT',
|
|
154
|
+
]);
|
|
155
|
+
|
|
156
|
+
function isConnectPhaseFailure(err: unknown): boolean {
|
|
157
|
+
const code = (err as { code?: unknown } | null)?.code;
|
|
158
|
+
return typeof code === 'string' && CONNECT_PHASE_CODES.has(code);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** COMMIT or its synonym END: the statements PostgreSQL may answer with a ROLLBACK tag. */
|
|
162
|
+
const COMMIT_RE = /^\s*(COMMIT|END)\b/i;
|
|
163
|
+
|
|
164
|
+
/** The loss signal, tripped by the driver's `onclose`. */
|
|
165
|
+
export interface ConnectionFence {
|
|
166
|
+
/** The loss, once it happened; null while the connection is alive. */
|
|
167
|
+
readonly lost: ConnectionLostError | null;
|
|
168
|
+
/**
|
|
169
|
+
* Trip the fence. Idempotent; a no-op once the owner has ended the connection. `cause` is
|
|
170
|
+
* the driver error that revealed the loss, when one did; later refusals name it.
|
|
171
|
+
*/
|
|
172
|
+
lose(cause?: unknown): void;
|
|
173
|
+
/**
|
|
174
|
+
* The value for postgres.js's `onclose` option. Chains `next` (a caller's own onclose), so
|
|
175
|
+
* installing the fence never silences another listener.
|
|
176
|
+
*/
|
|
177
|
+
onclose(next?: (connectionId: number) => void): (connectionId: number) => void;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
interface FenceInternals extends ConnectionFence {
|
|
181
|
+
ended: boolean;
|
|
182
|
+
/** Callers waiting on an in-flight statement; the loss releases each. */
|
|
183
|
+
listeners: Set<(cause?: unknown) => void>;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function createConnectionFence(): ConnectionFence {
|
|
187
|
+
const fence: FenceInternals = {
|
|
188
|
+
lost: null,
|
|
189
|
+
ended: false,
|
|
190
|
+
listeners: new Set(),
|
|
191
|
+
lose(cause?: unknown) {
|
|
192
|
+
if (fence.lost || fence.ended) return;
|
|
193
|
+
// What every LATER statement is refused with: nothing of it was in flight.
|
|
194
|
+
(fence as { lost: ConnectionLostError | null }).lost = new ConnectionLostError(LOST_BETWEEN_MESSAGE, { cause });
|
|
195
|
+
const waiting = [...fence.listeners];
|
|
196
|
+
fence.listeners.clear();
|
|
197
|
+
for (const release of waiting) release();
|
|
198
|
+
},
|
|
199
|
+
onclose(next) {
|
|
200
|
+
return (connectionId: number) => {
|
|
201
|
+
fence.lose();
|
|
202
|
+
next?.(connectionId);
|
|
203
|
+
};
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
return fence;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** The slice of a postgres.js client the fence drives. */
|
|
210
|
+
export interface FenceableClient {
|
|
211
|
+
unsafe: (query: string) => PromiseLike<unknown>;
|
|
212
|
+
end: (options?: { timeout?: number }) => Promise<void>;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export interface FencedConnection {
|
|
216
|
+
/** One statement (autocommit unless the caller opened a transaction with `BEGIN`). */
|
|
217
|
+
runner: QueryRunner;
|
|
218
|
+
/** N statements in ONE transaction on this connection — see `SessionRunner`. */
|
|
219
|
+
session: SessionRunner;
|
|
220
|
+
/** `BEGIN`, the body, `COMMIT` — or `ROLLBACK` on a body error while the connection lives. */
|
|
221
|
+
transaction: <T>(fn: (run: QueryRunner) => Promise<T>) => Promise<T>;
|
|
222
|
+
/** Close the client. Statements after this are refused. */
|
|
223
|
+
end: () => Promise<void>;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const ISOLATION_SQL: Record<string, string> = {
|
|
227
|
+
'read uncommitted': 'READ UNCOMMITTED',
|
|
228
|
+
'read committed': 'READ COMMITTED',
|
|
229
|
+
'repeatable read': 'REPEATABLE READ',
|
|
230
|
+
serializable: 'SERIALIZABLE',
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
/** `SET TRANSACTION …` for the session options, or null when there is nothing to set. */
|
|
234
|
+
function setTransactionSql(opts: SessionOptions | undefined): string | null {
|
|
235
|
+
const modes: string[] = [];
|
|
236
|
+
if (opts?.isolation !== undefined) {
|
|
237
|
+
const level = ISOLATION_SQL[String(opts.isolation).toLowerCase().trim()];
|
|
238
|
+
if (!level) {
|
|
239
|
+
throw new Error(`session isolation ${JSON.stringify(opts.isolation)} is not a PostgreSQL isolation level`);
|
|
240
|
+
}
|
|
241
|
+
modes.push(`ISOLATION LEVEL ${level}`);
|
|
242
|
+
}
|
|
243
|
+
if (opts?.readOnly) modes.push('READ ONLY');
|
|
244
|
+
return modes.length ? `SET TRANSACTION ${modes.join(', ')}` : null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Wrap a single-connection postgres.js client (`max: 1`, created with `onclose:
|
|
249
|
+
* fence.onclose(...)`) so it fails closed on connection loss. See the file comment.
|
|
250
|
+
*/
|
|
251
|
+
export function fencedConnection(sql: FenceableClient, fenceIn: ConnectionFence): FencedConnection {
|
|
252
|
+
const fence = fenceIn as FenceInternals;
|
|
253
|
+
let tail: Promise<unknown> = Promise.resolve();
|
|
254
|
+
|
|
255
|
+
/** Check the fence, then hand the statement to the driver; the loss also releases the caller. */
|
|
256
|
+
const dispatch = (query: string): Promise<any[]> => {
|
|
257
|
+
if (fence.ended) return Promise.reject(new Error('this database connection has already ended'));
|
|
258
|
+
if (fence.lost) return Promise.reject(fence.lost);
|
|
259
|
+
return new Promise<any[]>((resolve, reject) => {
|
|
260
|
+
// The statement was handed to the driver, so the server may have run it: a loss from here
|
|
261
|
+
// on is IN FLIGHT, and its outcome is unknown.
|
|
262
|
+
const inFlightLoss = (cause?: unknown): ConnectionLostError =>
|
|
263
|
+
new ConnectionLostError(LOST_IN_FLIGHT_MESSAGE, { outcomeUnknown: true, cause });
|
|
264
|
+
const release = (cause?: unknown): void => reject(inFlightLoss(cause));
|
|
265
|
+
fence.listeners.add(release);
|
|
266
|
+
Promise.resolve(sql.unsafe(query)).then(
|
|
267
|
+
(rows) => {
|
|
268
|
+
fence.listeners.delete(release);
|
|
269
|
+
// Read the command tag before Array.from copies the rows and drops it.
|
|
270
|
+
const command = (rows as { command?: unknown } | null)?.command;
|
|
271
|
+
if (command === 'ROLLBACK' && COMMIT_RE.test(query)) {
|
|
272
|
+
reject(new TransactionRolledBackError(query));
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
resolve(Array.from(rows as Iterable<any>));
|
|
276
|
+
},
|
|
277
|
+
(err) => {
|
|
278
|
+
fence.listeners.delete(release);
|
|
279
|
+
if (fence.lost || isConnectionFailure(err)) {
|
|
280
|
+
// Trip the fence NOW, before anything else can be sent. On a TCP reset Node emits
|
|
281
|
+
// 'error' before 'close', so the driver can reject this statement before its onclose
|
|
282
|
+
// runs; in that interval a catch-block ROLLBACK, a failure bookkeeping row or the
|
|
283
|
+
// lease release would otherwise reach a driver whose pool reconnects onto a NEW backend.
|
|
284
|
+
fence.lose(err);
|
|
285
|
+
// A connection that never opened ran nothing: keep the driver's own error.
|
|
286
|
+
reject(isConnectPhaseFailure(err) ? err : inFlightLoss(err));
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
reject(err);
|
|
290
|
+
},
|
|
291
|
+
);
|
|
292
|
+
});
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
// One statement at a time: the next is not even checked until the previous settled, so two
|
|
296
|
+
// callers can never have a statement queued inside the driver when the connection dies.
|
|
297
|
+
const runner: QueryRunner = (query: string) => {
|
|
298
|
+
const turn = tail.then(() => dispatch(query), () => dispatch(query));
|
|
299
|
+
tail = turn.catch(() => undefined);
|
|
300
|
+
return turn;
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
const transaction = async <T>(fn: (run: QueryRunner) => Promise<T>): Promise<T> => {
|
|
304
|
+
await runner('BEGIN');
|
|
305
|
+
let result: T;
|
|
306
|
+
try {
|
|
307
|
+
result = await fn(runner);
|
|
308
|
+
} catch (err) {
|
|
309
|
+
// After a loss nothing is sent: the transaction already rolled back with the backend.
|
|
310
|
+
if (!fence.lost) await runner('ROLLBACK').catch(() => undefined);
|
|
311
|
+
// A body statement lost in flight cannot have committed: its COMMIT was never sent.
|
|
312
|
+
if (err instanceof ConnectionLostError) {
|
|
313
|
+
throw new ConnectionLostError(LOST_IN_TRANSACTION_MESSAGE, { cause: err });
|
|
314
|
+
}
|
|
315
|
+
throw err;
|
|
316
|
+
}
|
|
317
|
+
if (fence.lost) throw new ConnectionLostError(LOST_IN_TRANSACTION_MESSAGE, { cause: fence.lost });
|
|
318
|
+
try {
|
|
319
|
+
await runner('COMMIT');
|
|
320
|
+
} catch (err) {
|
|
321
|
+
if (err instanceof ConnectionLostError) {
|
|
322
|
+
throw err.outcomeUnknown
|
|
323
|
+
? new ConnectionLostError(LOST_AT_COMMIT_MESSAGE, { outcomeUnknown: true, cause: err })
|
|
324
|
+
: new ConnectionLostError(LOST_IN_TRANSACTION_MESSAGE, { cause: err });
|
|
325
|
+
}
|
|
326
|
+
throw err;
|
|
327
|
+
}
|
|
328
|
+
return result;
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
const session: SessionRunner = async (statements, opts) => {
|
|
332
|
+
const setTransaction = setTransactionSql(opts);
|
|
333
|
+
return transaction(async (run) => {
|
|
334
|
+
if (setTransaction) await run(setTransaction);
|
|
335
|
+
// The borrowed runner pins search_path inside a savepoint and takes each allowFailure
|
|
336
|
+
// statement in its own savepoint: hand-issued, which is sound here because no driver
|
|
337
|
+
// transaction scope is keeping its own error bookkeeping.
|
|
338
|
+
return borrowedSessionRunner(run)(
|
|
339
|
+
statements,
|
|
340
|
+
opts?.searchPath !== undefined ? { searchPath: opts.searchPath } : undefined,
|
|
341
|
+
);
|
|
342
|
+
});
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
const end = async (): Promise<void> => {
|
|
346
|
+
fence.ended = true;
|
|
347
|
+
await sql.end({ timeout: 5 });
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
return { runner, session, transaction, end };
|
|
351
|
+
}
|
package/src/cli/db-source.ts
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
|
|
27
27
|
import type { QueryRunner } from './authz-contract';
|
|
28
28
|
import { buildSearchPathSql, type SessionRunner, type SessionResult } from './session';
|
|
29
|
+
import { createConnectionFence, fencedConnection } from './connection-fence';
|
|
29
30
|
|
|
30
31
|
export type DbSource =
|
|
31
32
|
| { kind: 'url'; url: string; from: 'flag' | 'env' | 'admin-env' | 'operator' }
|
|
@@ -73,6 +74,8 @@ export interface UrlRunner {
|
|
|
73
74
|
* so a caller typed against `SessionRunner` reads identically on either venue.
|
|
74
75
|
*/
|
|
75
76
|
session: SessionRunner;
|
|
77
|
+
/** One pinned write transaction, for an edge that must inspect uncommitted catalog state. */
|
|
78
|
+
transaction: <T>(fn: (runner: QueryRunner) => Promise<T>) => Promise<T>;
|
|
76
79
|
/** Close the client so the process can exit cleanly. */
|
|
77
80
|
end: () => Promise<void>;
|
|
78
81
|
}
|
|
@@ -150,12 +153,16 @@ export async function createUrlRunner(
|
|
|
150
153
|
// minutes, resolving the in-flight query and THEN killing the session. Under reconcile's
|
|
151
154
|
// BEGIN-across-calls transaction that is a silent session swap mid-batch (the reconcile
|
|
152
155
|
// apply path also pins the backend pid and refuses bookkeeping if it moved).
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
156
|
+
//
|
|
157
|
+
// Fenced (connection-fence.ts): every direct-lane mutation lease is taken on this connection,
|
|
158
|
+
// so a dead backend must fail CLOSED — a named ConnectionLostError, never a driver reconnect
|
|
159
|
+
// onto a new, unleased backend and never the driver's uncaught write on a closed socket.
|
|
160
|
+
// That is also why session and transaction are plain statements here, not `sql.begin`.
|
|
161
|
+
const fence = createConnectionFence();
|
|
162
|
+
const sql = postgres(url, {
|
|
163
|
+
max: 1, max_lifetime: null, onnotice: () => {}, ...sslDefaults(url), onclose: fence.onclose(),
|
|
164
|
+
});
|
|
165
|
+
return fencedConnection(sql, fence);
|
|
159
166
|
}
|
|
160
167
|
|
|
161
168
|
export interface UrlPipelineRunner {
|
|
@@ -12,7 +12,7 @@ import path from 'node:path';
|
|
|
12
12
|
import fs from 'node:fs/promises';
|
|
13
13
|
import { pathToFileURL } from 'node:url';
|
|
14
14
|
import type { Module, ModelDescriptor, SequenceDescriptor, DerivedDescriptor } from '@everystack/model';
|
|
15
|
-
import { moduleGovernedRoles } from '@everystack/model';
|
|
15
|
+
import { linkModels, ModelLinkError, moduleGovernedRoles } from '@everystack/model';
|
|
16
16
|
import { compileDerived } from './derived-compile';
|
|
17
17
|
import { compileTableRenames, compileTableMoves } from './schema-compile';
|
|
18
18
|
import type { SourceObject } from './derived-source';
|
|
@@ -69,19 +69,23 @@ export async function loadModulesFrom(modelsPath: string): Promise<Module[]> {
|
|
|
69
69
|
try {
|
|
70
70
|
mod = await import(pathToFileURL(abs).href);
|
|
71
71
|
} catch (err: any) {
|
|
72
|
+
if (err instanceof ModelLinkError) throw err;
|
|
72
73
|
throw new Error(`Could not load modules from ${modelsPath}: ${err.message}`);
|
|
73
74
|
}
|
|
74
75
|
if (Array.isArray(mod.modules)) {
|
|
75
76
|
assertNoHoles(modelsPath, 'modules', mod.modules);
|
|
77
|
+
linkModels(mod.modules.flatMap((m: Module) => m.models));
|
|
76
78
|
return mod.modules;
|
|
77
79
|
}
|
|
78
80
|
const models = mod.models ?? mod.default;
|
|
79
81
|
if (Array.isArray(models)) {
|
|
80
82
|
assertNoHoles(modelsPath, 'models', models);
|
|
83
|
+
linkModels(models);
|
|
81
84
|
return [{ models, extensions: [], sequences: [], derived: [], governedRoles: [], functions: [] }];
|
|
82
85
|
}
|
|
83
86
|
throw new Error(`${modelsPath} must export a \`modules\` (or \`models\`) array.`);
|
|
84
87
|
} catch (err) {
|
|
88
|
+
if (err instanceof ModelLinkError) throw err;
|
|
85
89
|
throw asModelComposeError(modelsPath, err);
|
|
86
90
|
}
|
|
87
91
|
}
|
|
@@ -191,6 +195,7 @@ export function composeDeclaredDerived(modules: Module[], modelsPath: string): D
|
|
|
191
195
|
extensions: [...new Set(modules.flatMap((m) => m.extensions ?? []))].sort(),
|
|
192
196
|
};
|
|
193
197
|
} catch (err) {
|
|
198
|
+
if (err instanceof ModelLinkError) throw err;
|
|
194
199
|
throw asModelComposeError(modelsPath, err);
|
|
195
200
|
}
|
|
196
201
|
}
|
package/src/cli/derived-apply.ts
CHANGED
|
@@ -249,9 +249,26 @@ export function renderSetSearchPath(schemas: string[], local: boolean): string |
|
|
|
249
249
|
*/
|
|
250
250
|
export function renderEnsureObjectSchemas(objects: SourceObject[]): string[] {
|
|
251
251
|
const statements: string[] = [];
|
|
252
|
+
const usage = declaredObjectSchemaUsage(objects);
|
|
253
|
+
for (const [schema, roles] of Object.entries(usage)) {
|
|
254
|
+
statements.push(`CREATE SCHEMA IF NOT EXISTS ${quoteIdent(schema)}`);
|
|
255
|
+
if (roles.length > 0) {
|
|
256
|
+
statements.push(`GRANT USAGE ON SCHEMA ${quoteIdent(schema)} TO ${roles.join(', ')}`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return statements;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* The schema USAGE the declared objects imply, as data: every non-public schema an object lives
|
|
264
|
+
* in (sorted) → the sorted roles its objects grant anything to. The rule renderEnsureObjectSchemas
|
|
265
|
+
* renders into SQL (see A13 there for why PUBLIC is never included); exposed so a recorder — the
|
|
266
|
+
* backup's authz manifest — reads the SAME answer instead of re-deriving it.
|
|
267
|
+
*/
|
|
268
|
+
export function declaredObjectSchemaUsage(objects: SourceObject[]): Record<string, string[]> {
|
|
269
|
+
const usage: Record<string, string[]> = {};
|
|
252
270
|
const schemas = [...new Set(objects.map((o) => o.schema))].filter((s) => s !== 'public').sort();
|
|
253
271
|
for (const schema of schemas) {
|
|
254
|
-
statements.push(`CREATE SCHEMA IF NOT EXISTS ${quoteIdent(schema)}`);
|
|
255
272
|
const roles = new Set<string>();
|
|
256
273
|
for (const o of objects) {
|
|
257
274
|
if (o.schema !== schema) continue;
|
|
@@ -262,11 +279,9 @@ export function renderEnsureObjectSchemas(objects: SourceObject[]): string[] {
|
|
|
262
279
|
if (privileges.length > 0 && role.toUpperCase() !== 'PUBLIC') roles.add(role);
|
|
263
280
|
}
|
|
264
281
|
}
|
|
265
|
-
|
|
266
|
-
statements.push(`GRANT USAGE ON SCHEMA ${quoteIdent(schema)} TO ${[...roles].sort().join(', ')}`);
|
|
267
|
-
}
|
|
282
|
+
usage[schema] = [...roles].sort();
|
|
268
283
|
}
|
|
269
|
-
return
|
|
284
|
+
return usage;
|
|
270
285
|
}
|
|
271
286
|
|
|
272
287
|
// ---------------------------------------------------------------------------
|