@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.
- package/CHANGELOG.md +28 -0
- package/package.json +13 -3
- package/src/cli/apply-execute.ts +45 -0
- 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-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.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 +11 -7
- package/src/cli/declared-derived.ts +6 -1
- package/src/cli/derived-apply.ts +20 -5
- 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
|
@@ -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' }
|
|
@@ -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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
}
|
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
|
// ---------------------------------------------------------------------------
|
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
|
|
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
|
package/src/cli/plain-output.ts
CHANGED
|
@@ -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
|
-
|
|
382
|
-
|
|
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
|
|
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 (
|
|
534
|
-
|
|
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';
|