@everystack/cli 0.4.98 → 0.4.100

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.
@@ -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 renderCheckVerdict(
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
- step('Presigning the dump (1 hour)...');
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
- url = await presignGet(source.region, source.backupsBucket, keyForId(backupId)!, 3600);
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)) return 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
 
@@ -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
- fail('db:doctor — the api connection is NOT correctly least-privilege (see failures above)');
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
+ }
@@ -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' }
@@ -152,13 +153,16 @@ export async function createUrlRunner(
152
153
  // minutes, resolving the in-flight query and THEN killing the session. Under reconcile's
153
154
  // BEGIN-across-calls transaction that is a silent session swap mid-batch (the reconcile
154
155
  // apply path also pins the backend pid and refuses bookkeeping if it moved).
155
- const sql = postgres(url, { max: 1, max_lifetime: null, onnotice: () => {}, ...sslDefaults(url) });
156
- return {
157
- runner: async (query: string) => Array.from(await sql.unsafe(query)),
158
- session: sessionRunnerOver(sql),
159
- transaction: async (fn) => sql.begin((tx: any) => fn(async (query: string) => Array.from(await tx.unsafe(query)))),
160
- end: () => sql.end({ timeout: 5 }),
161
- };
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);
162
166
  }
163
167
 
164
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
  }
@@ -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
- if (roles.size > 0) {
266
- statements.push(`GRANT USAGE ON SCHEMA ${quoteIdent(schema)} TO ${[...roles].sort().join(', ')}`);
267
- }
282
+ usage[schema] = [...roles].sort();
268
283
  }
269
- return statements;
284
+ return usage;
270
285
  }
271
286
 
272
287
  // ---------------------------------------------------------------------------
package/src/cli/index.ts CHANGED
@@ -317,7 +317,7 @@ Usage:
317
317
  everystack db:provision --rotate --stage <name> [--slot api|ops|both] [--grace 60] Rotate credentials live (A/B role swap, no deploy, no downtime)
318
318
  everystack db:snapshot [--stage <name>] [--instance <id>] Take a physical RDS snapshot (instant DR point; RDS only — use db:backup for portable logical backups)
319
319
  everystack db:snapshots [--stage <name>] [--instance <id>] List manual RDS snapshots for the instance
320
- everystack db:backup [--stage <name>] Logical pg_dump of the DB → private S3 backups bucket (prints the backup id). Add --database-url <local> --stage <target> to dump a LOCAL database and upload it into the target stage's bucket as a restorable backup — the credential-free break-glass local→stage full replace (then: db:restore --from <id> --stage <target>)
320
+ everystack db:backup [--stage <name>] Logical pg_dump of the DB → private S3 backups bucket (prints the backup id). Add --database-url <local> --stage <target> to dump a LOCAL database and upload it into the target stage's bucket. Such a backup records no origin commit, so db:restore refuses it: restoring a local backup into a stage is closed until local backups can record a verified origin
321
321
  everystack db:backups [--stage <name>] List logical backups (id, size, created)
322
322
  everystack db:restore --from <id> [--stage <name>] --confirm Restore a backup INTO the stage's DB (DESTRUCTIVE)
323
323
  everystack db:backup:download <id> [--stage <name>] Presigned URL to download a backup's dump (valid 1h)