@everystack/cli 0.4.25 → 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.25",
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';
@@ -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
  };
@@ -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