@everystack/cli 0.4.98 → 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.
@@ -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)
@@ -179,6 +179,11 @@ export function leaseSession(): string {
179
179
  * write of the verb. A verb that opens a SECOND connection mid-body has a writer the lease
180
180
  * does not cover.
181
181
  *
182
+ * That connection must fail CLOSED when its backend dies: hand this a fenced runner
183
+ * (connection-fence.ts — `createUrlRunner` and the server's mutation connection both are).
184
+ * An unfenced postgres.js pool reconnects onto a new backend after a loss, so the rest of the
185
+ * body would run without the lease; the lease's own release below would too.
186
+ *
182
187
  * HONEST LIMITATION (do not overstate): this is CONVENTIONALLY enforced, not runtime-enforced.
183
188
  * A PostgreSQL connection carries no read/write intent the connection layer can inspect, so
184
189
  * "reject a second connection" is unrealizable in-process. The counterexample is already in the
@@ -77,6 +77,16 @@ function head(statement: string): string {
77
77
  return body === '' ? statement.replace(/\s+/g, ' ').trim() : body;
78
78
  }
79
79
 
80
+ /**
81
+ * Match report marks by the whole statement with ONLY outer whitespace removed, on both sides.
82
+ * `head` is a description, not an identity: collapsing whitespace can change literals and
83
+ * function bodies. Internal formatting, comments, case and terminators must still match exactly.
84
+ * This key never replaces the original SQL passed to descriptions or returned to callers.
85
+ */
86
+ function statementKey(statement: string): string {
87
+ return statement.trim();
88
+ }
89
+
80
90
  /** The whole statement as one line, comments stripped and truncated — for the fallback description. */
81
91
  function oneLine(statement: string): string {
82
92
  const body = head(statement).replace(/;$/, '');
@@ -339,7 +349,8 @@ function objectKey(s: string): { verb: 'drop' | 'create'; key: string } | null {
339
349
  * definition edit. So the drop is removed and its create is marked `rebuilt`, which renders as
340
350
  * the update line it always was.
341
351
  *
342
- * Statements this does not recognise pass through untouched, in order.
352
+ * Statements this does not recognise pass through untouched, in order. Returned statements keep
353
+ * their original text; `rebuilt` holds statementKey values (outer whitespace removed).
343
354
  */
344
355
  export function collapseRebuilds(statements: readonly string[]): {
345
356
  statements: string[];
@@ -355,7 +366,7 @@ export function collapseRebuilds(statements: readonly string[]): {
355
366
  for (const s of statements) {
356
367
  const k = objectKey(head(s));
357
368
  if (k?.verb === 'drop' && created.has(k.key)) {
358
- rebuilt.add(created.get(k.key)!);
369
+ rebuilt.add(statementKey(created.get(k.key)!));
359
370
  continue;
360
371
  }
361
372
  kept.push(s);
@@ -378,8 +389,9 @@ const plural = (n: number, one: string, many: string): string => (n === 1 ? one
378
389
 
379
390
  /** One change line, indented, already carrying its mark. */
380
391
  function changeLine(statement: string, destructive: ReadonlySet<string>, rebuilt?: ReadonlySet<string>): string {
381
- if (destructive.has(statement)) return ` ${DESTRUCTIVE_MARK} — ${describeDestructive(statement)}`;
382
- const { symbol, text } = describeChange(statement, { rebuilt: rebuilt?.has(statement) === true });
392
+ const key = statementKey(statement);
393
+ if (destructive.has(key)) return ` ${DESTRUCTIVE_MARK} — ${describeDestructive(statement)}`;
394
+ const { symbol, text } = describeChange(statement, { rebuilt: rebuilt?.has(key) === true });
383
395
  return ` ${symbol} ${text}`;
384
396
  }
385
397
 
@@ -398,7 +410,7 @@ function changeLines(
398
410
  export interface PlanReportInput {
399
411
  /** The real DDL the plan will run — notices and held drops are not changes. */
400
412
  executable: readonly string[];
401
- /** The destructive subset, as the apply gate classifies it. Must be members of `executable`. */
413
+ /** The apply gate's destructive subset. Must match `executable` apart from outer whitespace. */
402
414
  destructive: readonly string[];
403
415
  /** What the plan is aimed at, in the reader's words: a stage name, or `local`. */
404
416
  target: string;
@@ -415,7 +427,7 @@ export function renderPlanReport(input: PlanReportInput): string[] {
415
427
  const { executable, target, stage, planPath, verbose = false } = input;
416
428
  if (executable.length === 0) return ['Up to date. Nothing to plan.'];
417
429
 
418
- const destructive = new Set(input.destructive);
430
+ const destructive = new Set(input.destructive.map(statementKey));
419
431
  const lines: string[] = [
420
432
  `Plan: ${executable.length} ${plural(executable.length, 'change', 'changes')} (${target})`,
421
433
  '',
@@ -440,6 +452,7 @@ export function renderPlanReport(input: PlanReportInput): string[] {
440
452
 
441
453
  export interface ApplyReceiptInput {
442
454
  executable: readonly string[];
455
+ /** The apply gate's destructive subset. Must match `executable` apart from outer whitespace. */
443
456
  destructive: readonly string[];
444
457
  /** The target was already where the plan ends — an idempotent re-apply. */
445
458
  alreadyApplied?: boolean;
@@ -461,7 +474,7 @@ export function renderApplyReceipt(input: ApplyReceiptInput): string[] {
461
474
  const n = executable.length;
462
475
  const tally = `Applied ${n} ${plural(n, 'change', 'changes')}.`;
463
476
  if (n === 0 || n > MAX_RECEIPT_CHANGES) return [tally];
464
- return [...changeLines(executable, new Set(input.destructive), MAX_RECEIPT_CHANGES, rebuilt), '', tally];
477
+ return [...changeLines(executable, new Set(input.destructive.map(statementKey)), MAX_RECEIPT_CHANGES, rebuilt), '', tally];
465
478
  }
466
479
 
467
480
  export interface CatchUpFailureInput {
@@ -529,7 +542,16 @@ function sentence(text: string): string {
529
542
  }
530
543
 
531
544
  /** db:check's default output: one line, always. */
532
- export function renderCheckVerdict(pending: number): string[] {
533
- if (pending === 0) return ['Up to date.'];
534
- return [`${pending} ${plural(pending, 'change', 'changes')} pending. Run db:plan to review.`];
545
+ export function renderCheckVerdict(pending: number, warnings = 0): string[] {
546
+ if (warnings === 0) {
547
+ if (pending === 0) return ['Up to date.'];
548
+ return [`${pending} ${plural(pending, 'change', 'changes')} pending. Run db:plan to review.`];
549
+ }
550
+ // Warnings never fail the gate, but a clean-looking line would hide them.
551
+ const warned = `${warnings} ${plural(warnings, 'warning', 'warnings')}`;
552
+ const readThem = `db:check --verbose to read ${warnings === 1 ? 'it' : 'them'}`;
553
+ if (pending === 0) return [`Up to date, ${warned}. Run ${readThem}.`];
554
+ return [
555
+ `${pending} ${plural(pending, 'change', 'changes')} pending, ${warned}. Run db:plan to review, db:check --verbose to read the ${plural(warnings, 'warning', 'warnings')}.`,
556
+ ];
535
557
  }
@@ -7,6 +7,7 @@
7
7
  * have — and an ability change shows up as runbook drift (`runbook --check`).
8
8
  */
9
9
 
10
+ import { ModelLinkError } from '@everystack/model';
10
11
  import type { Ability, FieldSpec, ModelDescriptor, Relation } from '@everystack/model';
11
12
 
12
13
  function typeLabel(spec: FieldSpec): string {
@@ -35,7 +36,8 @@ function referenceTarget(spec: FieldSpec): string | null {
35
36
  try {
36
37
  const target = spec.references() as { table?: string } | undefined;
37
38
  return target?.table ?? 'unknown';
38
- } catch {
39
+ } catch (err) {
40
+ if (err instanceof ModelLinkError) throw err;
39
41
  return 'unknown';
40
42
  }
41
43
  }
@@ -73,7 +75,8 @@ function relationLine(name: string, rel: Relation): string {
73
75
  let target = 'unknown';
74
76
  try {
75
77
  target = rel.target().table;
76
- } catch {
78
+ } catch (err) {
79
+ if (err instanceof ModelLinkError) throw err;
77
80
  /* forward reference that can't resolve here — label stays unknown */
78
81
  }
79
82
  const shape = rel.kind === 'belongsTo' ? 'belongs to' : 'has many';