@everystack/cli 0.4.24 → 0.4.25
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 +15 -6
- package/src/cli/index.ts +1 -1
- package/src/cli/refresh-execute.ts +32 -1
package/package.json
CHANGED
|
@@ -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/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
|
|
@@ -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
|
}
|