@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 +1 -1
- package/src/cli/commands/db-refresh.ts +16 -7
- package/src/cli/commands/db.ts +43 -12
- package/src/cli/discover.ts +21 -7
- package/src/cli/index.ts +1 -1
- package/src/cli/refresh-execute.ts +33 -2
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';
|
|
@@ -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
|
-
|
|
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(() => {});
|
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
|
};
|
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
|
-
* (
|
|
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(
|
|
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
|
}
|