@everystack/cli 0.3.13 → 0.3.14

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/README.md CHANGED
@@ -216,6 +216,7 @@ everystack db:psql --stage dev [-c SQL] # interactive psql / one-off query via L
216
216
  # Model-driven schema (@everystack/model)
217
217
  everystack db:generate # diff Models vs the live DB → next migration
218
218
  everystack db:pull # introspect a live DB → field() Models (brownfield on-ramp)
219
+ everystack db:reconcile # deploy the derived layer (functions/views/matviews) from db/sql — no migrations
219
220
 
220
221
  # Security
221
222
  everystack db:doctor # is the DB least-privilege + RLS-subject?
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.3.13",
3
+ "version": "0.3.14",
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>",
@@ -0,0 +1,300 @@
1
+ /**
2
+ * `everystack db:reconcile` — deploy the derived layer (functions, views,
3
+ * matviews) by reconciling the live database against the db/sql sources.
4
+ *
5
+ * db:reconcile [--sql-dir db/sql] [--stage <name> | --database-url <url>]
6
+ * [--apply] [--check] [--baseline] [--overwrite-drift] [--json]
7
+ *
8
+ * Derived objects do not migrate; they deploy. The plan is computed fresh
9
+ * every run: source objects (hashed, comment/whitespace-insensitive) joined
10
+ * against the live catalog and the reconciler's provenance claims. Default
11
+ * mode prints the plan with rebuild-cost estimates. `--check` is the CI gate
12
+ * (exit 1 when anything differs). `--apply` executes: the DDL goes as ONE
13
+ * multi-statement batch (one implicit transaction — a failed create rolls
14
+ * back its drops), then provenance is re-recorded from fresh introspection,
15
+ * and the full applied SQL is appended to `everystack.schema_log`.
16
+ *
17
+ * Hand-edited live objects are DRIFT — reported, applied over only with
18
+ * `--overwrite-drift`. Never-reconciled matches need `--baseline` once (the
19
+ * explicit trust that live == source, recorded). `--apply` needs a direct
20
+ * connection (`--database-url` / DATABASE_URL): the ops Lambda `db:query`
21
+ * path is read-only by design, so plan/check work anywhere but writes go
22
+ * through a credentialed connection you chose to hold.
23
+ */
24
+
25
+ import fs from 'node:fs/promises';
26
+ import path from 'node:path';
27
+ import { execSync } from 'node:child_process';
28
+ import type { QueryRunner } from '../authz-contract.js';
29
+ import { parseSqlSources, type SourceFile } from '../derived-source.js';
30
+ import { introspectDerived } from '../derived-introspect.js';
31
+ import { planReconcile, type ReconcilePlan, type ReconcileOptions } from '../derived-plan.js';
32
+ import {
33
+ renderReconcileSql,
34
+ renderProvenanceUpsert,
35
+ renderProvenanceDelete,
36
+ renderSchemaLogInsert,
37
+ ENSURE_RECONCILER_SQL,
38
+ } from '../derived-apply.js';
39
+ import { resolveDbSource, createUrlRunner, type DbSource } from '../db-source.js';
40
+ import { resolveConfig, opsFunction } from '../config.js';
41
+ import { invokeAction } from '../aws.js';
42
+ import { step, success, fail, info, warn } from '../output.js';
43
+
44
+ const DEFAULT_SQL_DIR = 'db/sql';
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // The testable core.
48
+ // ---------------------------------------------------------------------------
49
+
50
+ export interface ExecuteOptions extends ReconcileOptions {
51
+ apply?: boolean;
52
+ actor?: string | null;
53
+ gitRef?: string | null;
54
+ planRef?: string | null;
55
+ /** Injectable clock for tests. */
56
+ now?: () => number;
57
+ }
58
+
59
+ export interface ReconcileRun {
60
+ plan: ReconcilePlan;
61
+ /** True when DDL/bookkeeping was actually executed. */
62
+ applied: boolean;
63
+ /** The DDL batch that was (or would be) sent. */
64
+ statements: string[];
65
+ /** Why apply was refused, when it was. */
66
+ refusal?: string;
67
+ }
68
+
69
+ /**
70
+ * Plan — and under `apply`, execute — one reconcile. Pure orchestration over
71
+ * an injected QueryRunner; the CLI shell below owns flags, files, and exits.
72
+ */
73
+ export async function executeReconcile(
74
+ runner: QueryRunner,
75
+ sources: SourceFile[],
76
+ options: ExecuteOptions = {},
77
+ ): Promise<ReconcileRun> {
78
+ const live = await introspectDerived(runner);
79
+ const parsed = parseSqlSources(sources);
80
+ const plan = planReconcile(parsed, live, options);
81
+ const rendered = renderReconcileSql(plan, parsed.objects);
82
+
83
+ if (!options.apply) return { plan, applied: false, statements: rendered.statements };
84
+
85
+ if (plan.blocked.length > 0) {
86
+ return {
87
+ plan, applied: false, statements: rendered.statements,
88
+ refusal: `blocked chains: ${plan.blocked.map((b) => b.identity).join(', ')} — resolve the unmanaged dependents first`,
89
+ };
90
+ }
91
+ if (plan.drift.length > 0 && !options.overwriteDrift) {
92
+ return {
93
+ plan, applied: false, statements: rendered.statements,
94
+ refusal: `drift on ${plan.drift.map((d) => d.identity).join(', ')} — inspect, then re-run with --overwrite-drift to rebuild from source`,
95
+ };
96
+ }
97
+ if (rendered.statements.length === 0 && rendered.record.length === 0 && rendered.remove.length === 0) {
98
+ return { plan, applied: false, statements: [] };
99
+ }
100
+
101
+ const now = options.now ?? Date.now;
102
+ await runner(ENSURE_RECONCILER_SQL.join(';\n'));
103
+
104
+ const started = now();
105
+ let outcome = 'applied';
106
+ try {
107
+ if (rendered.statements.length > 0) {
108
+ await runner(rendered.statements.join(';\n'));
109
+ }
110
+ } catch (err: any) {
111
+ outcome = 'failed';
112
+ // The batch rolled back; record the attempt, then surface the failure.
113
+ try {
114
+ await runner(renderSchemaLogInsert({
115
+ kind: 'compute reconcile', sql: rendered.statements.join(';\n'),
116
+ outcome: `failed: ${String(err?.message ?? err).slice(0, 500)}`,
117
+ actor: options.actor, gitRef: options.gitRef, planRef: options.planRef,
118
+ durationMs: now() - started,
119
+ }));
120
+ } catch { /* the memoir is best-effort on failure */ }
121
+ throw err;
122
+ }
123
+
124
+ // The batch committed — re-read the catalog so provenance records the def
125
+ // hashes of what NOW exists, not what we hoped would exist.
126
+ const after = await introspectDerived(runner);
127
+ const liveById = new Map(after.objects.map((o) => [o.identity, o]));
128
+ const srcById = new Map(parsed.objects.map((o) => [o.identity, o]));
129
+
130
+ const bookkeeping: string[] = [];
131
+ for (const identity of rendered.record) {
132
+ const src = srcById.get(identity);
133
+ const liveObj = liveById.get(identity);
134
+ if (!src || !liveObj) {
135
+ throw new Error(`reconcile applied but ${identity} is not introspectable afterwards — provenance not recorded, investigate`);
136
+ }
137
+ bookkeeping.push(renderProvenanceUpsert(identity, src.hash, liveObj.defHash));
138
+ }
139
+ for (const identity of rendered.remove) bookkeeping.push(renderProvenanceDelete(identity));
140
+ bookkeeping.push(renderSchemaLogInsert({
141
+ kind: 'compute reconcile',
142
+ sql: rendered.statements.join(';\n') || '-- provenance-only (baseline/prune)',
143
+ outcome,
144
+ actor: options.actor, gitRef: options.gitRef, planRef: options.planRef,
145
+ durationMs: now() - started,
146
+ }));
147
+ await runner(bookkeeping.join(';\n'));
148
+
149
+ return { plan, applied: true, statements: rendered.statements };
150
+ }
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // Report rendering (pure).
154
+ // ---------------------------------------------------------------------------
155
+
156
+ export function formatBytes(bytes: number): string {
157
+ if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
158
+ if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
159
+ if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
160
+ return `${bytes} B`;
161
+ }
162
+
163
+ const VERB_GLYPH: Record<string, string> = {
164
+ create: '+', replace: '~', drop: '-', refresh: '↻', baseline: '◦', prune: '·',
165
+ };
166
+
167
+ export function buildReconcileReport(plan: ReconcilePlan): string[] {
168
+ const lines: string[] = [];
169
+ if (plan.skipped.length > 0) lines.push(`= ${plan.skipped.length} up to date`);
170
+ for (const a of plan.actions) {
171
+ const cost = a.bytes !== undefined ? `; ${formatBytes(a.bytes)}${a.rows ? `, ~${a.rows} rows` : ''}` : '';
172
+ lines.push(`${VERB_GLYPH[a.action] ?? '?'} ${a.action} ${a.identity} (${a.reason}${cost})`);
173
+ }
174
+ const rebuilt = plan.actions.filter((a) => (a.action === 'create' || a.action === 'refresh') && a.bytes !== undefined);
175
+ if (plan.totalRebuildBytes > 0) {
176
+ lines.push(`estimated rebuild: ${formatBytes(plan.totalRebuildBytes)} across ${rebuilt.length} matview(s)`);
177
+ }
178
+ for (const d of plan.drift) lines.push(`! drift ${d.identity} — ${d.detail}`);
179
+ if (plan.needsBaseline.length > 0) {
180
+ lines.push(`! needs baseline (${plan.needsBaseline.length}): ${plan.needsBaseline.join(', ')} — run with --baseline to record trust, once`);
181
+ }
182
+ if (plan.unmanaged.length > 0) {
183
+ lines.push(`! unmanaged (${plan.unmanaged.length}): ${plan.unmanaged.join(', ')} — not in source, never touched`);
184
+ }
185
+ for (const b of plan.blocked) lines.push(`! blocked ${b.identity} — ${b.reason}`);
186
+ for (const w of plan.warnings) lines.push(`! ${w}`);
187
+ if (lines.length === 0) lines.push('nothing to do — derived layer matches source');
188
+ return lines;
189
+ }
190
+
191
+ /** Anything that makes `--check` fail: pending work or unresolved findings. */
192
+ export function checkFails(plan: ReconcilePlan): boolean {
193
+ return plan.actions.some((a) => a.action !== 'baseline')
194
+ || plan.drift.length > 0
195
+ || plan.needsBaseline.length > 0
196
+ || plan.blocked.length > 0;
197
+ }
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // The CLI shell.
201
+ // ---------------------------------------------------------------------------
202
+
203
+ /** A QueryRunner backed by the ops Lambda `db:query` action (read-only). */
204
+ function lambdaRunner(region: string, fn: string): QueryRunner {
205
+ return async (sql: string) => {
206
+ const result: any = await invokeAction(region, fn, 'db:query', { sql });
207
+ if (result?.error) throw new Error(`Query failed: ${result.error}`);
208
+ return result?.rows ?? [];
209
+ };
210
+ }
211
+
212
+ async function readSqlDir(dir: string): Promise<SourceFile[]> {
213
+ let entries: string[];
214
+ try {
215
+ entries = await fs.readdir(dir);
216
+ } catch {
217
+ throw new Error(`No SQL source directory at ${dir} (set --sql-dir).`);
218
+ }
219
+ const files = entries.filter((f) => f.endsWith('.sql')).sort();
220
+ return Promise.all(files.map(async (file) => ({
221
+ file: path.join(dir, file),
222
+ sql: await fs.readFile(path.join(dir, file), 'utf-8'),
223
+ })));
224
+ }
225
+
226
+ function currentGitRef(): string | null {
227
+ try {
228
+ return execSync('git rev-parse HEAD', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
229
+ } catch {
230
+ return null;
231
+ }
232
+ }
233
+
234
+ export async function dbReconcileCommand(flags: Record<string, string>): Promise<void> {
235
+ const sqlDir = flags['sql-dir'] || DEFAULT_SQL_DIR;
236
+ const apply = flags.apply === 'true';
237
+ const check = flags.check === 'true';
238
+
239
+ let dbSource: DbSource;
240
+ try {
241
+ dbSource = resolveDbSource(flags);
242
+ } catch (err: any) {
243
+ fail(err.message);
244
+ process.exit(1);
245
+ }
246
+
247
+ if (apply && dbSource.kind !== 'url') {
248
+ fail('--apply needs a direct connection (--database-url or DATABASE_URL) — the ops Lambda query path is read-only by design.');
249
+ process.exit(1);
250
+ }
251
+
252
+ let sources: SourceFile[];
253
+ try {
254
+ sources = await readSqlDir(sqlDir);
255
+ } catch (err: any) {
256
+ fail(err.message);
257
+ process.exit(1);
258
+ }
259
+
260
+ let runner: QueryRunner;
261
+ let end: (() => Promise<void>) | undefined;
262
+ if (dbSource.kind === 'url') {
263
+ step(dbSource.from === 'flag' ? 'Connecting via --database-url...' : 'Connecting via DATABASE_URL...');
264
+ ({ runner, end } = await createUrlRunner(dbSource.url));
265
+ } else {
266
+ step('Resolving deployed config...');
267
+ const config = await resolveConfig(flags.stage);
268
+ runner = lambdaRunner(config.region, opsFunction(config));
269
+ }
270
+
271
+ try {
272
+ const run = await executeReconcile(runner, sources, {
273
+ apply,
274
+ baseline: flags.baseline === 'true',
275
+ overwriteDrift: flags['overwrite-drift'] === 'true',
276
+ actor: process.env.USER ?? null,
277
+ gitRef: currentGitRef(),
278
+ });
279
+
280
+ if (flags.json === 'true') {
281
+ console.log(JSON.stringify({ ...run.plan, applied: run.applied, refusal: run.refusal ?? null }, null, 2));
282
+ } else {
283
+ for (const line of buildReconcileReport(run.plan)) info(line);
284
+ if (run.refusal) {
285
+ fail(`not applied: ${run.refusal}`);
286
+ } else if (run.applied) {
287
+ success(`Applied ${run.statements.length} statement(s); provenance and schema_log recorded.`);
288
+ } else if (apply) {
289
+ success('Nothing to apply — derived layer matches source.');
290
+ } else if (run.statements.length > 0) {
291
+ warn('Plan only — re-run with --apply to execute.');
292
+ }
293
+ }
294
+
295
+ if (run.refusal) process.exit(1);
296
+ if (check && checkFails(run.plan)) process.exit(1);
297
+ } finally {
298
+ await end?.();
299
+ }
300
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * derived-apply — renders a ReconcilePlan into executable SQL.
3
+ *
4
+ * Pure: no database here. The command sends the DDL batch as ONE
5
+ * multi-statement string (postgres simple-query protocol = one implicit
6
+ * transaction, so a failed create rolls back the drops that preceded it),
7
+ * then — after the batch commits — re-introspects the touched objects and
8
+ * records provenance with the fresh live def hashes, and appends the
9
+ * schema_log row carrying the full SQL text that was applied.
10
+ *
11
+ * The bookkeeping tables live in the `everystack` schema with no grants
12
+ * beyond the owner: admin-only by construction. schema_log is a memoir —
13
+ * the reconciler writes it and never reads it.
14
+ */
15
+
16
+ import type { SourceObject } from './derived-source.js';
17
+ import type { ReconcilePlan } from './derived-plan.js';
18
+ import { quoteQualified } from './pg-ident.js';
19
+
20
+ /** SQL string literal with '' doubling (standard_conforming_strings). */
21
+ export function escapeLiteral(value: string): string {
22
+ return `'${value.replace(/'/g, "''")}'`;
23
+ }
24
+
25
+ const nullable = (value: string | null | undefined): string =>
26
+ value == null ? 'NULL' : escapeLiteral(value);
27
+
28
+ /** Bookkeeping DDL — idempotent, safe to send before every apply. */
29
+ export const ENSURE_RECONCILER_SQL: string[] = [
30
+ `CREATE SCHEMA IF NOT EXISTS everystack`,
31
+ `CREATE TABLE IF NOT EXISTS everystack.derived_provenance (
32
+ identity text PRIMARY KEY,
33
+ src_hash text NOT NULL,
34
+ def_hash text NOT NULL,
35
+ applied_at timestamptz NOT NULL DEFAULT now()
36
+ )`,
37
+ `CREATE TABLE IF NOT EXISTS everystack.schema_log (
38
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
39
+ applied_at timestamptz NOT NULL DEFAULT now(),
40
+ kind text NOT NULL,
41
+ actor text,
42
+ git_ref text,
43
+ plan_ref text,
44
+ from_fingerprint text,
45
+ to_fingerprint text,
46
+ sql text NOT NULL,
47
+ duration_ms integer,
48
+ outcome text NOT NULL
49
+ )`,
50
+ ];
51
+
52
+ export interface RenderedReconcile {
53
+ /** DDL in execution order — send as one batch (one implicit transaction). */
54
+ statements: string[];
55
+ /** Identities to (re)record in provenance after the batch commits. */
56
+ record: string[];
57
+ /** Identities whose provenance rows must be deleted (drops + prunes). */
58
+ remove: string[];
59
+ }
60
+
61
+ const DROP_KEYWORD: Record<string, string> = {
62
+ 'materialized view': 'MATERIALIZED VIEW',
63
+ view: 'VIEW',
64
+ function: 'FUNCTION',
65
+ };
66
+
67
+ /** Rewrite `CREATE FUNCTION` to `CREATE OR REPLACE FUNCTION` (replace-in-place). */
68
+ export function ensureOrReplace(sql: string): string {
69
+ return sql.replace(/^(\s*)CREATE\s+FUNCTION/i, '$1CREATE OR REPLACE FUNCTION');
70
+ }
71
+
72
+ /** An object's full creation SQL: the CREATE statement plus its attachments, in source order. */
73
+ function objectSql(obj: SourceObject): string[] {
74
+ return [obj.sql, ...obj.attachments.map((a) => a.sql)];
75
+ }
76
+
77
+ export function renderReconcileSql(plan: ReconcilePlan, source: SourceObject[]): RenderedReconcile {
78
+ const srcById = new Map(source.map((o) => [o.identity, o]));
79
+ const statements: string[] = [];
80
+ const record: string[] = [];
81
+ const remove: string[] = [];
82
+
83
+ for (const action of plan.actions) {
84
+ switch (action.action) {
85
+ case 'drop': {
86
+ statements.push(`DROP ${DROP_KEYWORD[action.kind]} IF EXISTS ${quoteQualified(action.identity)}`);
87
+ // A rebuild's drop is followed by its create; only a true removal loses provenance.
88
+ if (!plan.actions.some((a) => a.action === 'create' && a.identity === action.identity)) {
89
+ remove.push(action.identity);
90
+ }
91
+ break;
92
+ }
93
+ case 'replace': {
94
+ const obj = srcById.get(action.identity);
95
+ if (!obj) throw new Error(`replace action for ${action.identity} has no source object`);
96
+ statements.push(...objectSql(obj).map(ensureOrReplace));
97
+ record.push(action.identity);
98
+ break;
99
+ }
100
+ case 'create': {
101
+ const obj = srcById.get(action.identity);
102
+ if (!obj) throw new Error(`create action for ${action.identity} has no source object`);
103
+ statements.push(...objectSql(obj));
104
+ record.push(action.identity);
105
+ break;
106
+ }
107
+ case 'refresh': {
108
+ statements.push(`REFRESH MATERIALIZED VIEW ${quoteQualified(action.identity)}`);
109
+ break;
110
+ }
111
+ case 'baseline': {
112
+ record.push(action.identity);
113
+ break;
114
+ }
115
+ case 'prune': {
116
+ remove.push(action.identity);
117
+ break;
118
+ }
119
+ }
120
+ }
121
+
122
+ return { statements, record, remove };
123
+ }
124
+
125
+ export function renderProvenanceUpsert(identity: string, srcHash: string, defHash: string): string {
126
+ return `INSERT INTO everystack.derived_provenance (identity, src_hash, def_hash, applied_at)
127
+ VALUES (${escapeLiteral(identity)}, ${escapeLiteral(srcHash)}, ${escapeLiteral(defHash)}, now())
128
+ ON CONFLICT (identity) DO UPDATE SET src_hash = EXCLUDED.src_hash, def_hash = EXCLUDED.def_hash, applied_at = now()`;
129
+ }
130
+
131
+ export function renderProvenanceDelete(identity: string): string {
132
+ return `DELETE FROM everystack.derived_provenance WHERE identity = ${escapeLiteral(identity)}`;
133
+ }
134
+
135
+ export interface SchemaLogEntry {
136
+ kind: string;
137
+ sql: string;
138
+ outcome: string;
139
+ actor?: string | null;
140
+ gitRef?: string | null;
141
+ planRef?: string | null;
142
+ durationMs?: number;
143
+ }
144
+
145
+ export function renderSchemaLogInsert(entry: SchemaLogEntry): string {
146
+ const duration = entry.durationMs === undefined ? 'NULL' : String(Math.round(entry.durationMs));
147
+ return `INSERT INTO everystack.schema_log (kind, actor, git_ref, plan_ref, sql, duration_ms, outcome)
148
+ VALUES (${escapeLiteral(entry.kind)}, ${nullable(entry.actor)}, ${nullable(entry.gitRef)}, ${nullable(entry.planRef)}, ${escapeLiteral(entry.sql)}, ${duration}, ${escapeLiteral(entry.outcome)})`;
149
+ }