@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
|
@@ -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
|
-
* (
|
|
13
|
+
* (consumer field report 2026-07-19.)
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
|
package/src/cli/commands/db.ts
CHANGED
|
@@ -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
|
-
* (
|
|
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 (
|
|
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
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
)
|
|
582
|
-
const
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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
|
package/src/cli/discover.ts
CHANGED
|
@@ -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 (
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
* (
|
|
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
|