@everystack/cli 0.4.20 → 0.4.23
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 +9 -1
- package/src/cli/commands/db-reconcile.ts +47 -30
- package/src/cli/commands/db-refresh.ts +97 -0
- package/src/cli/commands/db.ts +397 -67
- package/src/cli/index.ts +8 -3
- package/src/cli/refresh-execute.ts +63 -0
- package/src/reconcile.ts +11 -0
- package/src/refresh.ts +11 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.23",
|
|
4
4
|
"description": "CLI and OTA updates for Expo apps on everystack",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"author": "Scalable Technology, Inc. <licensing@scalable.technology>",
|
|
@@ -37,6 +37,14 @@
|
|
|
37
37
|
"types": "./src/cli/apply-execute.ts",
|
|
38
38
|
"default": "./src/cli/apply-execute.ts"
|
|
39
39
|
},
|
|
40
|
+
"./reconcile": {
|
|
41
|
+
"types": "./src/reconcile.ts",
|
|
42
|
+
"default": "./src/reconcile.ts"
|
|
43
|
+
},
|
|
44
|
+
"./refresh": {
|
|
45
|
+
"types": "./src/refresh.ts",
|
|
46
|
+
"default": "./src/refresh.ts"
|
|
47
|
+
},
|
|
40
48
|
"./audit/source": {
|
|
41
49
|
"types": "./src/cli/audit-source-api.ts",
|
|
42
50
|
"default": "./src/cli/audit-source-api.ts"
|
|
@@ -23,10 +23,10 @@
|
|
|
23
23
|
* explicit trust that live == source, recorded). A source RE-RENDER over an
|
|
24
24
|
* untouched live object — the db/sql-era → descriptor migration — needs
|
|
25
25
|
* `--rebaseline` once: live is verified (defHash match), the new source hash
|
|
26
|
-
* is re-recorded, nothing rebuilds. `--apply`
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
26
|
+
* is re-recorded, nothing rebuilds. `--apply --stage` runs the whole reconcile
|
|
27
|
+
* credential-free in the ops Lambda on the operator connection (the `db:reconcile`
|
|
28
|
+
* action, the db:apply twin) — the deployer holds no URL. `--apply --database-url`
|
|
29
|
+
* runs it direct. A stage DRY-RUN (no `--apply`) reads through the `db:query` path.
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
32
|
import type { QueryRunner } from '../authz-contract.js';
|
|
@@ -380,11 +380,6 @@ export async function dbReconcileCommand(flags: Record<string, string>): Promise
|
|
|
380
380
|
process.exit(1);
|
|
381
381
|
}
|
|
382
382
|
|
|
383
|
-
if (apply && dbSource.kind !== 'url') {
|
|
384
|
-
fail('--apply needs a direct connection (--database-url or DATABASE_URL) — the ops Lambda query path is read-only by design.');
|
|
385
|
-
process.exit(1);
|
|
386
|
-
}
|
|
387
|
-
|
|
388
383
|
// --baseline only writes under --apply (executeReconcile returns the plan and executes nothing
|
|
389
384
|
// when apply=false). Passing it alone used to print "recording provenance…" and exit 0 while
|
|
390
385
|
// persisting nothing — a silent no-op. Fail loudly instead; the plan already lists needsBaseline.
|
|
@@ -416,29 +411,51 @@ export async function dbReconcileCommand(flags: Record<string, string>): Promise
|
|
|
416
411
|
info(`${declared.objects.length} declared derived object(s) from the models barrel.`);
|
|
417
412
|
}
|
|
418
413
|
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
414
|
+
const reconcileOptions = {
|
|
415
|
+
apply,
|
|
416
|
+
declared: declared?.objects,
|
|
417
|
+
renamedTables: declared?.renamedTables,
|
|
418
|
+
baseline: flags.baseline === 'true',
|
|
419
|
+
rebaseline: flags.rebaseline === 'true',
|
|
420
|
+
rebuild: flags.rebuild === 'true',
|
|
421
|
+
overwriteDrift: flags['overwrite-drift'] === 'true',
|
|
422
|
+
actor: process.env.USER ?? null,
|
|
423
|
+
gitRef: currentGitRef(),
|
|
424
|
+
};
|
|
429
425
|
|
|
426
|
+
// Resolve the run from one of two venues:
|
|
427
|
+
// - --apply on a deployed stage runs the WHOLE reconcile in the ops Lambda on the operator
|
|
428
|
+
// connection (the db:reconcile action) — a least-privileged operator needs no raw admin URL,
|
|
429
|
+
// the derived-edge twin of db:apply.
|
|
430
|
+
// - otherwise executeReconcile runs locally over a direct URL, or over the read-only db:query
|
|
431
|
+
// path for a stage DRY-RUN (planning only reads).
|
|
432
|
+
let end: (() => Promise<void>) | undefined;
|
|
430
433
|
try {
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
434
|
+
let run: ReconcileRun;
|
|
435
|
+
if (apply && dbSource.kind !== 'url') {
|
|
436
|
+
step('Resolving deployed config...');
|
|
437
|
+
const config = await resolveConfig(flags.stage);
|
|
438
|
+
const result: any = await invokeAction(config.region, opsFunction(config), 'db:reconcile', reconcileOptions);
|
|
439
|
+
if (result?.error) {
|
|
440
|
+
fail(`db:reconcile failed: ${result.error}`);
|
|
441
|
+
if (/Unknown action/i.test(String(result.error))) {
|
|
442
|
+
info('The deployed handler predates the db:reconcile ops action (needs @everystack/server >= 0.4.8). Upgrade the server, or apply direct: db:reconcile --apply --database-url <url>.');
|
|
443
|
+
}
|
|
444
|
+
process.exit(1);
|
|
445
|
+
}
|
|
446
|
+
run = { plan: result.plan, applied: result.applied, statements: result.statements ?? [], refusal: result.refusal ?? undefined };
|
|
447
|
+
} else {
|
|
448
|
+
let runner: QueryRunner;
|
|
449
|
+
if (dbSource.kind === 'url') {
|
|
450
|
+
step(connectingVia(dbSource));
|
|
451
|
+
({ runner, end } = await createUrlRunner(dbSource.url));
|
|
452
|
+
} else {
|
|
453
|
+
step('Resolving deployed config...');
|
|
454
|
+
const config = await resolveConfig(flags.stage);
|
|
455
|
+
runner = lambdaRunner(config.region, opsFunction(config));
|
|
456
|
+
}
|
|
457
|
+
run = await executeReconcile(runner, reconcileOptions);
|
|
458
|
+
}
|
|
442
459
|
|
|
443
460
|
if (flags.json === 'true') {
|
|
444
461
|
console.log(JSON.stringify({ ...run.plan, applied: run.applied, refusal: run.refusal ?? null }, null, 2));
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `everystack db:refresh` — refresh the declared materialized views on a stage, in dependency
|
|
3
|
+
* order, WITHOUT a raw database URL on the operator's machine.
|
|
4
|
+
*
|
|
5
|
+
* db:refresh [--stage <name> | --database-url <url> | --direct] [--only a,b] [--list]
|
|
6
|
+
*
|
|
7
|
+
* `db:reconcile` deploys the derived-layer DEFINITION credential-free but no-ops when the def is
|
|
8
|
+
* unchanged — a pure DATA-changed matview refresh had no credential-free path. This is the
|
|
9
|
+
* data-lane twin: `--stage` runs the whole refresh in the ops Lambda on the operator connection
|
|
10
|
+
* (the `db:refresh` action), so a least-privileged operator never holds a URL. `--database-url`
|
|
11
|
+
* / `--direct` refresh over a direct connection (dev). `--list` previews the topo-ordered
|
|
12
|
+
* matviews without connecting. Plain REFRESH (ACCESS EXCLUSIVE) — CONCURRENTLY is a follow-on.
|
|
13
|
+
* (GridironDB 2026-07-19.)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
|
|
17
|
+
import { loadDeclaredDerived } from '../declared-derived.js';
|
|
18
|
+
import { executeRefresh, matviewIdentities, type RefreshRun } from '../refresh-execute.js';
|
|
19
|
+
import { resolveConfig, opsFunction } from '../config.js';
|
|
20
|
+
import { invokeAction } from '../aws.js';
|
|
21
|
+
import { step, success, fail, info } from '../output.js';
|
|
22
|
+
|
|
23
|
+
export async function dbRefreshCommand(flags: Record<string, string>): Promise<void> {
|
|
24
|
+
// The declared matviews, in dependency order (a dependent matview reads its parents' fresh rows).
|
|
25
|
+
let declared;
|
|
26
|
+
try {
|
|
27
|
+
declared = await loadDeclaredDerived(flags.models);
|
|
28
|
+
} catch (err: any) {
|
|
29
|
+
fail(err.message);
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
const only = flags.only ? flags.only.split(',').map((s) => s.trim()).filter(Boolean) : undefined;
|
|
33
|
+
const matviews = matviewIdentities(declared?.objects ?? [], only);
|
|
34
|
+
|
|
35
|
+
if (matviews.length === 0) {
|
|
36
|
+
info(only?.length
|
|
37
|
+
? `No declared matviews match --only ${only.join(',')}.`
|
|
38
|
+
: 'No declared materialized views to refresh (db:refresh operates on defineMaterializedView descriptors).');
|
|
39
|
+
process.exit(0);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// --list: preview the order without connecting anywhere (read-only, no venue needed).
|
|
43
|
+
if (flags.list === 'true') {
|
|
44
|
+
info(`${matviews.length} materialized view(s) would refresh, in this order:`);
|
|
45
|
+
for (const id of matviews) info(` ${id}`);
|
|
46
|
+
process.exit(0);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let dbSource: DbSource;
|
|
50
|
+
try {
|
|
51
|
+
dbSource = resolveDbSource(flags);
|
|
52
|
+
} catch (err: any) {
|
|
53
|
+
fail(err.message);
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Two venues, mirroring the reconcile lane:
|
|
58
|
+
// - a deployed stage runs the whole refresh in the ops Lambda on the operator connection
|
|
59
|
+
// (the db:refresh action) — no raw URL on the operator's machine, the whole point.
|
|
60
|
+
// - a direct URL refreshes locally (dev).
|
|
61
|
+
let end: (() => Promise<void>) | undefined;
|
|
62
|
+
try {
|
|
63
|
+
let run: RefreshRun;
|
|
64
|
+
if (dbSource.kind === 'url') {
|
|
65
|
+
step(connectingVia(dbSource));
|
|
66
|
+
const conn = await createUrlRunner(dbSource.url);
|
|
67
|
+
end = conn.end;
|
|
68
|
+
step(`Refreshing ${matviews.length} materialized view(s)...`);
|
|
69
|
+
run = await executeRefresh(conn.runner, { matviews });
|
|
70
|
+
} else {
|
|
71
|
+
step('Resolving deployed config...');
|
|
72
|
+
const config = await resolveConfig(flags.stage);
|
|
73
|
+
info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
|
|
74
|
+
step(`Refreshing ${matviews.length} materialized view(s) in the ops Lambda...`);
|
|
75
|
+
const result: any = await invokeAction(config.region, opsFunction(config), 'db:refresh', { matviews });
|
|
76
|
+
if (result?.error) {
|
|
77
|
+
fail(`db:refresh failed: ${result.error}`);
|
|
78
|
+
if (/Unknown action/i.test(String(result.error))) {
|
|
79
|
+
info('The deployed handler predates the db:refresh ops action (needs @everystack/server >= 0.4.9). Upgrade the server, or refresh direct: db:refresh --database-url <url>.');
|
|
80
|
+
}
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
run = { refreshed: result.refreshed ?? [], statements: result.statements ?? [], failed: result.failed ?? undefined };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
for (const id of run.refreshed) success(`refreshed ${id}`);
|
|
87
|
+
if (run.failed) {
|
|
88
|
+
fail(`db:refresh stopped at ${run.failed.identity}: ${run.failed.error}`);
|
|
89
|
+
info(`${run.refreshed.length} of ${matviews.length} refreshed before the failure; fix and re-run (refresh is idempotent).`);
|
|
90
|
+
process.exit(1);
|
|
91
|
+
}
|
|
92
|
+
success(`db:refresh — ${run.refreshed.length} materialized view(s) refreshed.`);
|
|
93
|
+
process.exit(0);
|
|
94
|
+
} finally {
|
|
95
|
+
if (end) await end().catch(() => {});
|
|
96
|
+
}
|
|
97
|
+
}
|
package/src/cli/commands/db.ts
CHANGED
|
@@ -242,6 +242,153 @@ export async function loadServerProvision(importer?: () => Promise<unknown>): Pr
|
|
|
242
242
|
}
|
|
243
243
|
}
|
|
244
244
|
|
|
245
|
+
/** The doctor probe's shape (mirrors @everystack/server/db-doctor — runtime-only, same
|
|
246
|
+
* peer seam as loadServerProvision). Runners are plain `(sql) => rows`. */
|
|
247
|
+
export interface ServerDoctor {
|
|
248
|
+
runDoctorProbe(
|
|
249
|
+
runners: {
|
|
250
|
+
api: (sql: string) => Promise<any[]>;
|
|
251
|
+
ops: (sql: string) => Promise<any[]>;
|
|
252
|
+
},
|
|
253
|
+
options?: { apiSourceLabel?: string; forceRlsCarveouts?: string[]; functionSchemas?: string[] },
|
|
254
|
+
): Promise<{
|
|
255
|
+
api: { role: string; isSuperuser: boolean; bypassRls: boolean };
|
|
256
|
+
admin: { role: string; isSuperuser: boolean; bypassRls: boolean };
|
|
257
|
+
checks: Array<{ name: string; status: string; detail: string; remediation?: string }>;
|
|
258
|
+
ok: boolean;
|
|
259
|
+
}>;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Lazily load the doctor probe from the @everystack/server peer — the direct venue's
|
|
264
|
+
* orchestrator. Same runtime-only seam as loadServerProvision; a missing (or too old)
|
|
265
|
+
* peer gets the install/upgrade remedy, not a raw ERR_MODULE_NOT_FOUND.
|
|
266
|
+
*/
|
|
267
|
+
export async function loadServerDoctor(importer?: () => Promise<unknown>): Promise<ServerDoctor> {
|
|
268
|
+
const spec = '@everystack/server/db-doctor';
|
|
269
|
+
try {
|
|
270
|
+
return (await (importer ? importer() : import(spec))) as ServerDoctor;
|
|
271
|
+
} catch (err: unknown) {
|
|
272
|
+
const code = (err as { code?: string })?.code;
|
|
273
|
+
const message = String((err as { message?: string })?.message ?? err);
|
|
274
|
+
if (code === 'ERR_MODULE_NOT_FOUND' || code === 'MODULE_NOT_FOUND' || /Cannot find (package|module)/.test(message)) {
|
|
275
|
+
throw new Error(
|
|
276
|
+
'db:doctor --database-url needs @everystack/server (>= 0.4.9) installed in this app — '
|
|
277
|
+
+ 'it carries the doctor probe:\n pnpm add @everystack/server',
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
throw err;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Resolve the DIRECT venue for `db:doctor`. Unlike provision (one privileged URL) the
|
|
286
|
+
* doctor compares TWO connections — the api credential under scrutiny (DATABASE_URL) and
|
|
287
|
+
* the operator credential carrying the catalog introspection (ADMIN_DATABASE_URL). Explicit
|
|
288
|
+
* flags win (local dev): `--database-url` names the api connection, `--admin-database-url`
|
|
289
|
+
* the operator; a lone `--database-url` probes it as both (an honest api==admin report — a
|
|
290
|
+
* master URL then correctly reads "not least-privilege"). No flags but `--direct` reads BOTH
|
|
291
|
+
* from the stage secrets (the secret-safe, no-Ops on-ramp — never a URL on argv). No flag and
|
|
292
|
+
* no --direct returns null: the ops-Lambda venue.
|
|
293
|
+
*/
|
|
294
|
+
export function resolveDoctorVenue(args: {
|
|
295
|
+
flagApiUrl?: string;
|
|
296
|
+
flagAdminUrl?: string;
|
|
297
|
+
direct?: boolean;
|
|
298
|
+
secrets: Record<string, string>;
|
|
299
|
+
}): { apiUrl: string; adminUrl: string; source: 'flag' | 'secret' } | null {
|
|
300
|
+
const { flagApiUrl, flagAdminUrl, direct, secrets } = args;
|
|
301
|
+
// parseFlags encodes a value-less flag as the string 'true' — a URL was expected.
|
|
302
|
+
if (flagApiUrl === 'true' || flagAdminUrl === 'true') {
|
|
303
|
+
throw new Error('--database-url / --admin-database-url need a connection string (or use --direct to read the stage secrets).');
|
|
304
|
+
}
|
|
305
|
+
if (flagApiUrl || flagAdminUrl) {
|
|
306
|
+
const apiUrl = flagApiUrl ?? flagAdminUrl!;
|
|
307
|
+
const adminUrl = flagAdminUrl ?? flagApiUrl!;
|
|
308
|
+
return { apiUrl, adminUrl, source: 'flag' };
|
|
309
|
+
}
|
|
310
|
+
if (direct) {
|
|
311
|
+
const apiUrl = secrets.DATABASE_URL ?? secrets.DatabaseUrl;
|
|
312
|
+
const adminUrl = secrets.ADMIN_DATABASE_URL ?? secrets.AdminDatabaseUrl;
|
|
313
|
+
if (!apiUrl && !adminUrl) {
|
|
314
|
+
throw new Error(
|
|
315
|
+
'--direct needs the stage\'s DATABASE_URL and ADMIN_DATABASE_URL secrets (the api + operator connections) — '
|
|
316
|
+
+ 'run `everystack db:provision` first, or pass --database-url <api> [--admin-database-url <ops>].',
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
// If one secret is missing, probe the present URL as both (honest degraded report).
|
|
320
|
+
return { apiUrl: apiUrl ?? adminUrl!, adminUrl: adminUrl ?? apiUrl!, source: 'secret' };
|
|
321
|
+
}
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Resolve the DIRECT-venue privileged URL, keeping the master password OFF argv (shell
|
|
327
|
+
* history / ps / CI logs are all leak surfaces, and the whole point of provision is to STOP
|
|
328
|
+
* using master). An explicit --database-url wins; `--direct` reads the stage's already-stored
|
|
329
|
+
* ADMIN_DATABASE_URL secret; no flag returns null (the ops-Lambda venue). A consumer report:
|
|
330
|
+
* "any workflow that requires a human to re-handle the master DB password is both friction and
|
|
331
|
+
* a secret-leak point."
|
|
332
|
+
*/
|
|
333
|
+
export function resolveProvisionDirectUrl(args: {
|
|
334
|
+
flagUrl?: string;
|
|
335
|
+
direct?: boolean;
|
|
336
|
+
secrets: Record<string, string>;
|
|
337
|
+
}): { url: string; source: 'flag' | 'secret' } | null {
|
|
338
|
+
if (args.flagUrl) return { url: args.flagUrl, source: 'flag' };
|
|
339
|
+
if (args.direct) {
|
|
340
|
+
const url = args.secrets.ADMIN_DATABASE_URL ?? args.secrets.AdminDatabaseUrl;
|
|
341
|
+
if (!url) {
|
|
342
|
+
throw new Error(
|
|
343
|
+
'--direct needs the stage\'s ADMIN_DATABASE_URL secret (the privileged URL) — set it with '
|
|
344
|
+
+ '`everystack secrets set ADMIN_DATABASE_URL <url> --stage <stage>`, or pass --database-url <url> once.',
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
return { url, source: 'secret' };
|
|
348
|
+
}
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** The declared non-public schemas (sorted, public trailing) — the API connection's search_path.
|
|
353
|
+
* Empty for an all-public app, so its URL is left byte-identical. */
|
|
354
|
+
export function declaredSearchPath(schemas: string[]): string[] {
|
|
355
|
+
const nonPublic = [...new Set(schemas)].filter((s) => s && s !== 'public').sort();
|
|
356
|
+
return nonPublic.length ? [...nonPublic, 'public'] : [];
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Every schema the API connection must reach — the union of the TABLE schemas (the models
|
|
361
|
+
* barrel) and the DERIVED-object schemas (views/matviews declared via
|
|
362
|
+
* `defineMaterializedView('stats_view.x')`). The derived layer's schema is declared in the
|
|
363
|
+
* descriptors, NOT the barrel, so a table-only scan structurally misses it — and the
|
|
364
|
+
* authenticator flip then goes dark on every bare-ref matview read ("relation does not
|
|
365
|
+
* exist"). Baking the union into DATABASE_URL's search_path keeps those reads resolving.
|
|
366
|
+
* (GridironDB 2026-07-19: 54 matviews in `stats_view`, absent from the table-only bake.)
|
|
367
|
+
*/
|
|
368
|
+
export function collectDeclaredSchemas(args: {
|
|
369
|
+
models: Array<{ schema?: string }>;
|
|
370
|
+
derivedObjects?: Array<{ schema: string }>;
|
|
371
|
+
}): string[] {
|
|
372
|
+
const schemas = args.models.map((m) => m.schema ?? 'public');
|
|
373
|
+
for (const o of args.derivedObjects ?? []) schemas.push(o.schema);
|
|
374
|
+
return schemas;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Bake the declared schemas into a postgres URL as `?search_path=…`. A multi-schema app's role
|
|
379
|
+
* defaults to `"$user",public`, so every bare-ref query 404s until a hand ALTER ROLE pin
|
|
380
|
+
* (recorded nowhere, repeated every stage) — writing it into the minted DATABASE_URL is
|
|
381
|
+
* declarative and survives redeploys. postgres.js forwards unknown URL params as connection
|
|
382
|
+
* startup params (GridironDB proved search_path this way). No-op for an all-public app.
|
|
383
|
+
*/
|
|
384
|
+
export function withSearchPath(url: string, schemas: string[]): string {
|
|
385
|
+
const path = declaredSearchPath(schemas);
|
|
386
|
+
if (!path.length) return url;
|
|
387
|
+
const u = new URL(url);
|
|
388
|
+
u.searchParams.set('search_path', path.join(','));
|
|
389
|
+
return u.toString();
|
|
390
|
+
}
|
|
391
|
+
|
|
245
392
|
/** host/port/database from a postgres URL — the direct venue's connection info. */
|
|
246
393
|
export function parseUrlConnection(url: string): { host: string; port: string; database: string } {
|
|
247
394
|
const u = new URL(url);
|
|
@@ -267,13 +414,34 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
267
414
|
process.exit(1);
|
|
268
415
|
}
|
|
269
416
|
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
const
|
|
417
|
+
// Load the stage secrets once — the direct venue reads the privileged URL from here (never
|
|
418
|
+
// from argv), and the writer below reuses them.
|
|
419
|
+
const { loadSstSecrets, putSstSecrets } = await import('../utils/secrets.js');
|
|
420
|
+
const { parseAppName } = await import('../discover.js');
|
|
421
|
+
const secretsCtx = { appName: await parseAppName(), stage: flags.stage, region: config.region };
|
|
422
|
+
let stageSecrets: Record<string, string> = {};
|
|
423
|
+
try {
|
|
424
|
+
stageSecrets = await loadSstSecrets(secretsCtx);
|
|
425
|
+
} catch {
|
|
426
|
+
// Secrets unreadable (e.g. store not bootstrapped) — direct-from-secret is simply unavailable;
|
|
427
|
+
// an explicit --database-url still works, and the writer below surfaces any real failure.
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Venue: DIRECT connection (bring-your-own-RDS / no Ops Lambda) vs the ops-Lambda invoke.
|
|
431
|
+
// --database-url is explicit; --direct reads the ADMIN_DATABASE_URL secret so master never
|
|
432
|
+
// lands on argv. No flag = ops-Lambda venue, with an auto-fallback to the secret-backed
|
|
433
|
+
// direct connection when the deployed handler has no dbPlugin. --stage always names the
|
|
434
|
+
// secret store the new credentials are written to.
|
|
435
|
+
let venue: { url: string; source: 'flag' | 'secret' } | null;
|
|
436
|
+
try {
|
|
437
|
+
venue = resolveProvisionDirectUrl({ flagUrl: flags['database-url'], direct: !!flags.direct, secrets: stageSecrets });
|
|
438
|
+
} catch (err: any) {
|
|
439
|
+
fail(err.message);
|
|
440
|
+
process.exit(1);
|
|
441
|
+
}
|
|
442
|
+
let directUrl = venue?.url;
|
|
275
443
|
if (directUrl) {
|
|
276
|
-
info(`Region: ${config.region}, venue: direct connection (bring-your-own database)`);
|
|
444
|
+
info(`Region: ${config.region}, venue: direct connection (${venue!.source === 'secret' ? 'ADMIN_DATABASE_URL secret' : 'bring-your-own database'})`);
|
|
277
445
|
} else {
|
|
278
446
|
info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
|
|
279
447
|
}
|
|
@@ -286,33 +454,39 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
286
454
|
const authPassword = randomBytes(24).toString('hex');
|
|
287
455
|
const adminPassword = randomBytes(24).toString('hex');
|
|
288
456
|
|
|
457
|
+
// Run the role chain over a direct connection to `url` — shared by the explicit direct venue
|
|
458
|
+
// and the auto-fallback below.
|
|
459
|
+
const provisionDirect = async (url: string): Promise<any> => {
|
|
460
|
+
const { runProvision } = await loadServerProvision();
|
|
461
|
+
const { createUrlRunner } = await import('../db-source.js');
|
|
462
|
+
const { runner, end } = await createUrlRunner(url);
|
|
463
|
+
try {
|
|
464
|
+
return await runProvision({ authPassword, adminPassword }, {
|
|
465
|
+
execute: async (statement) => runner(statement),
|
|
466
|
+
connection: parseUrlConnection(url),
|
|
467
|
+
// Same probe as the ops-Lambda venue: a real login as the new role, DDL-capable.
|
|
468
|
+
connectProbe: async (probeUrl) => {
|
|
469
|
+
const { default: postgres } = await import('postgres');
|
|
470
|
+
const probe = postgres(probeUrl, { max: 1, connect_timeout: 5, ssl: 'prefer' });
|
|
471
|
+
try {
|
|
472
|
+
await probe`SELECT 1`;
|
|
473
|
+
await probe.unsafe('CREATE TEMP TABLE _everystack_provision_probe (x int)');
|
|
474
|
+
await probe.unsafe('DROP TABLE _everystack_provision_probe');
|
|
475
|
+
} finally {
|
|
476
|
+
await probe.end({ timeout: 1 });
|
|
477
|
+
}
|
|
478
|
+
},
|
|
479
|
+
});
|
|
480
|
+
} finally {
|
|
481
|
+
await end?.();
|
|
482
|
+
}
|
|
483
|
+
};
|
|
484
|
+
|
|
289
485
|
let result: any;
|
|
290
486
|
let conn: any;
|
|
291
487
|
if (directUrl) {
|
|
292
488
|
try {
|
|
293
|
-
|
|
294
|
-
const { createUrlRunner } = await import('../db-source.js');
|
|
295
|
-
const { runner, end } = await createUrlRunner(directUrl);
|
|
296
|
-
try {
|
|
297
|
-
result = await runProvision({ authPassword, adminPassword }, {
|
|
298
|
-
execute: async (statement) => runner(statement),
|
|
299
|
-
connection: parseUrlConnection(directUrl),
|
|
300
|
-
// Same probe as the ops-Lambda venue: a real login as the new role, DDL-capable.
|
|
301
|
-
connectProbe: async (probeUrl) => {
|
|
302
|
-
const { default: postgres } = await import('postgres');
|
|
303
|
-
const probe = postgres(probeUrl, { max: 1, connect_timeout: 5, ssl: 'prefer' });
|
|
304
|
-
try {
|
|
305
|
-
await probe`SELECT 1`;
|
|
306
|
-
await probe.unsafe('CREATE TEMP TABLE _everystack_provision_probe (x int)');
|
|
307
|
-
await probe.unsafe('DROP TABLE _everystack_provision_probe');
|
|
308
|
-
} finally {
|
|
309
|
-
await probe.end({ timeout: 1 });
|
|
310
|
-
}
|
|
311
|
-
},
|
|
312
|
-
});
|
|
313
|
-
} finally {
|
|
314
|
-
await end?.();
|
|
315
|
-
}
|
|
489
|
+
result = await provisionDirect(directUrl);
|
|
316
490
|
} catch (err: any) {
|
|
317
491
|
fail(`db:provision failed: ${err.message}`);
|
|
318
492
|
process.exit(1);
|
|
@@ -322,23 +496,34 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
322
496
|
try {
|
|
323
497
|
result = await invokeAction(config.region, opsFunction(config), 'db:provision', { authPassword, adminPassword });
|
|
324
498
|
} catch (err: any) {
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
499
|
+
// Auto-fallback: no Ops Lambda (Unknown action), but an ADMIN_DATABASE_URL secret exists —
|
|
500
|
+
// provision directly over it. Master never crosses the CLI; the operator sets nothing new.
|
|
501
|
+
const secretUrl = stageSecrets.ADMIN_DATABASE_URL ?? stageSecrets.AdminDatabaseUrl;
|
|
502
|
+
if (/Unknown action/i.test(String(err.message)) && secretUrl) {
|
|
503
|
+
info('No Ops Lambda (the deployed handler has no dbPlugin) — provisioning directly over the ADMIN_DATABASE_URL secret.');
|
|
504
|
+
try {
|
|
505
|
+
result = await provisionDirect(secretUrl);
|
|
506
|
+
directUrl = secretUrl;
|
|
507
|
+
conn = parseUrlConnection(secretUrl);
|
|
508
|
+
} catch (err2: any) {
|
|
509
|
+
fail(`db:provision failed: ${err2.message}`);
|
|
510
|
+
process.exit(1);
|
|
511
|
+
}
|
|
512
|
+
} else {
|
|
513
|
+
fail(`db:provision failed: ${err.message}`);
|
|
514
|
+
if (/Unknown action/i.test(String(err.message))) {
|
|
515
|
+
info('The deployed handler has no dbPlugin (no Ops Lambda), and no ADMIN_DATABASE_URL secret is set.');
|
|
516
|
+
info('Provision directly: everystack db:provision --stage <stage> --database-url <master-url> (once), or set the ADMIN_DATABASE_URL secret and use --direct.');
|
|
517
|
+
}
|
|
518
|
+
for (const line of opsAdviceLines(err, [IAM_ADVICE])) info(line);
|
|
519
|
+
process.exit(1);
|
|
329
520
|
}
|
|
330
|
-
for (const line of opsAdviceLines(err, [IAM_ADVICE])) info(line);
|
|
331
|
-
process.exit(1);
|
|
332
521
|
}
|
|
333
522
|
}
|
|
334
523
|
if (result?.error) {
|
|
335
524
|
// Includes the admin-verification refusal: the action set NO authenticator password,
|
|
336
525
|
// so nothing here rewrites DATABASE_URL — the working credentials are untouched.
|
|
337
526
|
fail(`db:provision failed: ${result.error}`);
|
|
338
|
-
if (!directUrl && /Unknown action/i.test(String(result.error))) {
|
|
339
|
-
info('The deployed handler has no dbPlugin (no Ops Lambda). For a bring-your-own database,');
|
|
340
|
-
info('run the DIRECT venue instead: everystack db:provision --stage <stage> --database-url <master-url>');
|
|
341
|
-
}
|
|
342
527
|
process.exit(1);
|
|
343
528
|
}
|
|
344
529
|
success(`Role chain ready: ${result.loginRole} (LOGIN, NOINHERIT, no BYPASSRLS) → can SET ROLE to ${result.appRoles.join(', ')}`);
|
|
@@ -360,24 +545,57 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
360
545
|
info('Provide a db:psql connectionInfo callback, or set the DATABASE_URL secret yourself.');
|
|
361
546
|
process.exit(1);
|
|
362
547
|
}
|
|
363
|
-
|
|
548
|
+
// Echo the NON-SECRET target so the operator can confirm which database the secrets landed on,
|
|
549
|
+
// WITHOUT psql'ing the master in to check the prompt (a consumer burned time on a phantom
|
|
550
|
+
// wrong-database scare, and that check meant re-handling the master credential).
|
|
551
|
+
success(`Target database: ${conn.database} @ ${conn.host}:${conn.port ?? 5432} — secrets for stage "${flags.stage}" will point here.`);
|
|
552
|
+
|
|
553
|
+
// Bake the app's declared schemas into the minted URLs, so a multi-schema API connects with
|
|
554
|
+
// the right search_path instead of going dark on bare refs (best-effort: a modelless app or
|
|
555
|
+
// an all-public one changes nothing).
|
|
556
|
+
let declaredSchemas: string[] = [];
|
|
557
|
+
try {
|
|
558
|
+
const { resolveModelsPath } = await import('../models-path.js');
|
|
559
|
+
const { loadModels } = await import('./db-generate.js');
|
|
560
|
+
const models = await loadModels(resolveModelsPath(flags.models));
|
|
561
|
+
// Union the DERIVED-object schemas (matviews/views live in schemas the barrel never
|
|
562
|
+
// names) so the authenticator flip doesn't go dark on bare-ref matview reads.
|
|
563
|
+
let derivedObjects: Array<{ schema: string }> = [];
|
|
564
|
+
try {
|
|
565
|
+
const { loadDeclaredDerived } = await import('../declared-derived.js');
|
|
566
|
+
const declared = await loadDeclaredDerived(flags.models);
|
|
567
|
+
if (declared) derivedObjects = declared.objects;
|
|
568
|
+
} catch {
|
|
569
|
+
// No derived layer resolvable — the table schemas still bake correctly.
|
|
570
|
+
}
|
|
571
|
+
declaredSchemas = collectDeclaredSchemas({ models, derivedObjects });
|
|
572
|
+
} catch {
|
|
573
|
+
// No models resolvable here — leave the URLs schema-agnostic.
|
|
574
|
+
}
|
|
575
|
+
const searchPath = declaredSearchPath(declaredSchemas);
|
|
576
|
+
if (searchPath.length) info(` search_path baked into DATABASE_URL: ${searchPath.join(', ')}`);
|
|
577
|
+
|
|
578
|
+
const authUrl = withSearchPath(
|
|
579
|
+
`postgresql://${result.loginRole}:${authPassword}@${conn.host}:${conn.port ?? 5432}/${conn.database}`,
|
|
580
|
+
declaredSchemas,
|
|
581
|
+
);
|
|
364
582
|
const adminUrl = result.adminRole
|
|
365
|
-
?
|
|
583
|
+
? withSearchPath(
|
|
584
|
+
`postgresql://${result.adminRole}:${adminPassword}@${conn.host}:${conn.port ?? 5432}/${conn.database}`,
|
|
585
|
+
declaredSchemas,
|
|
586
|
+
)
|
|
366
587
|
: undefined;
|
|
367
588
|
|
|
368
589
|
// Write the secrets directly to the SST secret store — never display them. One blob
|
|
369
590
|
// write updates every key together, so there is no window where the API side is the
|
|
370
|
-
// authenticator but the operator side is still unset.
|
|
591
|
+
// authenticator but the operator side is still unset. Reuse the secrets loaded up front.
|
|
371
592
|
step('Writing the database secrets (not displayed)...');
|
|
372
593
|
let plan: ProvisionSecretPlan;
|
|
373
594
|
try {
|
|
374
|
-
const {
|
|
375
|
-
const { parseAppName } = await import('../discover.js');
|
|
376
|
-
const ctx = { appName: await parseAppName(), stage: flags.stage, region: config.region };
|
|
377
|
-
const secrets = await loadSstSecrets(ctx);
|
|
595
|
+
const secrets = { ...stageSecrets };
|
|
378
596
|
plan = buildProvisionSecretPlan({ result, authUrl, adminUrl, existing: secrets });
|
|
379
597
|
Object.assign(secrets, plan.updates);
|
|
380
|
-
await putSstSecrets(
|
|
598
|
+
await putSstSecrets(secretsCtx, secrets);
|
|
381
599
|
} catch (err: any) {
|
|
382
600
|
fail(`Failed to write the database secrets: ${err.message}`);
|
|
383
601
|
info('Ensure your IAM role can write the SST secret store (s3:PutObject, kms:Encrypt).');
|
|
@@ -405,7 +623,78 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
405
623
|
info(`Verify: everystack db:doctor --stage ${flags.stage}`);
|
|
406
624
|
}
|
|
407
625
|
|
|
626
|
+
/** Render a doctor report — identical output whichever venue produced it. */
|
|
627
|
+
function printDoctorReport(report: any): void {
|
|
628
|
+
console.log('');
|
|
629
|
+
info(`api connection (DATABASE_URL): role=${report.api.role} superuser=${report.api.isSuperuser} bypassrls=${report.api.bypassRls}`);
|
|
630
|
+
info(`operator connection (ADMIN_URL): role=${report.admin.role} superuser=${report.admin.isSuperuser} bypassrls=${report.admin.bypassRls}`);
|
|
631
|
+
console.log('');
|
|
632
|
+
|
|
633
|
+
for (const c of report.checks as Array<{ name: string; status: string; detail: string; remediation?: string }>) {
|
|
634
|
+
if (c.status === 'pass') success(`${c.name} — ${c.detail}`);
|
|
635
|
+
else if (c.status === 'warn') warn(`${c.name} — ${c.detail}`);
|
|
636
|
+
else fail(`${c.name} — ${c.detail}`);
|
|
637
|
+
if (c.status === 'fail' && c.remediation) info(` fix: ${c.remediation}`);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
console.log('');
|
|
641
|
+
if (report.ok) {
|
|
642
|
+
success('db:doctor — database is least-privilege and RLS-subject');
|
|
643
|
+
} else {
|
|
644
|
+
fail('db:doctor — the api connection is NOT correctly least-privilege (see failures above)');
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* The DIRECT venue: connect to both URLs from this process and run the same probe the ops
|
|
650
|
+
* Lambda would, then render the report. Two postgres.js connections — the api credential
|
|
651
|
+
* under scrutiny and the operator credential carrying the catalog introspection. The
|
|
652
|
+
* backups lens is absent here (no pg_dump layer locally), and the app's dbPlugin
|
|
653
|
+
* forceRlsCarveouts/functionSchemas are unknown to the CLI, so the functions teeth run at
|
|
654
|
+
* their safe default (unpinned SECDEF warns rather than fails). Returns report.ok.
|
|
655
|
+
*/
|
|
656
|
+
async function runDoctorDirect(apiUrl: string, adminUrl: string, sourceLabel: string): Promise<boolean> {
|
|
657
|
+
const { runDoctorProbe } = await loadServerDoctor();
|
|
658
|
+
const { createUrlRunner } = await import('../db-source.js');
|
|
659
|
+
step('Probing database connections (api + operator)...');
|
|
660
|
+
const apiConn = await createUrlRunner(apiUrl);
|
|
661
|
+
const opsConn = await createUrlRunner(adminUrl);
|
|
662
|
+
try {
|
|
663
|
+
const report = await runDoctorProbe(
|
|
664
|
+
{ api: apiConn.runner, ops: opsConn.runner },
|
|
665
|
+
{ apiSourceLabel: sourceLabel },
|
|
666
|
+
);
|
|
667
|
+
printDoctorReport(report);
|
|
668
|
+
return report.ok;
|
|
669
|
+
} finally {
|
|
670
|
+
await apiConn.end().catch(() => {});
|
|
671
|
+
await opsConn.end().catch(() => {});
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
408
675
|
export async function dbDoctorCommand(flags: Record<string, string>): Promise<void> {
|
|
676
|
+
// Explicit --database-url / --admin-database-url is a fully LOCAL venue — no AWS, no
|
|
677
|
+
// stage, no secrets. Resolve and run before touching config (mirrors db:apply's
|
|
678
|
+
// local-dev --database-url path).
|
|
679
|
+
if (flags['database-url'] || flags['admin-database-url']) {
|
|
680
|
+
let venue: { apiUrl: string; adminUrl: string; source: 'flag' | 'secret' } | null;
|
|
681
|
+
try {
|
|
682
|
+
venue = resolveDoctorVenue({ flagApiUrl: flags['database-url'], flagAdminUrl: flags['admin-database-url'], secrets: {} });
|
|
683
|
+
} catch (err: any) {
|
|
684
|
+
fail(err.message);
|
|
685
|
+
process.exit(1);
|
|
686
|
+
}
|
|
687
|
+
info('venue: direct connection (explicit --database-url)');
|
|
688
|
+
let ok = false;
|
|
689
|
+
try {
|
|
690
|
+
ok = await runDoctorDirect(venue!.apiUrl, venue!.adminUrl, '--database-url');
|
|
691
|
+
} catch (err: any) {
|
|
692
|
+
fail(`db:doctor failed: ${err.message}`);
|
|
693
|
+
process.exit(1);
|
|
694
|
+
}
|
|
695
|
+
process.exit(ok ? 0 : 1);
|
|
696
|
+
}
|
|
697
|
+
|
|
409
698
|
step('Resolving deployed config...');
|
|
410
699
|
let config;
|
|
411
700
|
try {
|
|
@@ -415,43 +704,84 @@ export async function dbDoctorCommand(flags: Record<string, string>): Promise<vo
|
|
|
415
704
|
process.exit(1);
|
|
416
705
|
}
|
|
417
706
|
|
|
707
|
+
// Load the stage secrets once — the direct venue (--direct, and the no-Ops auto-fallback)
|
|
708
|
+
// reads DATABASE_URL + ADMIN_DATABASE_URL from here, never from argv.
|
|
709
|
+
const { loadSstSecrets } = await import('../utils/secrets.js');
|
|
710
|
+
const { parseAppName } = await import('../discover.js');
|
|
711
|
+
let stageSecrets: Record<string, string> = {};
|
|
712
|
+
try {
|
|
713
|
+
stageSecrets = await loadSstSecrets({ appName: await parseAppName(), stage: flags.stage, region: config.region });
|
|
714
|
+
} catch {
|
|
715
|
+
// Secrets unreadable — --direct will error clearly; the ops venue below still works.
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// --direct: read both connections from the stage secrets and run locally.
|
|
719
|
+
if (flags.direct) {
|
|
720
|
+
let venue: { apiUrl: string; adminUrl: string; source: 'flag' | 'secret' } | null;
|
|
721
|
+
try {
|
|
722
|
+
venue = resolveDoctorVenue({ direct: true, secrets: stageSecrets });
|
|
723
|
+
} catch (err: any) {
|
|
724
|
+
fail(err.message);
|
|
725
|
+
process.exit(1);
|
|
726
|
+
}
|
|
727
|
+
info(`Region: ${config.region}, venue: direct connection (stage secrets)`);
|
|
728
|
+
let ok = false;
|
|
729
|
+
try {
|
|
730
|
+
ok = await runDoctorDirect(venue!.apiUrl, venue!.adminUrl, 'DATABASE_URL secret');
|
|
731
|
+
} catch (err: any) {
|
|
732
|
+
fail(`db:doctor failed: ${err.message}`);
|
|
733
|
+
process.exit(1);
|
|
734
|
+
}
|
|
735
|
+
process.exit(ok ? 0 : 1);
|
|
736
|
+
}
|
|
737
|
+
|
|
418
738
|
info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
|
|
419
739
|
step('Probing database connections (api + operator)...');
|
|
420
740
|
|
|
741
|
+
// A bring-your-own-DB app has no Ops Lambda, so db:doctor's action can't be dispatched.
|
|
742
|
+
// If the stage secrets carry both URLs, run the direct venue automatically (the no-Ops
|
|
743
|
+
// on-ramp — mirrors db:provision's auto-fallback); otherwise name the cause and the manual path.
|
|
744
|
+
const noOpsFallback = async (): Promise<never> => {
|
|
745
|
+
let venue: { apiUrl: string; adminUrl: string; source: 'flag' | 'secret' } | null = null;
|
|
746
|
+
try {
|
|
747
|
+
venue = resolveDoctorVenue({ direct: true, secrets: stageSecrets });
|
|
748
|
+
} catch {
|
|
749
|
+
venue = null;
|
|
750
|
+
}
|
|
751
|
+
if (venue) {
|
|
752
|
+
info('No Ops Lambda (the deployed handler has no dbPlugin) — verifying directly over the stage secrets.');
|
|
753
|
+
let ok = false;
|
|
754
|
+
try {
|
|
755
|
+
ok = await runDoctorDirect(venue.apiUrl, venue.adminUrl, 'DATABASE_URL secret');
|
|
756
|
+
} catch (err: any) {
|
|
757
|
+
fail(`db:doctor failed: ${err.message}`);
|
|
758
|
+
process.exit(1);
|
|
759
|
+
}
|
|
760
|
+
process.exit(ok ? 0 : 1);
|
|
761
|
+
}
|
|
762
|
+
info('This deployed handler has no dbPlugin (no Ops Lambda), so db:doctor has nothing to dispatch to.');
|
|
763
|
+
info('Set the stage\'s DATABASE_URL + ADMIN_DATABASE_URL secrets and re-run with --direct, or pass --database-url <api> [--admin-database-url <ops>].');
|
|
764
|
+
process.exit(1);
|
|
765
|
+
};
|
|
766
|
+
|
|
421
767
|
let report: any;
|
|
422
768
|
try {
|
|
423
769
|
report = await invokeAction(config.region, opsFunction(config), 'db:doctor', {});
|
|
424
770
|
} catch (err: any) {
|
|
771
|
+
if (/Unknown action/i.test(String(err.message))) return void (await noOpsFallback());
|
|
425
772
|
fail(`db:doctor failed: ${err.message}`);
|
|
426
773
|
for (const line of opsAdviceLines(err, [IAM_ADVICE])) info(line);
|
|
427
774
|
process.exit(1);
|
|
428
775
|
}
|
|
429
776
|
|
|
430
777
|
if (report?.error) {
|
|
778
|
+
if (/Unknown action/i.test(String(report.error))) return void (await noOpsFallback());
|
|
431
779
|
fail(`db:doctor failed: ${report.error}`);
|
|
432
780
|
process.exit(1);
|
|
433
781
|
}
|
|
434
782
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
info(`operator connection (ADMIN_URL): role=${report.admin.role} superuser=${report.admin.isSuperuser} bypassrls=${report.admin.bypassRls}`);
|
|
438
|
-
console.log('');
|
|
439
|
-
|
|
440
|
-
for (const c of report.checks as Array<{ name: string; status: string; detail: string; remediation?: string }>) {
|
|
441
|
-
if (c.status === 'pass') success(`${c.name} — ${c.detail}`);
|
|
442
|
-
else if (c.status === 'warn') warn(`${c.name} — ${c.detail}`);
|
|
443
|
-
else fail(`${c.name} — ${c.detail}`);
|
|
444
|
-
if (c.status === 'fail' && c.remediation) info(` fix: ${c.remediation}`);
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
console.log('');
|
|
448
|
-
if (report.ok) {
|
|
449
|
-
success('db:doctor — database is least-privilege and RLS-subject');
|
|
450
|
-
process.exit(0);
|
|
451
|
-
} else {
|
|
452
|
-
fail('db:doctor — the api connection is NOT correctly least-privilege (see failures above)');
|
|
453
|
-
process.exit(1);
|
|
454
|
-
}
|
|
783
|
+
printDoctorReport(report);
|
|
784
|
+
process.exit(report.ok ? 0 : 1);
|
|
455
785
|
}
|
|
456
786
|
|
|
457
787
|
export async function dbPsqlCommand(flags: Record<string, string>): Promise<void> {
|
package/src/cli/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { dbAuthzPullCommand, dbAuthzDiffCommand, dbAuthzTestCommand, dbAuthzOwne
|
|
|
12
12
|
import { dbGenerateCommand } from './commands/db-generate.js';
|
|
13
13
|
import { dbPullCommand } from './commands/db-pull.js';
|
|
14
14
|
import { dbReconcileCommand } from './commands/db-reconcile.js';
|
|
15
|
+
import { dbRefreshCommand } from './commands/db-refresh.js';
|
|
15
16
|
import { dbFingerprintCommand } from './commands/db-fingerprint.js';
|
|
16
17
|
import { dbSyncCommand } from './commands/db-sync.js';
|
|
17
18
|
import { dbDiffCommand } from './commands/db-diff.js';
|
|
@@ -203,6 +204,9 @@ async function main() {
|
|
|
203
204
|
case 'db:reconcile':
|
|
204
205
|
await dbReconcileCommand(flags);
|
|
205
206
|
break;
|
|
207
|
+
case 'db:refresh':
|
|
208
|
+
await dbRefreshCommand(flags);
|
|
209
|
+
break;
|
|
206
210
|
case 'db:fingerprint':
|
|
207
211
|
await dbFingerprintCommand(flags);
|
|
208
212
|
break;
|
|
@@ -352,8 +356,8 @@ Usage:
|
|
|
352
356
|
everystack db:reset [--stage <name>] Drop all schemas + re-run migrations (dev only)
|
|
353
357
|
everystack db:psql --stage <name> Interactive ADMIN psql (IAM-gated; resolves the admin URL in-process)
|
|
354
358
|
everystack db:psql [--stage <name>] -c <command> Run one query via Lambda (works for private RDS)
|
|
355
|
-
everystack db:doctor [--stage <name>] Check the DB is least-privilege + RLS-subject (api vs operator connection)
|
|
356
|
-
everystack db:provision --stage <name> [--database-url <url>] Create the least-privilege role chain on an EXISTING database (idempotent; creates no DB).
|
|
359
|
+
everystack db:doctor [--stage <name>] [--direct | --database-url <api> [--admin-database-url <ops>]] Check the DB is least-privilege + RLS-subject (api vs operator connection). No flag = ops-Lambda venue (auto-falls to direct via the stage secrets if the handler has no dbPlugin). --direct = probe both connections from the stage's DATABASE_URL + ADMIN_DATABASE_URL secrets; --database-url = explicit local venue (a lone URL is probed as both)
|
|
360
|
+
everystack db:provision --stage <name> [--direct | --database-url <url>] Create the least-privilege role chain on an EXISTING database (idempotent; creates no DB). No flag = ops-Lambda venue (auto-falls to direct via the ADMIN_DATABASE_URL secret if the handler has no dbPlugin). --direct = direct connection reading that secret (master never on argv); --database-url = explicit URL. Declared schemas are baked into the minted DATABASE_URL as search_path
|
|
357
361
|
everystack db:snapshot [--stage <name>] [--instance <id>] Take a physical RDS snapshot (instant DR point; RDS only — use db:backup for portable logical backups)
|
|
358
362
|
everystack db:snapshots [--stage <name>] [--instance <id>] List manual RDS snapshots for the instance
|
|
359
363
|
everystack db:backup:probe [--stage <name>] Verify the pg_dump layer is attached + version-compatible with the server
|
|
@@ -367,7 +371,8 @@ Usage:
|
|
|
367
371
|
everystack db:pull [--stage <name> | --database-url <url>] [--schema public] [--out <dir | file.ts>] [--derived-out <file.ts>] [--abilities public-read] Introspect the live DB → render field() Models (the brownfield on-ramp). --out <dir> writes one file per model + index.ts (the default shape); --out <file.ts> writes a single module; stdout otherwise. --derived-out <file.ts> extracts the derived layer (descriptors + sequences) as its own self-contained module — alone it leaves the models untouched (the hand-maintained-barrel splice); with --out the models render omits the now-external derived layer. Every model scaffolds its authz decision as comments (db:check fails until authored); --abilities public-read stamps the common stanza (public read, admin write) uncommented — explicit generated code, never a runtime default. --matviews-as-tables renders every matview as defineMaterializedTable with INTROSPECTED fields (the canonical-sync flip: a pipeline-owned table everystack migrates but never refreshes) — names land in an exported materializedTables array to spread into your models; fields come back nullable/unkeyed (matviews carry no PK/NOT NULL) — tighten on review; add --suggest-keys to probe the LIVE rows for functionally-unique columns (one scan per matview) and surface each as a commented .primaryKey() suggestion. docs/derived-objects.md#flipping-a-matview-to-a-materialized-table---matviews-as-tables
|
|
368
372
|
Both introspect via the deployed ops Lambda by default; --database-url (or an inherited DATABASE_URL) connects directly — for a schema that exists only on a local Postgres.
|
|
369
373
|
everystack db:fingerprint [--stage <name> | --database-url <url>] [--models <barrel>] [--json] Content-address the live base schema (tables+constraints+authz) and compare against the models — MATCH/MISMATCH (exit 1), plus the unfingerprinted-objects report
|
|
370
|
-
everystack db:reconcile [--stage <name> | --database-url <url>] [--apply] [--check] [--baseline] [--rebuild] [--overwrite-drift] [--json] Reconcile the derived layer (functions/views/matviews/triggers) against the DECLARED descriptors (defineView/defineMaterializedView/defineFunction/defineSql/trigger() on models, from the barrel) — the single home (db/sql is retired; leftover .sql files fail with the migration path): plan with rebuild-cost estimates by default; --check is the CI gate; --apply executes (
|
|
374
|
+
everystack db:reconcile [--stage <name> | --database-url <url>] [--apply] [--check] [--baseline] [--rebuild] [--overwrite-drift] [--json] Reconcile the derived layer (functions/views/matviews/triggers) against the DECLARED descriptors (defineView/defineMaterializedView/defineFunction/defineSql/trigger() on models, from the barrel) — the single home (db/sql is retired; leftover .sql files fail with the migration path): plan with rebuild-cost estimates by default; --check is the CI gate; --apply executes (atomic — DDL + provenance in one transaction) and records provenance + schema_log; --apply --stage runs credential-free in the ops Lambda (no admin URL on the deployer, the db:apply twin), --apply --database-url runs direct. Hand-edits are drift (never overwritten silently). First contact with existing objects: --baseline TRUSTS live == source (records provenance, verifies nothing), --rebuild GUARANTEES it (drop+create from source). They are mutually exclusive.
|
|
375
|
+
everystack db:refresh [--stage <name> | --database-url <url> | --direct] [--only a,b] [--list] Refresh the declared materialized views in dependency order, credential-free. --stage runs the whole refresh in the ops Lambda on the operator connection (no URL on the operator's machine — the data-lane twin of the reconcile lane); --database-url/--direct refresh over a direct connection (dev); --only refreshes a named subset (full identity or bare name); --list previews the order without connecting. Plain REFRESH (ACCESS EXCLUSIVE); fail-fast, idempotent to re-run.
|
|
371
376
|
everystack db:sync [--database-url <url>] [--models <barrel>] [--schema-out <file.ts>] [--allow-drops] [--overwrite-drift] [--baseline] [--json] Make the database match your checkout — one verb, both layers: apply the state diff (tables+authz, one transaction, verified by re-diff), reconcile the derived layer against the declared descriptors, report the resulting fingerprint vs the models' declared one. Dev databases only (direct connection required); DROPs held back unless --allow-drops; derived drift refuses unless --overwrite-drift; exit 1 when not converged
|
|
372
377
|
everystack db:diff --from-models <barrel> [--to-models db/models/index.ts] [--allow-drops] [--check] [--json] The state edge between two declared states, NO database: the SQL db:generate would produce, computed purely — CI plan previews (--check exits 1 on a non-empty edge) and computed rollbacks (swap the flags)
|
|
373
378
|
everystack db:plan [--stage <name> | --database-url <url>] [--models <barrel>] [--allow-drops] [--out db.plan.json | --out -] Mint a verified edge against a target: asks the TARGET its fingerprint, diffs the models, writes ONE reviewable plan (edge + both endpoint fingerprints). Held drops refuse the mint (--allow-drops carries destruction explicitly). Read-only — works via the ops Lambda; plans are ephemeral, never committed
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The matview-refresh core — the credential-free stage DATA lane's refresh half.
|
|
3
|
+
*
|
|
4
|
+
* `db:reconcile` deploys the derived-layer DEFINITION credential-free (the ops lane), but it
|
|
5
|
+
* no-ops when a matview's def is unchanged — a pure DATA-changed refresh has no credential-free
|
|
6
|
+
* path. This is that verb: refresh the declared matviews in dependency order, over whatever
|
|
7
|
+
* runner the venue supplies. The ops `db:refresh` action loads executeRefresh here and runs it
|
|
8
|
+
* on the operator connection, so `db:refresh --stage` needs no raw URL on the operator's machine
|
|
9
|
+
* (GridironDB 2026-07-19, the data-lane twin of the reconcile lane).
|
|
10
|
+
*
|
|
11
|
+
* Plain `REFRESH MATERIALIZED VIEW` (ACCESS EXCLUSIVE) — the faithful twin of the direct-connect
|
|
12
|
+
* data script it replaces. CONCURRENTLY (needs a unique index; doesn't block reads) is a
|
|
13
|
+
* deliberate follow-on, not a silent default.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { quoteQualified } from './pg-ident.js';
|
|
17
|
+
import type { SourceObject } from './derived-source.js';
|
|
18
|
+
|
|
19
|
+
export type RefreshRunner = (sql: string) => Promise<any[]>;
|
|
20
|
+
|
|
21
|
+
export interface RefreshRun {
|
|
22
|
+
/** Identities refreshed, in the order issued. */
|
|
23
|
+
refreshed: string[];
|
|
24
|
+
/** The REFRESH statements that ran (one per refreshed matview). */
|
|
25
|
+
statements: string[];
|
|
26
|
+
/** Fail-fast: the matview whose refresh threw, stopping the run. Earlier ones already committed. */
|
|
27
|
+
failed?: { identity: string; error: string };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The declared matview identities in dependency order — the refresh order (a dependent matview
|
|
32
|
+
* reads its parents' freshly-committed rows, so parents refresh first). `loadDeclaredDerived`
|
|
33
|
+
* already returns objects in dependency order. `only` filters by full identity (`schema.name`)
|
|
34
|
+
* or bare name.
|
|
35
|
+
*/
|
|
36
|
+
export function matviewIdentities(objects: SourceObject[], only?: string[]): string[] {
|
|
37
|
+
const ids = objects.filter((o) => o.kind === 'materialized view').map((o) => o.identity);
|
|
38
|
+
if (!only?.length) return ids;
|
|
39
|
+
const want = new Set(only);
|
|
40
|
+
return ids.filter((id) => want.has(id) || want.has(id.split('.').pop()!));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Refresh the given matview identities in the order supplied. Fail-fast: the first error stops
|
|
45
|
+
* the run and is reported (earlier refreshes have already committed — each REFRESH is its own
|
|
46
|
+
* statement, no wrapping transaction, so CONCURRENTLY stays possible later). An empty list is a
|
|
47
|
+
* clean no-op.
|
|
48
|
+
*/
|
|
49
|
+
export async function executeRefresh(runner: RefreshRunner, opts: { matviews: string[] }): Promise<RefreshRun> {
|
|
50
|
+
const refreshed: string[] = [];
|
|
51
|
+
const statements: string[] = [];
|
|
52
|
+
for (const identity of opts.matviews) {
|
|
53
|
+
const stmt = `REFRESH MATERIALIZED VIEW ${quoteQualified(identity)}`;
|
|
54
|
+
try {
|
|
55
|
+
await runner(stmt);
|
|
56
|
+
statements.push(stmt);
|
|
57
|
+
refreshed.push(identity);
|
|
58
|
+
} catch (err: any) {
|
|
59
|
+
return { refreshed, statements, failed: { identity, error: err?.message || String(err) } };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return { refreshed, statements };
|
|
63
|
+
}
|
package/src/reconcile.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @everystack/cli/reconcile — the derived-layer reconcile core, for the ops-Lambda lane.
|
|
3
|
+
*
|
|
4
|
+
* db:apply runs the STATE edge credential-free in the ops Lambda (@everystack/cli/apply). This is
|
|
5
|
+
* its DERIVED-edge twin: the ops `db:reconcile` action loads executeReconcile here and runs the
|
|
6
|
+
* whole reconcile (introspect → plan → apply) on the operator connection, so `db:reconcile --apply
|
|
7
|
+
* --stage` needs no raw admin URL on the deployer's machine.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export { executeReconcile, buildReconcileReport } from './cli/commands/db-reconcile.js';
|
|
11
|
+
export type { ReconcileRun, ExecuteOptions } from './cli/commands/db-reconcile.js';
|
package/src/refresh.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @everystack/cli/refresh — the matview-refresh core, for the ops-Lambda lane.
|
|
3
|
+
*
|
|
4
|
+
* db:reconcile deploys the derived-layer DEFINITION credential-free (@everystack/cli/reconcile).
|
|
5
|
+
* This is the DATA lane's twin: the ops `db:refresh` action loads executeRefresh here and runs it
|
|
6
|
+
* on the operator connection, so `db:refresh --stage` needs no raw admin URL on the deployer's
|
|
7
|
+
* machine. The CLI ships the declared matview identities (dependency order); this refreshes them.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export { executeRefresh, matviewIdentities } from './cli/refresh-execute.js';
|
|
11
|
+
export type { RefreshRun, RefreshRunner } from './cli/refresh-execute.js';
|