@everystack/cli 0.4.24 → 0.4.27

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.24",
3
+ "version": "0.4.27",
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>",
@@ -10,7 +10,7 @@
10
10
  * (the `db:refresh` action), so a least-privileged operator never holds a URL. `--database-url`
11
11
  * / `--direct` refresh over a direct connection (dev). `--list` previews the topo-ordered
12
12
  * matviews without connecting. Plain REFRESH (ACCESS EXCLUSIVE) — CONCURRENTLY is a follow-on.
13
- * (GridironDB 2026-07-19.)
13
+ * (consumer field report 2026-07-19.)
14
14
  */
15
15
 
16
16
  import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
@@ -30,6 +30,9 @@ export async function dbRefreshCommand(flags: Record<string, string>): Promise<v
30
30
  process.exit(1);
31
31
  }
32
32
  const only = flags.only ? flags.only.split(',').map((s) => s.trim()).filter(Boolean) : undefined;
33
+ // --verify-nonempty: after refreshing, gate on populated-but-zero-rows matviews (the dark-panel
34
+ // check) — credential-free, a scoped EXISTS on only what was just refreshed.
35
+ const verifyNonempty = flags['verify-nonempty'] === 'true';
33
36
  const matviews = matviewIdentities(declared?.objects ?? [], only);
34
37
 
35
38
  if (matviews.length === 0) {
@@ -65,14 +68,14 @@ export async function dbRefreshCommand(flags: Record<string, string>): Promise<v
65
68
  step(connectingVia(dbSource));
66
69
  const conn = await createUrlRunner(dbSource.url);
67
70
  end = conn.end;
68
- step(`Refreshing ${matviews.length} materialized view(s)...`);
69
- run = await executeRefresh(conn.runner, { matviews });
71
+ step(`Refreshing ${matviews.length} materialized view(s)${verifyNonempty ? ' (verifying non-empty)' : ''}...`);
72
+ run = await executeRefresh(conn.runner, { matviews, verifyNonempty });
70
73
  } else {
71
74
  step('Resolving deployed config...');
72
75
  const config = await resolveConfig(flags.stage);
73
76
  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 });
77
+ step(`Refreshing ${matviews.length} materialized view(s) in the ops Lambda${verifyNonempty ? ' (verifying non-empty)' : ''}...`);
78
+ const result: any = await invokeAction(config.region, opsFunction(config), 'db:refresh', { matviews, verifyNonempty });
76
79
  if (result?.error) {
77
80
  fail(`db:refresh failed: ${result.error}`);
78
81
  if (/Unknown action/i.test(String(result.error))) {
@@ -80,7 +83,7 @@ export async function dbRefreshCommand(flags: Record<string, string>): Promise<v
80
83
  }
81
84
  process.exit(1);
82
85
  }
83
- run = { refreshed: result.refreshed ?? [], statements: result.statements ?? [], failed: result.failed ?? undefined };
86
+ run = { refreshed: result.refreshed ?? [], statements: result.statements ?? [], failed: result.failed ?? undefined, empty: result.empty ?? undefined };
84
87
  }
85
88
 
86
89
  for (const id of run.refreshed) success(`refreshed ${id}`);
@@ -89,7 +92,13 @@ export async function dbRefreshCommand(flags: Record<string, string>): Promise<v
89
92
  info(`${run.refreshed.length} of ${matviews.length} refreshed before the failure; fix and re-run (refresh is idempotent).`);
90
93
  process.exit(1);
91
94
  }
92
- success(`db:refresh — ${run.refreshed.length} materialized view(s) refreshed.`);
95
+ // The dark-panel gate: refreshed cleanly but came back empty (inputs not loaded). Refuse.
96
+ if (run.empty?.length) {
97
+ fail(`db:refresh --verify-nonempty: ${run.empty.length} matview(s) refreshed but are EMPTY (populated, zero rows) — a dark panel: ${run.empty.join(', ')}`);
98
+ info('These refreshed without error but serve no rows — their inputs were likely not loaded. Load the inputs and re-refresh before promoting.');
99
+ process.exit(1);
100
+ }
101
+ success(`db:refresh — ${run.refreshed.length} materialized view(s) refreshed${verifyNonempty ? ', all non-empty' : ''}.`);
93
102
  process.exit(0);
94
103
  } finally {
95
104
  if (end) await end().catch(() => {});
@@ -363,7 +363,7 @@ export function declaredSearchPath(schemas: string[]): string[] {
363
363
  * descriptors, NOT the barrel, so a table-only scan structurally misses it — and the
364
364
  * authenticator flip then goes dark on every bare-ref matview read ("relation does not
365
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.)
366
+ * (consumer field report 2026-07-19: 54 matviews in `stats_view`, absent from the table-only bake.)
367
367
  */
368
368
  export function collectDeclaredSchemas(args: {
369
369
  models: Array<{ schema?: string }>;
@@ -379,7 +379,7 @@ export function collectDeclaredSchemas(args: {
379
379
  * defaults to `"$user",public`, so every bare-ref query 404s until a hand ALTER ROLE pin
380
380
  * (recorded nowhere, repeated every stage) — writing it into the minted DATABASE_URL is
381
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.
382
+ * startup params (a consumer proved search_path this way). No-op for an all-public app.
383
383
  */
384
384
  export function withSearchPath(url: string, schemas: string[]): string {
385
385
  const path = declaredSearchPath(schemas);
@@ -389,6 +389,31 @@ export function withSearchPath(url: string, schemas: string[]): string {
389
389
  return u.toString();
390
390
  }
391
391
 
392
+ /**
393
+ * Carry the CONNECTION params (sslmode, connect_timeout, …) from the URL the app already connects
394
+ * with onto a freshly minted one. provision mints the new URL from host/port/database components,
395
+ * which silently drops any `?sslmode=require` on the source — on an `rds.force_ssl=1` instance the
396
+ * new authenticator/migrator roles then can't connect. `search_path` is provision's OWN concern
397
+ * (withSearchPath owns it), so it is never copied; existing params on the minted URL win. No-op
398
+ * when there is no source or it doesn't parse.
399
+ */
400
+ export function preserveConnParams(mintedUrl: string, sourceUrl?: string): string {
401
+ if (!sourceUrl) return mintedUrl;
402
+ let src: URL;
403
+ let out: URL;
404
+ try {
405
+ src = new URL(sourceUrl);
406
+ out = new URL(mintedUrl);
407
+ } catch {
408
+ return mintedUrl;
409
+ }
410
+ for (const [key, value] of src.searchParams) {
411
+ if (key === 'search_path') continue; // provision derives search_path from the declared schemas
412
+ if (!out.searchParams.has(key)) out.searchParams.set(key, value);
413
+ }
414
+ return out.toString();
415
+ }
416
+
392
417
  /** host/port/database from a postgres URL — the direct venue's connection info. */
393
418
  export function parseUrlConnection(url: string): { host: string; port: string; database: string } {
394
419
  const u = new URL(url);
@@ -575,16 +600,22 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
575
600
  const searchPath = declaredSearchPath(declaredSchemas);
576
601
  if (searchPath.length) info(` search_path baked into DATABASE_URL: ${searchPath.join(', ')}`);
577
602
 
578
- const authUrl = withSearchPath(
579
- `postgresql://${result.loginRole}:${authPassword}@${conn.host}:${conn.port ?? 5432}/${conn.database}`,
580
- declaredSchemas,
581
- );
582
- const adminUrl = result.adminRole
583
- ? withSearchPath(
584
- `postgresql://${result.adminRole}:${adminPassword}@${conn.host}:${conn.port ?? 5432}/${conn.database}`,
585
- declaredSchemas,
586
- )
587
- : undefined;
603
+ // Preserve the connection params (sslmode, …) from the URL the app already connects with — the
604
+ // minted URL is built from bare host/port/db components, so without this a `?sslmode=require`
605
+ // would be dropped and the new roles could fail to connect on an SSL-forced RDS. Prefer the
606
+ // venue URL provision connected through, else the existing DATABASE_URL secret (either spelling).
607
+ const sourceUrl = directUrl
608
+ ?? stageSecrets.ADMIN_DATABASE_URL ?? stageSecrets.AdminDatabaseUrl
609
+ ?? stageSecrets.DATABASE_URL ?? stageSecrets.DatabaseUrl;
610
+
611
+ const mint = (role: string, password: string): string =>
612
+ withSearchPath(
613
+ preserveConnParams(`postgresql://${role}:${password}@${conn.host}:${conn.port ?? 5432}/${conn.database}`, sourceUrl),
614
+ declaredSchemas,
615
+ );
616
+
617
+ const authUrl = mint(result.loginRole, authPassword);
618
+ const adminUrl = result.adminRole ? mint(result.adminRole, adminPassword) : undefined;
588
619
 
589
620
  // Write the secrets directly to the SST secret store — never display them. One blob
590
621
  // write updates every key together, so there is no window where the API side is the
@@ -26,6 +26,7 @@ interface DiscoveredConfig {
26
26
  updatesBucket: string;
27
27
  clientBundlesBucket: string;
28
28
  mediaBucket?: string;
29
+ backupsBucket?: string;
29
30
  kvsArn?: string;
30
31
  distributionId?: string;
31
32
  }
@@ -209,6 +210,7 @@ interface BucketsResult {
209
210
  updatesBucket?: string;
210
211
  clientBundlesBucket?: string;
211
212
  mediaBucket?: string;
213
+ backupsBucket?: string;
212
214
  }
213
215
 
214
216
  /**
@@ -231,10 +233,15 @@ export async function discoverBuckets(
231
233
  // Media bucket resource is named 'Media' (not 'MediaBucket'), so match both patterns
232
234
  const mediaNeedle = `${prefix}media-`.toLowerCase();
233
235
  const mediaBucketNeedle = `${prefix}mediabucket-`.toLowerCase();
236
+ // Backups bucket (db:backup/restore/backup:download). Discovered like every other bucket, so the
237
+ // --stage path carries it — db:backup:download presigns CLIENT-side and needs the name locally
238
+ // (db:backup/backups run server-side in the ops Lambda, which is why only download failed).
239
+ const backupsNeedle = `${prefix}backupsbucket-`.toLowerCase();
234
240
 
235
241
  let updatesBucket: string | undefined;
236
242
  let clientBundlesBucket: string | undefined;
237
243
  let mediaBucket: string | undefined;
244
+ let backupsBucket: string | undefined;
238
245
 
239
246
  for (const b of buckets) {
240
247
  if (!b.Name) continue;
@@ -242,10 +249,11 @@ export async function discoverBuckets(
242
249
  if (lower.startsWith(updatesNeedle)) updatesBucket = b.Name;
243
250
  if (lower.startsWith(clientNeedle)) clientBundlesBucket = b.Name;
244
251
  if (!mediaBucket && (lower.startsWith(mediaNeedle) || lower.startsWith(mediaBucketNeedle))) mediaBucket = b.Name;
245
- if (updatesBucket && clientBundlesBucket && mediaBucket) break;
252
+ if (lower.startsWith(backupsNeedle)) backupsBucket = b.Name;
253
+ if (updatesBucket && clientBundlesBucket && mediaBucket && backupsBucket) break;
246
254
  }
247
255
 
248
- return { updatesBucket, clientBundlesBucket, mediaBucket };
256
+ return { updatesBucket, clientBundlesBucket, mediaBucket, backupsBucket };
249
257
  }
250
258
 
251
259
  // ---------------------------------------------------------------------------
@@ -304,14 +312,16 @@ export async function discoverConfig(
304
312
  );
305
313
  }
306
314
 
307
- // Media bucket fallback: if not found via naming convention (custom/pre-existing bucket),
308
- // try reading from .sst/outputs.json where sst.config.ts return values land.
315
+ // Media/backups bucket fallback: if not found via naming convention (custom/pre-existing
316
+ // bucket), try reading from .sst/outputs.json where sst.config.ts return values land.
309
317
  let mediaBucket = bucketsResult.mediaBucket;
310
- if (!mediaBucket) {
318
+ let backupsBucket = bucketsResult.backupsBucket;
319
+ if (!mediaBucket || !backupsBucket) {
311
320
  try {
312
321
  const raw = await fs.readFile(path.resolve('.sst', 'outputs.json'), 'utf8');
313
322
  const outputs = JSON.parse(raw);
314
- if (outputs.mediaBucket) mediaBucket = outputs.mediaBucket;
323
+ if (!mediaBucket && outputs.mediaBucket) mediaBucket = outputs.mediaBucket;
324
+ if (!backupsBucket && outputs.backupsBucket) backupsBucket = outputs.backupsBucket;
315
325
  } catch {
316
326
  // outputs.json not available — non-fatal
317
327
  }
@@ -327,6 +337,7 @@ export async function discoverConfig(
327
337
  updatesBucket: bucketsResult.updatesBucket,
328
338
  clientBundlesBucket: bucketsResult.clientBundlesBucket,
329
339
  mediaBucket,
340
+ backupsBucket,
330
341
  kvsArn: cfResult.kvsArn,
331
342
  distributionId,
332
343
  };
@@ -337,7 +348,7 @@ export async function discoverConfig(
337
348
  // ---------------------------------------------------------------------------
338
349
 
339
350
  const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
340
- const CACHE_VERSION = 4; // Bump when CachedOutputs schema changes to invalidate stale caches
351
+ const CACHE_VERSION = 5; // Bump when CachedOutputs schema changes to invalidate stale caches (5: +backupsBucket)
341
352
 
342
353
  interface CachedOutputs {
343
354
  _cachedAt: number;
@@ -350,6 +361,7 @@ interface CachedOutputs {
350
361
  updatesBucket?: string;
351
362
  clientBundlesBucket?: string;
352
363
  mediaBucket?: string;
364
+ backupsBucket?: string;
353
365
  kvsArn?: string;
354
366
  distributionId?: string;
355
367
  }
@@ -377,6 +389,7 @@ export async function getCachedConfig(stage: string): Promise<DiscoveredConfig |
377
389
  updatesBucket: cached.updatesBucket,
378
390
  clientBundlesBucket: cached.clientBundlesBucket,
379
391
  mediaBucket: cached.mediaBucket,
392
+ backupsBucket: cached.backupsBucket,
380
393
  kvsArn: cached.kvsArn,
381
394
  distributionId: cached.distributionId,
382
395
  };
@@ -452,6 +465,7 @@ export async function setCachedConfig(stage: string, config: DiscoveredConfig):
452
465
  updatesBucket: config.updatesBucket,
453
466
  clientBundlesBucket: config.clientBundlesBucket,
454
467
  mediaBucket: config.mediaBucket,
468
+ backupsBucket: config.backupsBucket,
455
469
  kvsArn: config.kvsArn,
456
470
  distributionId: config.distributionId,
457
471
  };
package/src/cli/index.ts CHANGED
@@ -372,7 +372,7 @@ Usage:
372
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.
373
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
374
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.
375
+ everystack db:refresh [--stage <name> | --database-url <url> | --direct] [--only a,b] [--verify-nonempty] [--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); --verify-nonempty gates on populated-but-zero-rows matviews (the dark-panel check — a scoped EXISTS on just what was refreshed, so a promotion script gets the gate with no read authority) and FAILS naming any empty; --list previews the order without connecting. Plain REFRESH (ACCESS EXCLUSIVE); fail-fast, idempotent to re-run.
376
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
377
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)
378
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
@@ -6,7 +6,7 @@
6
6
  * path. This is that verb: refresh the declared matviews in dependency order, over whatever
7
7
  * runner the venue supplies. The ops `db:refresh` action loads executeRefresh here and runs it
8
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).
9
+ * (consumer field report 2026-07-19, the data-lane twin of the reconcile lane).
10
10
  *
11
11
  * Plain `REFRESH MATERIALIZED VIEW` (ACCESS EXCLUSIVE) — the faithful twin of the direct-connect
12
12
  * data script it replaces. CONCURRENTLY (needs a unique index; doesn't block reads) is a
@@ -25,6 +25,19 @@ export interface RefreshRun {
25
25
  statements: string[];
26
26
  /** Fail-fast: the matview whose refresh threw, stopping the run. Earlier ones already committed. */
27
27
  failed?: { identity: string; error: string };
28
+ /**
29
+ * `--verify-nonempty`: refreshed matviews that came back populated-but-ZERO-rows — the
30
+ * dark-panel trap (a matview refreshed before its inputs loaded; `ispopulated` is true, so
31
+ * it's not a refresh error, but it serves nothing). Only present when verifyNonempty was set;
32
+ * a non-empty list is a promotion-gate failure the venue turns into a refusal.
33
+ */
34
+ empty?: string[];
35
+ }
36
+
37
+ /** Coerce a Postgres boolean (true | 't' | 'true' | 1) to a JS boolean — postgres.js and
38
+ * drizzle disagree on the encoding, so accept both. */
39
+ function pgTrue(v: unknown): boolean {
40
+ return v === true || v === 't' || v === 'true' || v === 1 || v === '1';
28
41
  }
29
42
 
30
43
  /**
@@ -45,8 +58,16 @@ export function matviewIdentities(objects: SourceObject[], only?: string[]): str
45
58
  * the run and is reported (earlier refreshes have already committed — each REFRESH is its own
46
59
  * statement, no wrapping transaction, so CONCURRENTLY stays possible later). An empty list is a
47
60
  * clean no-op.
61
+ *
62
+ * `verifyNonempty` (opt-in): after a FULLY-successful refresh, probe each matview it refreshed
63
+ * with `SELECT EXISTS (SELECT 1 …)` and return the populated-but-zero-rows ones in `empty`. This
64
+ * is the credential-free dark-panel gate — a scoped read of only what was just refreshed, never
65
+ * arbitrary db:query authority. Skipped when the refresh itself failed (that failure owns the run).
48
66
  */
49
- export async function executeRefresh(runner: RefreshRunner, opts: { matviews: string[] }): Promise<RefreshRun> {
67
+ export async function executeRefresh(
68
+ runner: RefreshRunner,
69
+ opts: { matviews: string[]; verifyNonempty?: boolean },
70
+ ): Promise<RefreshRun> {
50
71
  const refreshed: string[] = [];
51
72
  const statements: string[] = [];
52
73
  for (const identity of opts.matviews) {
@@ -59,5 +80,15 @@ export async function executeRefresh(runner: RefreshRunner, opts: { matviews: st
59
80
  return { refreshed, statements, failed: { identity, error: err?.message || String(err) } };
60
81
  }
61
82
  }
83
+
84
+ if (opts.verifyNonempty && refreshed.length) {
85
+ const empty: string[] = [];
86
+ for (const identity of refreshed) {
87
+ const rows = await runner(`SELECT EXISTS (SELECT 1 FROM ${quoteQualified(identity)}) AS has_rows`);
88
+ if (!pgTrue(rows[0]?.has_rows)) empty.push(identity);
89
+ }
90
+ if (empty.length) return { refreshed, statements, empty };
91
+ }
92
+
62
93
  return { refreshed, statements };
63
94
  }