@everystack/cli 0.4.22 → 0.4.24

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.4.22",
3
+ "version": "0.4.24",
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>",
@@ -41,6 +41,10 @@
41
41
  "types": "./src/reconcile.ts",
42
42
  "default": "./src/reconcile.ts"
43
43
  },
44
+ "./refresh": {
45
+ "types": "./src/refresh.ts",
46
+ "default": "./src/refresh.ts"
47
+ },
44
48
  "./audit/source": {
45
49
  "types": "./src/cli/audit-source-api.ts",
46
50
  "default": "./src/cli/audit-source-api.ts"
@@ -411,6 +411,24 @@ export async function dbReconcileCommand(flags: Record<string, string>): Promise
411
411
  info(`${declared.objects.length} declared derived object(s) from the models barrel.`);
412
412
  }
413
413
 
414
+ // --only <a,b>: restrict the reconcile to the named identities (surgical). With --rebuild it is
415
+ // the recovery path for a self-consistent-but-wrong provenance row (a mistaken --rebaseline).
416
+ const only = flags.only ? flags.only.split(',').map((s) => s.trim()).filter(Boolean) : undefined;
417
+ // An empty/whitespace --only would silently degrade to a FULL reconcile — refuse it, since the
418
+ // operator clearly meant to scope the run.
419
+ if (flags.only !== undefined && !only?.length) {
420
+ fail('--only was given but names no identities — pass full schema.name objects (e.g. --only stats_view.my_mv), or drop the flag for a full reconcile.');
421
+ process.exit(1);
422
+ }
423
+ if (only?.length) {
424
+ const declaredIds = new Set((declared?.objects ?? []).map((o) => o.identity));
425
+ const unknown = only.filter((id) => !declaredIds.has(id));
426
+ if (unknown.length) {
427
+ warn(`--only names ${unknown.length} identity(ies) not in the declared derived layer: ${unknown.join(', ')} — a full schema.name is expected (e.g. stats_view.college_player_epa_context). They will match nothing.`);
428
+ }
429
+ info(`--only: restricting the reconcile to ${only.join(', ')}${flags.rebuild === 'true' ? ' (forced rebuild from source)' : ''}.`);
430
+ }
431
+
414
432
  const reconcileOptions = {
415
433
  apply,
416
434
  declared: declared?.objects,
@@ -419,6 +437,7 @@ export async function dbReconcileCommand(flags: Record<string, string>): Promise
419
437
  rebaseline: flags.rebaseline === 'true',
420
438
  rebuild: flags.rebuild === 'true',
421
439
  overwriteDrift: flags['overwrite-drift'] === 'true',
440
+ only,
422
441
  actor: process.env.USER ?? null,
423
442
  gitRef: currentGitRef(),
424
443
  };
@@ -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
+ }
@@ -242,6 +242,86 @@ 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
+
245
325
  /**
246
326
  * Resolve the DIRECT-venue privileged URL, keeping the master password OFF argv (shell
247
327
  * history / ps / CI logs are all leak surfaces, and the whole point of provision is to STOP
@@ -276,6 +356,24 @@ export function declaredSearchPath(schemas: string[]): string[] {
276
356
  return nonPublic.length ? [...nonPublic, 'public'] : [];
277
357
  }
278
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
+
279
377
  /**
280
378
  * Bake the declared schemas into a postgres URL as `?search_path=…`. A multi-schema app's role
281
379
  * defaults to `"$user",public`, so every bare-ref query 404s until a hand ALTER ROLE pin
@@ -460,7 +558,17 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
460
558
  const { resolveModelsPath } = await import('../models-path.js');
461
559
  const { loadModels } = await import('./db-generate.js');
462
560
  const models = await loadModels(resolveModelsPath(flags.models));
463
- declaredSchemas = models.map((m: any) => m.schema ?? 'public');
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 });
464
572
  } catch {
465
573
  // No models resolvable here — leave the URLs schema-agnostic.
466
574
  }
@@ -515,7 +623,78 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
515
623
  info(`Verify: everystack db:doctor --stage ${flags.stage}`);
516
624
  }
517
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
+
518
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
+
519
698
  step('Resolving deployed config...');
520
699
  let config;
521
700
  try {
@@ -525,52 +704,84 @@ export async function dbDoctorCommand(flags: Record<string, string>): Promise<vo
525
704
  process.exit(1);
526
705
  }
527
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
+
528
738
  info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
529
739
  step('Probing database connections (api + operator)...');
530
740
 
531
- const noOpsHint = () => {
532
- // Name the cause, not the internal dispatch: a bring-your-own-DB app has no Ops Lambda, so
533
- // db:doctor's action can't be dispatched. It has no direct venue yet — say so plainly.
534
- info('This deployed handler has no dbPlugin (no Ops Lambda), so db:doctor has nothing to run against.');
535
- info('db:doctor has no --database-url venue yet (unlike db:provision). To verify a bring-your-own database meanwhile, apply grants with `db:reconcile --apply --database-url <url>` and inspect roles/grants with psql. A direct db:doctor venue is on the way.');
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);
536
765
  };
537
766
 
538
767
  let report: any;
539
768
  try {
540
769
  report = await invokeAction(config.region, opsFunction(config), 'db:doctor', {});
541
770
  } catch (err: any) {
771
+ if (/Unknown action/i.test(String(err.message))) return void (await noOpsFallback());
542
772
  fail(`db:doctor failed: ${err.message}`);
543
- if (/Unknown action/i.test(String(err.message))) noOpsHint();
544
- else for (const line of opsAdviceLines(err, [IAM_ADVICE])) info(line);
773
+ for (const line of opsAdviceLines(err, [IAM_ADVICE])) info(line);
545
774
  process.exit(1);
546
775
  }
547
776
 
548
777
  if (report?.error) {
778
+ if (/Unknown action/i.test(String(report.error))) return void (await noOpsFallback());
549
779
  fail(`db:doctor failed: ${report.error}`);
550
- if (/Unknown action/i.test(String(report.error))) noOpsHint();
551
780
  process.exit(1);
552
781
  }
553
782
 
554
- console.log('');
555
- info(`api connection (DATABASE_URL): role=${report.api.role} superuser=${report.api.isSuperuser} bypassrls=${report.api.bypassRls}`);
556
- info(`operator connection (ADMIN_URL): role=${report.admin.role} superuser=${report.admin.isSuperuser} bypassrls=${report.admin.bypassRls}`);
557
- console.log('');
558
-
559
- for (const c of report.checks as Array<{ name: string; status: string; detail: string; remediation?: string }>) {
560
- if (c.status === 'pass') success(`${c.name} — ${c.detail}`);
561
- else if (c.status === 'warn') warn(`${c.name} — ${c.detail}`);
562
- else fail(`${c.name} — ${c.detail}`);
563
- if (c.status === 'fail' && c.remediation) info(` fix: ${c.remediation}`);
564
- }
565
-
566
- console.log('');
567
- if (report.ok) {
568
- success('db:doctor — database is least-privilege and RLS-subject');
569
- process.exit(0);
570
- } else {
571
- fail('db:doctor — the api connection is NOT correctly least-privilege (see failures above)');
572
- process.exit(1);
573
- }
783
+ printDoctorReport(report);
784
+ process.exit(report.ok ? 0 : 1);
574
785
  }
575
786
 
576
787
  export async function dbPsqlCommand(flags: Record<string, string>): Promise<void> {
@@ -126,6 +126,19 @@ export interface ReconcileOptions {
126
126
  * their provenance identity migrates with them.
127
127
  */
128
128
  renamedTables?: Record<string, string>;
129
+ /**
130
+ * Restrict the reconcile to these identities (full `schema.name`) — a surgical run that
131
+ * considers ONLY the named objects for create/replace/rebuild/drop/prune. Everything else is
132
+ * left untouched. The dependency cascade still applies: rebuilding a named relation pulls its
133
+ * live dependents in (they must rebuild with it) or blocks if one has no source.
134
+ *
135
+ * Combined with `rebuild`, this is the RECOVERY path for a self-consistent-but-WRONG
136
+ * provenance row — the state a mistaken `--rebaseline` leaves, where the source hash was
137
+ * re-recorded against a stale live def so both `srcChanged` and `liveChanged` read false and
138
+ * every normal verb no-ops. `--rebuild --only <id>` forces that object to rebuild from source
139
+ * regardless of the hash bookkeeping, then re-records honest provenance from the fresh object.
140
+ */
141
+ only?: string[];
129
142
  }
130
143
 
131
144
  const isRelation = (kind: DerivedKind): boolean => kind !== 'function';
@@ -156,6 +169,12 @@ export function planReconcile(
156
169
  // Every provenance consumer below reads the POST-MIGRATION view.
157
170
  const provRows = [...provById.values()];
158
171
 
172
+ // --only: restrict every primary decision (create/replace/rebuild/drop/prune) to these
173
+ // identities. The catalog/source/provenance maps above stay FULL so the dependency cascade
174
+ // can still pull a named relation's dependents in — the restriction is on what the operator
175
+ // TARGETS, not on what the graph is allowed to see.
176
+ const only = options.only && options.only.length ? new Set(options.only) : null;
177
+
159
178
  const skipped: string[] = [];
160
179
  const drift: DriftFinding[] = [];
161
180
  const needsBaseline: string[] = [];
@@ -184,6 +203,10 @@ export function planReconcile(
184
203
  const extraWarnings: string[] = [];
185
204
 
186
205
  for (const src of source.objects) {
206
+ // --only restricts the primary decisions to the named identities; the rest are left
207
+ // untouched (they are not skips — they were never in scope, so they get no plan entry).
208
+ if (only && !only.has(src.identity)) continue;
209
+
187
210
  const liveObj = liveById.get(src.identity);
188
211
  const prov = provById.get(src.identity);
189
212
 
@@ -193,6 +216,10 @@ export function planReconcile(
193
216
  if (src.kind === 'sql') {
194
217
  if (!prov) {
195
218
  create.set(src.identity, 'new in source (provenance-tracked kind — invisible to the catalog)');
219
+ } else if (options.rebuild && only) {
220
+ // Forced recovery: rebuild through the recorded drop even when the source hash matches.
221
+ sqlDrops.set(src.identity, { dropSql: prov.dropSql ?? src.drop ?? '', reason: 'forced rebuild from source (--rebuild --only)' });
222
+ create.set(src.identity, 'forced rebuild from source (--rebuild --only, through the recorded drop)');
196
223
  } else if (src.hash !== prov.srcHash) {
197
224
  sqlDrops.set(src.identity, { dropSql: prov.dropSql ?? src.drop ?? '', reason: 'source changed' });
198
225
  create.set(src.identity, 'source changed (rebuilt through the recorded drop)');
@@ -224,6 +251,27 @@ export function planReconcile(
224
251
  continue;
225
252
  }
226
253
 
254
+ // Forced recovery (--rebuild --only): rebuild this provenanced object from source even when
255
+ // the hashes show no diff. This is the clean exit from a self-consistent-but-WRONG provenance
256
+ // row — a mistaken --rebaseline re-records the source hash against the stale live def, so both
257
+ // srcChanged and liveChanged read false below and every normal verb no-ops. The dependency
258
+ // cascade recreates any live dependents; the post-batch introspection re-records honest
259
+ // provenance. Gated on `only` so a bare --rebuild never becomes an implicit rebuild-the-world.
260
+ if (options.rebuild && only) {
261
+ // If the live object was genuinely hand-edited (drift), record it before overwriting — the
262
+ // same audit the --overwrite-drift path keeps, so a forced rebuild never silently erases
263
+ // what it clobbered.
264
+ if (liveObj.defHash !== prov.defHash) {
265
+ drift.push({
266
+ identity: src.identity,
267
+ detail: 'live definition differs from what the reconciler applied — rebuilt from source (--rebuild --only overwrote it)',
268
+ });
269
+ }
270
+ if (isRelation(src.kind)) rebuild.set(src.identity, 'forced rebuild from source (--rebuild --only)');
271
+ else fnReplace.set(src.identity, 'forced replace from source (--rebuild --only)');
272
+ continue;
273
+ }
274
+
227
275
  const srcChanged = src.hash !== prov.srcHash;
228
276
  const liveChanged = liveObj.defHash !== prov.defHash;
229
277
 
@@ -253,12 +301,14 @@ export function planReconcile(
253
301
  }
254
302
 
255
303
  for (const liveObj of live.objects) {
304
+ if (only && !only.has(liveObj.identity)) continue;
256
305
  if (srcById.has(liveObj.identity)) continue;
257
306
  if (provById.has(liveObj.identity)) drop.set(liveObj.identity, 'removed from source');
258
307
  else unmanaged.push(liveObj.identity);
259
308
  }
260
309
 
261
310
  for (const prov of provRows) {
311
+ if (only && !only.has(prov.identity)) continue;
262
312
  if (srcById.has(prov.identity) || liveById.has(prov.identity)) continue;
263
313
  // A removed 'sql' object is invisible to the catalog — pruning would silently orphan
264
314
  // it in the database. The recorded drop_sql removes it (the C1 closure); only a
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,7 +356,7 @@ 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)
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)
356
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
@@ -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 (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.
374
+ everystack db:reconcile [--stage <name> | --database-url <url>] [--apply] [--check] [--baseline] [--rebuild] [--overwrite-drift] [--only a,b] [--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. --only <schema.name,…> restricts the run to the named objects (surgical); with --rebuild it FORCES those to rebuild from source even when the hashes show no diff — the recovery exit when a mistaken --rebaseline left a self-consistent-but-wrong provenance row (the dependency cascade rebuilds their live dependents).
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/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';