@everystack/cli 0.4.33 → 0.4.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cli/commands/db-backup.ts +4 -2
- package/src/cli/commands/db-export.ts +3 -2
- package/src/cli/commands/db-reconcile.ts +8 -5
- package/src/cli/commands/db-swap.ts +285 -31
- package/src/cli/commands/db.ts +55 -8
- package/src/cli/derived-apply.ts +14 -4
- package/src/cli/derived-compile.ts +13 -2
- package/src/cli/derived-grants.ts +15 -0
- package/src/cli/derived-introspect.ts +4 -0
- package/src/cli/derived-plan.ts +27 -3
- package/src/cli/derived-source.ts +8 -0
- package/src/cli/swap-execute.ts +263 -2
- package/src/cli/swap-heartbeat.ts +407 -0
package/package.json
CHANGED
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
parseBackupRef, keyForId, crossStageGuard, restoreTargetGuard,
|
|
26
26
|
backupKey, backupId, metaKey, utcStamp,
|
|
27
27
|
} from '../backup.js';
|
|
28
|
-
import { pgEnvFromUrl } from './db.js';
|
|
28
|
+
import { pgEnvFromUrl, pgKeepaliveConninfo } from './db.js';
|
|
29
29
|
import { pollTaskUntilStopped } from '../task-poll.js';
|
|
30
30
|
import { step, success, fail, info, warn } from '../output.js';
|
|
31
31
|
|
|
@@ -57,7 +57,9 @@ export function resolveBackupVenue(flags: Record<string, string>): BackupVenue {
|
|
|
57
57
|
* restoring it would rewind that history AND makes pg_restore --clean fail on DROP SCHEMA everystack).
|
|
58
58
|
*/
|
|
59
59
|
export function pgDumpFullArgs(): string[] {
|
|
60
|
-
|
|
60
|
+
// -d carries keepalives only; the credential still rides the PG* env (see db.ts). A full dump of
|
|
61
|
+
// a large database goes quiet for long stretches, which is exactly when an unkept connection dies.
|
|
62
|
+
return ['-d', pgKeepaliveConninfo(), '-Fc', '--no-owner', '--no-privileges', '--no-comments', '--exclude-schema=everystack'];
|
|
61
63
|
}
|
|
62
64
|
|
|
63
65
|
/** The .meta.json sidecar — byte-shape identical to the server's runBackup meta (backup-run.ts:156),
|
|
@@ -26,7 +26,7 @@ import { fingerprintModels } from '../schema-fingerprint.js';
|
|
|
26
26
|
import { resolveModelsPath } from '../models-path.js';
|
|
27
27
|
import { loadModels } from './db-generate.js';
|
|
28
28
|
import { loadDeclaredDerived } from '../declared-derived.js';
|
|
29
|
-
import { pgEnvFromUrl } from './db.js';
|
|
29
|
+
import { pgEnvFromUrl, pgKeepaliveConninfo } from './db.js';
|
|
30
30
|
import { utcStamp } from '../backup.js';
|
|
31
31
|
import { pollTaskUntilStopped } from '../task-poll.js';
|
|
32
32
|
import { step, success, fail, info } from '../output.js';
|
|
@@ -99,7 +99,8 @@ export function localArtifactMeta(opts: {
|
|
|
99
99
|
|
|
100
100
|
/** pg_dump args for the local venue — same dump shape as the ops venue, written straight to a file. */
|
|
101
101
|
export function pgDumpLocalArgs(schema: string, dumpPath: string): string[] {
|
|
102
|
-
|
|
102
|
+
// -d carries keepalives only; the credential still rides the PG* env (see db.ts).
|
|
103
|
+
return ['-d', pgKeepaliveConninfo(), '-Fc', '--no-owner', '--no-privileges', '--no-comments', `--schema=${schema}`, '-f', dumpPath];
|
|
103
104
|
}
|
|
104
105
|
|
|
105
106
|
/**
|
|
@@ -206,7 +206,7 @@ export async function executeReconcile(
|
|
|
206
206
|
// 'sql'-kind objects are invisible to the catalog: provenance records the source
|
|
207
207
|
// hash on both sides plus HOW TO REMOVE it (the escape hatch's whole contract).
|
|
208
208
|
if (src.kind === 'sql') {
|
|
209
|
-
bookkeeping.push(renderProvenanceUpsert(identity, src.hash, src.hash, 'sql', src.drop));
|
|
209
|
+
bookkeeping.push(renderProvenanceUpsert(identity, src.hash, src.hash, 'sql', src.drop, src.bodyHash));
|
|
210
210
|
continue;
|
|
211
211
|
}
|
|
212
212
|
const liveObj = liveById.get(identity);
|
|
@@ -214,7 +214,7 @@ export async function executeReconcile(
|
|
|
214
214
|
throw new Error(`reconcile applied but ${identity} is not introspectable afterwards — provenance not recorded, investigate`);
|
|
215
215
|
}
|
|
216
216
|
const dropSql = src.kind === 'trigger' && src.table ? triggerDropSql(src.name, src.table) : undefined;
|
|
217
|
-
bookkeeping.push(renderProvenanceUpsert(identity, src.hash, liveObj.defHash, src.kind, dropSql));
|
|
217
|
+
bookkeeping.push(renderProvenanceUpsert(identity, src.hash, liveObj.defHash, src.kind, dropSql, src.bodyHash));
|
|
218
218
|
}
|
|
219
219
|
for (const m of rendered.migrate) bookkeeping.push(renderProvenanceMigrate(m.from, m.to));
|
|
220
220
|
for (const identity of rendered.remove) bookkeeping.push(renderProvenanceDelete(identity));
|
|
@@ -292,7 +292,7 @@ export function formatBytes(bytes: number): string {
|
|
|
292
292
|
}
|
|
293
293
|
|
|
294
294
|
const VERB_GLYPH: Record<string, string> = {
|
|
295
|
-
create: '+', replace: '~', drop: '-', refresh: '↻', baseline: '◦', rebaseline: '≈', prune: '·',
|
|
295
|
+
create: '+', replace: '~', drop: '-', refresh: '↻', baseline: '◦', rebaseline: '≈', prune: '·', backfill: '◌',
|
|
296
296
|
};
|
|
297
297
|
|
|
298
298
|
export function buildReconcileReport(plan: ReconcilePlan): string[] {
|
|
@@ -330,9 +330,12 @@ export function buildReconcileReport(plan: ReconcilePlan): string[] {
|
|
|
330
330
|
}
|
|
331
331
|
|
|
332
332
|
/** Anything that makes `--check` fail: pending work or unresolved findings.
|
|
333
|
-
* Baseline and rebaseline are explicit adoption the operator asked for, not findings
|
|
333
|
+
* Baseline and rebaseline are explicit adoption the operator asked for, not findings; a
|
|
334
|
+
* backfill is record-only bookkeeping (body_hash arming) — neither is a CI failure. An
|
|
335
|
+
* UNAPPLIED authz-only grant change still fails here via `regrants` below (the real signal);
|
|
336
|
+
* an empty regrant delta means live already matches declared, so green is correct. */
|
|
334
337
|
export function checkFails(plan: ReconcilePlan): boolean {
|
|
335
|
-
return plan.actions.some((a) => a.action !== 'baseline' && a.action !== 'rebaseline')
|
|
338
|
+
return plan.actions.some((a) => a.action !== 'baseline' && a.action !== 'rebaseline' && a.action !== 'backfill')
|
|
336
339
|
|| plan.drift.length > 0
|
|
337
340
|
|| plan.needsBaseline.length > 0
|
|
338
341
|
|| plan.blocked.length > 0
|
|
@@ -27,14 +27,17 @@ import { resolveModelsPath } from '../models-path.js';
|
|
|
27
27
|
import { loadModels } from './db-generate.js';
|
|
28
28
|
import { loadDeclaredDerived } from '../declared-derived.js';
|
|
29
29
|
import { createUrlRunner } from '../db-source.js';
|
|
30
|
+
import type { QueryRunner } from '../authz-contract.js';
|
|
30
31
|
import { executeSwap, type SwapVerdict } from '../swap-execute.js';
|
|
32
|
+
import { startHeartbeat, humanElapsed } from '../swap-heartbeat.js';
|
|
33
|
+
import { formatBytes } from '../bundle-weight.js';
|
|
31
34
|
import { rewriteStatementLine, opensCopyData, closesCopyData } from '../schema-rewrite.js';
|
|
32
35
|
import { resolveOperatorUrlViaStage } from '../direct-venue.js';
|
|
33
36
|
import { withMutationLease, MutationLeaseError } from '../mutation-lease.js';
|
|
34
37
|
import { resolveConfig, opsFunction } from '../config.js';
|
|
35
38
|
import { invokeAction, presignGet } from '../aws.js';
|
|
36
39
|
import { keyForArtifactId, metaKey } from '../backup.js';
|
|
37
|
-
import { pgEnvFromUrl } from './db.js';
|
|
40
|
+
import { pgEnvFromUrl, pgKeepaliveConninfo } from './db.js';
|
|
38
41
|
import { step, success, fail, warn, info } from '../output.js';
|
|
39
42
|
|
|
40
43
|
/** A COPY-aware line transform that rewrites the schema token on statement lines only. */
|
|
@@ -70,37 +73,254 @@ function schemaRewriteStream(from: string, to: string): Transform {
|
|
|
70
73
|
}
|
|
71
74
|
|
|
72
75
|
/**
|
|
73
|
-
*
|
|
74
|
-
*
|
|
76
|
+
* Consecutive polls reporting NO server-side backend before the watchdog kills psql. Three at the
|
|
77
|
+
* 10s cadence is 30s of agreement, which is well past any single-poll blip and far short of the
|
|
78
|
+
* eight minutes the unguarded version cost.
|
|
75
79
|
*/
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
80
|
+
const DEAD_BACKEND_POLLS = 3;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* TOC entry types that a DERIVED object can appear as. Order matters: the longer types must be
|
|
84
|
+
* tested first, or `MATERIALIZED VIEW DATA` parses as `MATERIALIZED VIEW` with a mangled schema.
|
|
85
|
+
*/
|
|
86
|
+
const DERIVED_TOC_TYPES = [
|
|
87
|
+
'MATERIALIZED VIEW DATA',
|
|
88
|
+
'MATERIALIZED VIEW',
|
|
89
|
+
'PROCEDURE',
|
|
90
|
+
'FUNCTION',
|
|
91
|
+
'TRIGGER',
|
|
92
|
+
'VIEW',
|
|
93
|
+
] as const;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Strip the DECLARED DERIVED objects out of a pg_restore TOC listing.
|
|
97
|
+
*
|
|
98
|
+
* An artifact should carry BASE STATE — tables, data, indexes, constraints, sequences. It should
|
|
99
|
+
* not carry the derived layer, because db:reconcile owns that and rebuilds it from the descriptors.
|
|
100
|
+
* `pg_dump --schema=<s>` cannot make that distinction: it dumps everything in the schema, derived
|
|
101
|
+
* objects included.
|
|
102
|
+
*
|
|
103
|
+
* Shipping them is not merely redundant, it DEADLOCKS the swap. A derived object inside the base
|
|
104
|
+
* schema may reference the derived schema built on top of it (`stats.draft_value` returns
|
|
105
|
+
* `SETOF stats_view.draft_value_row`). The restore then cannot run until the derived layer exists,
|
|
106
|
+
* while the derived layer cannot be built until the new base tables land. Neither can go first.
|
|
107
|
+
* Measured on a real artifact: the only entries referencing the derived schema were the 16
|
|
108
|
+
* reconcile-managed functions; every other entry was state.
|
|
109
|
+
*
|
|
110
|
+
* Filtering by TOC entry rather than by SQL text is deliberate: `pg_restore -L` is the supported
|
|
111
|
+
* mechanism for restoring a subset, and the alternative is pattern-matching a six-million-line SQL
|
|
112
|
+
* file. Entries are COMMENTED OUT rather than deleted so the listing stays diffable.
|
|
113
|
+
*/
|
|
114
|
+
export function filterDerivedFromToc(
|
|
115
|
+
toc: string,
|
|
116
|
+
declaredIdentities: Iterable<string>,
|
|
117
|
+
): { listing: string; skipped: string[] } {
|
|
118
|
+
const declared = new Set(declaredIdentities);
|
|
119
|
+
const skipped: string[] = [];
|
|
120
|
+
const listing = toc.split('\n').map((line) => {
|
|
121
|
+
// `<dumpId>; <catalogOid> <oid> <TYPE> <schema> <name(args)> <owner>`
|
|
122
|
+
const m = /^(\d+;\s+\d+\s+\d+)\s+(.+)$/.exec(line);
|
|
123
|
+
if (!m) return line; // header/comment/blank — pass through untouched
|
|
124
|
+
const rest = m[2];
|
|
125
|
+
const type = DERIVED_TOC_TYPES.find((t) => rest.startsWith(`${t} `));
|
|
126
|
+
if (!type) return line; // not a derived-capable entry (TABLE, INDEX, POLICY, ...)
|
|
127
|
+
const after = rest.slice(type.length + 1);
|
|
128
|
+
const parts = after.split(/\s+/);
|
|
129
|
+
if (parts.length < 2) return line;
|
|
130
|
+
const schema = parts[0];
|
|
131
|
+
// The owner is the last token; everything between it and the schema is the name (+args).
|
|
132
|
+
const nameWithArgs = parts.slice(1, -1).join(' ');
|
|
133
|
+
const name = nameWithArgs.replace(/\(.*$/, ''); // drop the argument list
|
|
134
|
+
if (!declared.has(`${schema}.${name}`)) return line;
|
|
135
|
+
skipped.push(`${schema}.${name} (${type.toLowerCase()})`);
|
|
136
|
+
return `;${line}`;
|
|
137
|
+
}).join('\n');
|
|
138
|
+
return { listing, skipped };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** A pass-through that counts the bytes crossing it — Phase A's progress signal. */
|
|
142
|
+
function countingTap(onBytes: (total: number) => void): Transform {
|
|
143
|
+
let total = 0;
|
|
144
|
+
return new Transform({
|
|
145
|
+
transform(chunk, _enc, cb) {
|
|
146
|
+
total += chunk.length;
|
|
147
|
+
onBytes(total);
|
|
148
|
+
cb(null, chunk);
|
|
149
|
+
},
|
|
98
150
|
});
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The observation sinks the restore reports through. Injected so the phases stay testable. */
|
|
154
|
+
interface RestoreIO {
|
|
155
|
+
/** The operator connection — IDLE for the whole restore, so the Phase B heartbeat reuses it. */
|
|
156
|
+
runner: QueryRunner;
|
|
157
|
+
log: (msg: string) => void;
|
|
158
|
+
warn: (msg: string) => void;
|
|
159
|
+
/**
|
|
160
|
+
* `schema.name` of every DECLARED derived object. These are stripped from the restore: the
|
|
161
|
+
* artifact carries base state, db:reconcile owns the derived layer. See filterDerivedFromToc.
|
|
162
|
+
*/
|
|
163
|
+
declaredIdentities: string[];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Restore a `-Fc` artifact into `<schema>_incoming`, in TWO phases split by a local temp file:
|
|
168
|
+
*
|
|
169
|
+
* Phase A (local, no network): pg_restore -f - (archive → SQL) → the COPY-safe schema rewrite →
|
|
170
|
+
* a local `.sql` file. The archive names `<schema>`; the rewrite lands it as `<incoming>`.
|
|
171
|
+
* Phase B (network): psql -f <file> streams that file to the target at psql's own pace.
|
|
172
|
+
*
|
|
173
|
+
* Why the temp file and not a live `pg_restore | rewrite | psql` pipe: decoupling the producer
|
|
174
|
+
* (local, fast) from the consumer removes all cross-process backpressure, and it makes Phase A
|
|
175
|
+
* measurable on its own. Costs one temp file (~the uncompressed dump), cleaned up in `finally`.
|
|
176
|
+
*
|
|
177
|
+
* A CORRECTION, because the original note here sent two debugging sessions down the wrong path.
|
|
178
|
+
* It claimed the three-way pipe DEADLOCKED at the DDL→data boundary and that the temp file was the
|
|
179
|
+
* cure. The temp file went in, and the restore STILL died at the same place. The pipe was never the
|
|
180
|
+
* root cause; the `write EPIPE` it produced was a downstream symptom.
|
|
181
|
+
*
|
|
182
|
+
* The real cause (proven 2026-07-27, see db.ts's keepalive note): the connection dies during the
|
|
183
|
+
* long quiet stretch of the index/constraint phase, the server terminates the backend, and the
|
|
184
|
+
* client — which never sees a FIN or RST — blocks forever on a socket its kernel still calls
|
|
185
|
+
* ESTABLISHED. The fix is TCP keepalives on psql's connection, plus the watchdog below, NOT the
|
|
186
|
+
* process topology. The temp file is kept because it is genuinely better instrumented, not because
|
|
187
|
+
* it fixes a deadlock.
|
|
188
|
+
*
|
|
189
|
+
* Both phases are INSTRUMENTED. This ran silent once — a multi-GB push with no output at all — and
|
|
190
|
+
* a run that died unattended was indistinguishable from one still working. Now: a byte counter on
|
|
191
|
+
* Phase A, a server-side heartbeat on Phase B (see swap-heartbeat.ts), and both child processes'
|
|
192
|
+
* stderr streamed as it arrives instead of being withheld until exit.
|
|
193
|
+
*/
|
|
194
|
+
async function restoreIntoIncoming(
|
|
195
|
+
url: string,
|
|
196
|
+
artifactPath: string,
|
|
197
|
+
schema: string,
|
|
198
|
+
incoming: string,
|
|
199
|
+
io: RestoreIO,
|
|
200
|
+
): Promise<void> {
|
|
201
|
+
const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'everystack-restore-'));
|
|
202
|
+
const sqlPath = path.join(tmpDir, `${incoming}.sql`);
|
|
203
|
+
const t0 = Date.now();
|
|
204
|
+
try {
|
|
205
|
+
// Phase A0: strip the declared derived layer from the restore. The artifact is base STATE;
|
|
206
|
+
// db:reconcile owns derived. Landing them here is redundant at best and deadlocks the restore
|
|
207
|
+
// when a derived object in the base schema references the derived schema above it.
|
|
208
|
+
const tocPath = path.join(tmpDir, 'toc.list');
|
|
209
|
+
const toc = await new Promise<string>((res, rej) => {
|
|
210
|
+
const p = spawn('pg_restore', ['-l', artifactPath], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
211
|
+
let out = '', err = '';
|
|
212
|
+
p.stdout.on('data', (d) => { out += d.toString(); });
|
|
213
|
+
p.stderr.on('data', (d) => { err += d.toString(); });
|
|
214
|
+
p.on('error', rej);
|
|
215
|
+
p.on('close', (c) => c === 0 ? res(out) : rej(new Error(`pg_restore -l exited ${c}: ${err.trim()}`)));
|
|
216
|
+
});
|
|
217
|
+
const { listing, skipped } = filterDerivedFromToc(toc, io.declaredIdentities);
|
|
218
|
+
await fs.promises.writeFile(tocPath, listing, 'utf8');
|
|
219
|
+
io.log(
|
|
220
|
+
skipped.length
|
|
221
|
+
? `restore: skipping ${skipped.length} declared derived object(s) — db:reconcile owns them (${skipped.slice(0, 6).join(', ')}${skipped.length > 6 ? `, +${skipped.length - 6} more` : ''}).`
|
|
222
|
+
: 'restore: the artifact carries no declared derived objects.',
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
// Phase A: pg_restore → rewrite → local file. Only local processes; nothing can stall here.
|
|
226
|
+
io.log(`restore phase A: pg_restore → schema rewrite (${schema} → ${incoming}) → ${sqlPath}`);
|
|
227
|
+
const restore = spawn('pg_restore', ['-L', tocPath, '-f', '-', artifactPath], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
228
|
+
let rErr = '';
|
|
229
|
+
restore.stderr.on('data', (d) => {
|
|
230
|
+
const s = d.toString();
|
|
231
|
+
rErr += s;
|
|
232
|
+
// Surface as it happens — a warning withheld until exit is a warning nobody can act on.
|
|
233
|
+
for (const line of s.split('\n').map((l: string) => l.trim()).filter(Boolean)) io.log(`pg_restore: ${line}`);
|
|
234
|
+
});
|
|
235
|
+
const restoreExit = new Promise<void>((res, rej) => {
|
|
236
|
+
restore.on('error', rej);
|
|
237
|
+
restore.on('close', (c) => c === 0 ? res() : rej(new Error(`pg_restore exited ${c}: ${rErr.trim()}`)));
|
|
238
|
+
});
|
|
239
|
+
let written = 0;
|
|
240
|
+
const phaseATimer = setInterval(() => {
|
|
241
|
+
io.log(`restore phase A: ${formatBytes(written)} of SQL written (t+${humanElapsed(Date.now() - t0)})...`);
|
|
242
|
+
}, 5_000);
|
|
243
|
+
(phaseATimer as any).unref?.();
|
|
244
|
+
try {
|
|
245
|
+
await Promise.all([
|
|
246
|
+
pipeline(
|
|
247
|
+
restore.stdout!,
|
|
248
|
+
countingTap((n) => { written = n; }),
|
|
249
|
+
schemaRewriteStream(schema, incoming),
|
|
250
|
+
fs.createWriteStream(sqlPath),
|
|
251
|
+
),
|
|
252
|
+
restoreExit,
|
|
253
|
+
]);
|
|
254
|
+
} finally {
|
|
255
|
+
clearInterval(phaseATimer);
|
|
256
|
+
}
|
|
257
|
+
const aMs = Date.now() - t0;
|
|
258
|
+
io.log(`restore phase A done: ${formatBytes(written)} of SQL in ${humanElapsed(aMs)}.`);
|
|
259
|
+
|
|
260
|
+
// Phase B: psql reads the local file and streams to the target. The credential rides PG* env,
|
|
261
|
+
// never argv (libpq also REJECTS non-keyword URI params like the `search_path` the operator URL
|
|
262
|
+
// bakes in — fine for postgres.js, fatal for a libpq URI). `-d` carries ONLY keepalives, which
|
|
263
|
+
// have no PG* env equivalent and are what keep this connection from dying in the index phase.
|
|
264
|
+
io.log(`restore phase B: psql streaming ${formatBytes(written)} to the target — heartbeat every 10s.`);
|
|
265
|
+
const psql = spawn('psql', ['-d', pgKeepaliveConninfo(), '-v', 'ON_ERROR_STOP=1', '-f', sqlPath], {
|
|
266
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
267
|
+
env: { ...process.env, ...pgEnvFromUrl(url) },
|
|
268
|
+
});
|
|
269
|
+
let pErr = '';
|
|
270
|
+
psql.stderr.on('data', (d) => {
|
|
271
|
+
const s = d.toString();
|
|
272
|
+
pErr += s;
|
|
273
|
+
for (const line of s.split('\n').map((l: string) => l.trim()).filter(Boolean)) io.warn(`psql: ${line}`);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
// The watchdog. Keepalives should prevent the dead-backend hang, but if it happens anyway the
|
|
277
|
+
// heartbeat SEES it — pg_stat_activity, read over our own live connection, reports no psql
|
|
278
|
+
// backend while the psql process sits there forever. Detection without action is what cost an
|
|
279
|
+
// eight-minute wait: turn it into a kill and a named failure.
|
|
280
|
+
let deadBackendPolls = 0;
|
|
281
|
+
const stopHeartbeat = startHeartbeat(io.runner, {
|
|
282
|
+
incoming,
|
|
283
|
+
log: io.log,
|
|
284
|
+
warn: io.warn,
|
|
285
|
+
onSample: (sample) => {
|
|
286
|
+
if (sample.state === null) deadBackendPolls += 1;
|
|
287
|
+
else deadBackendPolls = 0;
|
|
288
|
+
if (deadBackendPolls === DEAD_BACKEND_POLLS && psql.exitCode === null) {
|
|
289
|
+
io.warn(
|
|
290
|
+
`restore: psql is still running but the server reports NO backend for it after ${deadBackendPolls} consecutive polls. `
|
|
291
|
+
+ 'The connection died and psql will never notice (no FIN/RST reaches it). Killing it rather than hanging.',
|
|
292
|
+
);
|
|
293
|
+
psql.kill('SIGTERM');
|
|
294
|
+
// SIGTERM will not land if psql is parked in a blocking read on a dead socket.
|
|
295
|
+
const hardKill = setTimeout(() => { if (psql.exitCode === null) psql.kill('SIGKILL'); }, 5_000);
|
|
296
|
+
(hardKill as any).unref?.();
|
|
297
|
+
}
|
|
298
|
+
},
|
|
299
|
+
});
|
|
300
|
+
const bStart = Date.now();
|
|
301
|
+
try {
|
|
302
|
+
await new Promise<void>((res, rej) => {
|
|
303
|
+
psql.on('error', rej);
|
|
304
|
+
psql.on('close', (c) => {
|
|
305
|
+
if (c === 0) return res();
|
|
306
|
+
if (deadBackendPolls >= DEAD_BACKEND_POLLS) {
|
|
307
|
+
return rej(new Error(
|
|
308
|
+
'the restore connection died mid-load and psql hung on it (the server had no backend for it). '
|
|
309
|
+
+ 'psql was killed by the watchdog; NOTHING was swapped and live is untouched. '
|
|
310
|
+
+ 'This is the network path dropping a connection that goes quiet during the index/constraint phase — '
|
|
311
|
+
+ 'keepalives are now set, so if you are seeing this the path is dropping the flow faster than a 30s probe interval.',
|
|
312
|
+
));
|
|
313
|
+
}
|
|
314
|
+
rej(new Error(`psql exited ${c}: ${pErr.trim()}`));
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
} finally {
|
|
318
|
+
await stopHeartbeat();
|
|
319
|
+
}
|
|
320
|
+
io.log(`restore phase B done in ${humanElapsed(Date.now() - bStart)} (restore total ${humanElapsed(Date.now() - t0)}).`);
|
|
321
|
+
} finally {
|
|
322
|
+
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
|
323
|
+
}
|
|
104
324
|
}
|
|
105
325
|
|
|
106
326
|
/**
|
|
@@ -229,14 +449,24 @@ export async function dbSwapCommand(flags: Record<string, string>): Promise<void
|
|
|
229
449
|
process.exit(1);
|
|
230
450
|
}
|
|
231
451
|
|
|
452
|
+
// --rebuild-derived carries a real outage window: the derived layer does not exist between the
|
|
453
|
+
// swap committing and db:reconcile --apply finishing. State it BEFORE the work starts — saying it
|
|
454
|
+
// only afterward tells the operator about an outage they are already in.
|
|
455
|
+
if (flags['rebuild-derived'] === 'true') {
|
|
456
|
+
warn('--rebuild-derived drops the dependent derived objects as part of the swap. They do NOT exist until db:reconcile --apply finishes — an outage window proportional to the size of the derived layer.');
|
|
457
|
+
}
|
|
458
|
+
|
|
232
459
|
const modelsPath = resolveModelsPath(flags.models);
|
|
233
460
|
let models: ModelDescriptor[];
|
|
234
461
|
let declaredFingerprint: string;
|
|
462
|
+
let declaredDerivedObjects: Array<{ identity: string }> = [];
|
|
235
463
|
try {
|
|
236
464
|
step(`Loading models from ${modelsPath}...`);
|
|
237
465
|
models = await loadModels(modelsPath);
|
|
238
466
|
const declaredDb = await loadDeclaredDerived(flags.models);
|
|
239
467
|
declaredFingerprint = fingerprintModels(models, { schemas: [schema], sequences: declaredDb?.sequences }).hash;
|
|
468
|
+
// The identities db:reconcile can regenerate — what makes a dependent safe to drop.
|
|
469
|
+
declaredDerivedObjects = declaredDb?.objects ?? [];
|
|
240
470
|
} catch (err: any) { fail(err.message); process.exit(1); }
|
|
241
471
|
|
|
242
472
|
// Resolve --from to a local plain -Fc dump (a local file, or an S3 export id fetched down).
|
|
@@ -263,7 +493,20 @@ export async function dbSwapCommand(flags: Record<string, string>): Promise<void
|
|
|
263
493
|
models, schema,
|
|
264
494
|
artifactFingerprint,
|
|
265
495
|
declaredFingerprint,
|
|
266
|
-
|
|
496
|
+
// What db:reconcile can regenerate — the set a dependent must be in to be safe to drop.
|
|
497
|
+
declaredIdentities: declaredDerivedObjects.map((o) => o.identity),
|
|
498
|
+
rebuildDerived: flags['rebuild-derived'] === 'true',
|
|
499
|
+
log: (m) => info(m),
|
|
500
|
+
// The runner is handed in and USED: it is idle for the whole restore, so the Phase B
|
|
501
|
+
// heartbeat reads the loading backend's state over it (swap-heartbeat.ts).
|
|
502
|
+
applyIncoming: async (r) => {
|
|
503
|
+
await restoreIntoIncoming(url!, artifact.dumpPath, schema, `${schema}_incoming`, {
|
|
504
|
+
runner: r,
|
|
505
|
+
log: (m) => info(m),
|
|
506
|
+
warn: (m) => warn(m),
|
|
507
|
+
declaredIdentities: declaredDerivedObjects.map((o) => o.identity),
|
|
508
|
+
});
|
|
509
|
+
},
|
|
267
510
|
snapshot: snapshotViaStage
|
|
268
511
|
? async () => {
|
|
269
512
|
step('Snapshotting the stage before the swap (db:backup)...');
|
|
@@ -278,8 +521,19 @@ export async function dbSwapCommand(flags: Record<string, string>): Promise<void
|
|
|
278
521
|
if (res.status === 'swapped') {
|
|
279
522
|
success(`Swapped ${schema} — the artifact is live (no refresh ran).`);
|
|
280
523
|
for (const w of res.warnings ?? []) warn(`verify warning: ${w.name}${w.detail ? ` — ${w.detail}` : ''}`);
|
|
524
|
+
// The derived layer was dropped with the swap. Say so LOUDLY: until reconcile runs, every
|
|
525
|
+
// reader of those objects is looking at a schema that no longer has them.
|
|
526
|
+
if (flags['rebuild-derived'] === 'true') {
|
|
527
|
+
warn(`the derived objects depending on ${schema} were dropped — they do NOT exist until you regenerate them.`);
|
|
528
|
+
warn(` run now: everystack db:reconcile --apply --stage ${stage ?? '<stage>'} --direct`);
|
|
529
|
+
}
|
|
281
530
|
} else {
|
|
282
531
|
fail(`db:swap ${res.status}: ${res.reason}`);
|
|
532
|
+
// Name every object CASCADE would have destroyed, one per line — a comma-joined list of
|
|
533
|
+
// dozens is unreadable, and this is the list the operator has to act on.
|
|
534
|
+
if (res.status === 'refused-dependents') {
|
|
535
|
+
for (const d of res.dependents ?? []) info(` would be destroyed: ${d.schema}.${d.name} (${d.kind})`);
|
|
536
|
+
}
|
|
283
537
|
process.exit(1);
|
|
284
538
|
}
|
|
285
539
|
} catch (err: any) {
|
package/src/cli/commands/db.ts
CHANGED
|
@@ -38,6 +38,51 @@ export function pgEnvFromUrl(url: string): Record<string, string> {
|
|
|
38
38
|
return env;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
// --- TCP keepalives: why every long-running pg binary needs them -------------------------------
|
|
42
|
+
//
|
|
43
|
+
// Verified failure, 2026-07-27, on a real db:swap against a deployed stage. A restore's COPY phase
|
|
44
|
+
// saturates the connection; the index/constraint phase that FOLLOWS it sends one statement and then
|
|
45
|
+
// goes quiet for minutes. During that quiet period a stateful middlebox on the path (NAT gateway,
|
|
46
|
+
// firewall) drops the flow's state. The server's own keepalive then finds a dead peer and
|
|
47
|
+
// terminates the backend. The CLIENT, on the far side of the break, never receives a FIN or RST:
|
|
48
|
+
// its socket stays ESTABLISHED and it blocks on a read that will never return.
|
|
49
|
+
//
|
|
50
|
+
// Measured: psql alive at 8m54s with 0.05s CPU, socket ESTABLISHED and Send-Q 0, while a SECOND
|
|
51
|
+
// connection to the same database confirmed via pg_stat_activity that no psql backend existed.
|
|
52
|
+
//
|
|
53
|
+
// Client keepalives fix both halves. Probing every 30s of quiet keeps the middlebox's state alive
|
|
54
|
+
// so the flow is never dropped, and if the peer does die the client declares it dead in ~80s
|
|
55
|
+
// (30 + 5 x 10) instead of hanging forever.
|
|
56
|
+
//
|
|
57
|
+
// These MUST travel as libpq connection parameters — there is no PG* environment variable for
|
|
58
|
+
// keepalives. Passing them via `-d` keyword/value form still leaves host/user/password/sslmode to
|
|
59
|
+
// the PG* env, because libpq resolves each parameter from the conninfo first and the environment
|
|
60
|
+
// second. So the credential stays off argv. Mirrors @everystack/server's backup.ts.
|
|
61
|
+
|
|
62
|
+
export const PG_KEEPALIVE_IDLE_S = 30;
|
|
63
|
+
export const PG_KEEPALIVE_INTERVAL_S = 10;
|
|
64
|
+
export const PG_KEEPALIVE_COUNT = 5;
|
|
65
|
+
|
|
66
|
+
/** Escape a value for libpq keyword/value conninfo (single-quoted, backslash-escaped). */
|
|
67
|
+
function conninfoValue(v: string): string {
|
|
68
|
+
return `'${v.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A libpq keyword/value conninfo carrying ONLY keepalives (plus `dbname` when given). Everything
|
|
73
|
+
* else still resolves from the PG* env — see the note above. Safe to log: it holds no credential.
|
|
74
|
+
*/
|
|
75
|
+
export function pgKeepaliveConninfo(dbName?: string): string {
|
|
76
|
+
const parts = [
|
|
77
|
+
'keepalives=1',
|
|
78
|
+
`keepalives_idle=${PG_KEEPALIVE_IDLE_S}`,
|
|
79
|
+
`keepalives_interval=${PG_KEEPALIVE_INTERVAL_S}`,
|
|
80
|
+
`keepalives_count=${PG_KEEPALIVE_COUNT}`,
|
|
81
|
+
];
|
|
82
|
+
if (dbName) parts.unshift(`dbname=${conninfoValue(dbName)}`);
|
|
83
|
+
return parts.join(' ');
|
|
84
|
+
}
|
|
85
|
+
|
|
41
86
|
export async function dbMigrateCommand(flags: Record<string, string>): Promise<void> {
|
|
42
87
|
step('Resolving deployed config...');
|
|
43
88
|
let config;
|
|
@@ -160,10 +205,14 @@ export interface ProvisionSecretPlan {
|
|
|
160
205
|
}
|
|
161
206
|
|
|
162
207
|
/**
|
|
163
|
-
* Decide what db:provision writes and what it announces.
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
* `secrets export`
|
|
208
|
+
* Decide what db:provision writes and what it announces. Writes the canonical
|
|
209
|
+
* SCREAMING_SNAKE names only (DATABASE_URL / ADMIN_DATABASE_URL) — the names the
|
|
210
|
+
* reference app declares (`new sst.Secret('DATABASE_URL')`), getDatabaseUrl() /
|
|
211
|
+
* getAdminDatabaseUrl() read, and `secrets export` emits to .env. (The old both-
|
|
212
|
+
* spellings write hedged against an sst.Secret needing a PascalCase name — it does
|
|
213
|
+
* not; underscore names are valid, so the PascalCase copy had no consumer.) The
|
|
214
|
+
* READS of `existing` below still tolerate the legacy PascalCase spelling so a stage
|
|
215
|
+
* provisioned by an older CLI is still detected. Pure — the contract is pinned by tests.
|
|
167
216
|
*/
|
|
168
217
|
export function buildProvisionSecretPlan(args: {
|
|
169
218
|
result: { loginRole: string; adminRole?: string; adminVerified?: boolean | null };
|
|
@@ -174,13 +223,12 @@ export function buildProvisionSecretPlan(args: {
|
|
|
174
223
|
const { result, authUrl, adminUrl, existing } = args;
|
|
175
224
|
const updates: Record<string, string> = {
|
|
176
225
|
DATABASE_URL: authUrl,
|
|
177
|
-
DatabaseUrl: authUrl,
|
|
178
226
|
};
|
|
179
227
|
const notes: string[] = [];
|
|
180
228
|
const warnings: string[] = [];
|
|
181
229
|
|
|
182
230
|
const prevAuthRole = roleOfUrl(existing.DATABASE_URL ?? existing.DatabaseUrl);
|
|
183
|
-
notes.push(`DATABASE_URL
|
|
231
|
+
notes.push(`DATABASE_URL: ${prevAuthRole ?? 'unset'} → ${result.loginRole}`);
|
|
184
232
|
if (prevAuthRole && prevAuthRole !== result.loginRole) {
|
|
185
233
|
warnings.push(
|
|
186
234
|
`DATABASE_URL was already set (role '${prevAuthRole}') — any function linked to it connects as '${result.loginRole}' after its next cold start. Ensure grants are in place: everystack db:reconcile && everystack db:doctor.`,
|
|
@@ -189,10 +237,9 @@ export function buildProvisionSecretPlan(args: {
|
|
|
189
237
|
|
|
190
238
|
if (result.adminRole && adminUrl) {
|
|
191
239
|
updates.ADMIN_DATABASE_URL = adminUrl;
|
|
192
|
-
updates.AdminDatabaseUrl = adminUrl;
|
|
193
240
|
const prevAdminRole = roleOfUrl(existing.AdminDatabaseUrl ?? existing.ADMIN_DATABASE_URL);
|
|
194
241
|
const verified = result.adminVerified === true ? 'login verified' : 'login NOT verified';
|
|
195
|
-
notes.push(`
|
|
242
|
+
notes.push(`ADMIN_DATABASE_URL: ${prevAdminRole ?? 'unset'} → ${result.adminRole} (${verified})`);
|
|
196
243
|
if (result.adminVerified !== true) {
|
|
197
244
|
warnings.push(
|
|
198
245
|
`The '${result.adminRole}' login could not be verified from the ops function — confirm operator connectivity before relying on it: everystack db:doctor.`,
|
package/src/cli/derived-apply.ts
CHANGED
|
@@ -41,6 +41,10 @@ export const ENSURE_RECONCILER_SQL: string[] = [
|
|
|
41
41
|
// databases upgrade in place; their old rows (relations/functions) never need these.
|
|
42
42
|
`ALTER TABLE everystack.derived_provenance ADD COLUMN IF NOT EXISTS kind text`,
|
|
43
43
|
`ALTER TABLE everystack.derived_provenance ADD COLUMN IF NOT EXISTS drop_sql text`,
|
|
44
|
+
// body_hash = the source hash MINUS plain grants (authz-only-change discriminator). Idempotent;
|
|
45
|
+
// legacy rows have NULL until the reconciler re-records them, and a NULL body_hash falls back to
|
|
46
|
+
// the rebuild-on-src-change behavior (no regression) — the fix only ARMS once body_hash is recorded.
|
|
47
|
+
`ALTER TABLE everystack.derived_provenance ADD COLUMN IF NOT EXISTS body_hash text`,
|
|
44
48
|
`CREATE TABLE IF NOT EXISTS everystack.schema_log (
|
|
45
49
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
46
50
|
applied_at timestamptz NOT NULL DEFAULT now(),
|
|
@@ -221,6 +225,12 @@ export function renderReconcileSql(plan: ReconcilePlan, source: SourceObject[]):
|
|
|
221
225
|
record.push(action.identity);
|
|
222
226
|
break;
|
|
223
227
|
}
|
|
228
|
+
// Arm the authz-only fast path on an up-to-date object: no DDL — the upsert just adds the
|
|
229
|
+
// body_hash column value the record loop always writes now. Idempotent; converges in one run.
|
|
230
|
+
case 'backfill': {
|
|
231
|
+
record.push(action.identity);
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
224
234
|
case 'prune': {
|
|
225
235
|
remove.push(action.identity);
|
|
226
236
|
break;
|
|
@@ -235,10 +245,10 @@ export function renderReconcileSql(plan: ReconcilePlan, source: SourceObject[]):
|
|
|
235
245
|
return { statements, record, remove, migrate: [...plan.migrations] };
|
|
236
246
|
}
|
|
237
247
|
|
|
238
|
-
export function renderProvenanceUpsert(identity: string, srcHash: string, defHash: string, kind?: string, dropSql?: string): string {
|
|
239
|
-
return `INSERT INTO everystack.derived_provenance (identity, src_hash, def_hash, kind, drop_sql, applied_at)
|
|
240
|
-
VALUES (${escapeLiteral(identity)}, ${escapeLiteral(srcHash)}, ${escapeLiteral(defHash)}, ${nullable(kind)}, ${nullable(dropSql)}, now())
|
|
241
|
-
ON CONFLICT (identity) DO UPDATE SET src_hash = EXCLUDED.src_hash, def_hash = EXCLUDED.def_hash, kind = EXCLUDED.kind, drop_sql = EXCLUDED.drop_sql, applied_at = now()`;
|
|
248
|
+
export function renderProvenanceUpsert(identity: string, srcHash: string, defHash: string, kind?: string, dropSql?: string, bodyHash?: string): string {
|
|
249
|
+
return `INSERT INTO everystack.derived_provenance (identity, src_hash, def_hash, kind, drop_sql, body_hash, applied_at)
|
|
250
|
+
VALUES (${escapeLiteral(identity)}, ${escapeLiteral(srcHash)}, ${escapeLiteral(defHash)}, ${nullable(kind)}, ${nullable(dropSql)}, ${nullable(bodyHash)}, now())
|
|
251
|
+
ON CONFLICT (identity) DO UPDATE SET src_hash = EXCLUDED.src_hash, def_hash = EXCLUDED.def_hash, kind = EXCLUDED.kind, drop_sql = EXCLUDED.drop_sql, body_hash = EXCLUDED.body_hash, applied_at = now()`;
|
|
242
252
|
}
|
|
243
253
|
|
|
244
254
|
/** A table rename carried its triggers along — same object, new identity; the provenance
|
|
@@ -22,14 +22,22 @@ import type {
|
|
|
22
22
|
FunctionDescriptor, SqlDescriptor, TriggerSpec, Ability, SetofReturn, DependsOnRef,
|
|
23
23
|
} from '@everystack/model';
|
|
24
24
|
import { hashSourceContent, parseQualified, DECLARED_SOURCE_FILE, type Attachment, type SourceObject } from './derived-source.js';
|
|
25
|
+
import { isPlainGrantAttachment } from './derived-grants.js';
|
|
25
26
|
import { findInvokerReachabilityGaps } from './derived-lint.js';
|
|
26
27
|
|
|
27
28
|
/** The provenance marker for descriptor-compiled objects (SourceObject.file). */
|
|
28
29
|
const DECLARED = DECLARED_SOURCE_FILE;
|
|
29
30
|
|
|
30
|
-
/**
|
|
31
|
+
/**
|
|
32
|
+
* Always schema-qualify — the CREATE/GRANT/COMMENT target must be `schema.name` so the object lands
|
|
33
|
+
* in its DECLARED schema regardless of the apply-time search_path order. A bare `public` name landed
|
|
34
|
+
* in search_path[0] instead (derivedSearchPath puts public LAST), so a new public derived object
|
|
35
|
+
* created while a non-public derived schema existed mislanded into the wrong schema and the record
|
|
36
|
+
* loop couldn't find it. (The old bare-public rendering chased hash parity with hand-written db/sql
|
|
37
|
+
* sources; db/sql is retired — B7 — so that rationale is dead. Qualifying is the correctness rule.)
|
|
38
|
+
*/
|
|
31
39
|
function renderName(schema: string, name: string): string {
|
|
32
|
-
return
|
|
40
|
+
return `${schema}.${name}`;
|
|
33
41
|
}
|
|
34
42
|
|
|
35
43
|
/** The declared name of a dependable ref, for rendering (`SETOF posts`) and identity. */
|
|
@@ -243,6 +251,9 @@ function make(kind: SourceObject['kind'], rawName: string, sql: string, attachme
|
|
|
243
251
|
identity: extra.identity ?? `${schema}.${name}`,
|
|
244
252
|
sql, attachments,
|
|
245
253
|
hash: hashSourceContent(sql, attachments),
|
|
254
|
+
// bodyHash excludes PLAIN grants (column-scoped grants stay in — attacl isn't drift-checked, so
|
|
255
|
+
// a column-grant change must still rebuild). Equal across an authz-only plain-grant change.
|
|
256
|
+
bodyHash: hashSourceContent(sql, attachments.filter((a) => !isPlainGrantAttachment(a))),
|
|
246
257
|
file: DECLARED, seq,
|
|
247
258
|
...extra,
|
|
248
259
|
});
|