@everystack/cli 0.4.14 → 0.4.15

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.14",
3
+ "version": "0.4.15",
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>",
@@ -78,6 +78,10 @@ export interface StaticCheckInput {
78
78
  derived?: DerivedDescriptor[] | null;
79
79
  /** Set when the derived layer failed to COMPILE (e.g. reachability) — a hard fail. */
80
80
  derivedError?: string;
81
+ /** The models the barrel's MODULES compose (declared-derived's stream). The verbs' state
82
+ * stream reads `export const models`; when the two disagree, the state layer builds a
83
+ * different database than the modules declare — the split-brain gate below names it. */
84
+ moduleModels?: ModelDescriptor[] | null;
81
85
  }
82
86
 
83
87
  export function runStaticChecks(input: StaticCheckInput): CheckFinding[] {
@@ -106,6 +110,31 @@ export function runStaticChecks(input: StaticCheckInput): CheckFinding[] {
106
110
  });
107
111
  return findings;
108
112
  }
113
+ // The split-brain gate: `export const models` (what every verb's state stream reads)
114
+ // vs the modules' composed models (what the derived stream reads). Divergence means the
115
+ // ephemeral compose builds a database missing tables the derived layer references — the
116
+ // cryptic "relation does not exist" this names precisely. The --matviews-as-tables
117
+ // splice is the common trigger: materializedTables spread into defineModule but not
118
+ // into the exported array.
119
+ if (input.moduleModels != null) {
120
+ const identity = (m: ModelDescriptor): string => `${m.schema}.${m.table}`;
121
+ const exported = new Set(input.models.map(identity));
122
+ const composed = new Set(input.moduleModels.map(identity));
123
+ const missingFromExport = [...composed].filter((t) => !exported.has(t)).sort();
124
+ const missingFromModules = [...exported].filter((t) => !composed.has(t)).sort();
125
+ if (missingFromExport.length || missingFromModules.length) {
126
+ const parts: string[] = [];
127
+ if (missingFromExport.length) parts.push(`composed by modules but MISSING from \`export const models\`: ${missingFromExport.join(', ')}`);
128
+ if (missingFromModules.length) parts.push(`exported but composed by NO module: ${missingFromModules.join(', ')}`);
129
+ findings.push({
130
+ level: 'fail',
131
+ area: 'compose',
132
+ message: `the barrel's exported models and its modules disagree — ${parts.join('; ')}. The verbs read the EXPORT; keep one array and use it in both places (a --matviews-as-tables pull: spread materializedTables into \`export const models\`, not only defineModule).`,
133
+ });
134
+ return findings;
135
+ }
136
+ }
137
+
109
138
  try {
110
139
  compileDeclaredState(input.models);
111
140
  const statements = compileMigration(input.models, { sequences: input.sequences });
@@ -269,6 +298,7 @@ export async function dbCheckCommand(flags: Record<string, string>): Promise<voi
269
298
  const findings = runStaticChecks({
270
299
  modelsPath, models, modelsError, artifactPath, artifactSource, sqlDirRetired,
271
300
  sequences: declaredDb?.sequences, derived: declaredDb?.derived, derivedError,
301
+ moduleModels: declaredDb?.models ?? null,
272
302
  });
273
303
  for (const f of findings) {
274
304
  (f.level === 'fail' || f.level === 'warn' ? warn : info)(`${MARK[f.level]} ${f.area}: ${f.message}`);
@@ -47,6 +47,37 @@ const ok = (m: string) => console.error(` ✓ ${m}`);
47
47
  const detail = (m: string) => console.error(` ${m}`);
48
48
  const caution = (m: string) => console.error(` ! ${m}`);
49
49
 
50
+ /** Types that can't (arrays, json) or won't sensibly (jsonb) serve as a key — the probe skips them. */
51
+ const UNKEYABLE = (type: string): boolean => type.endsWith('[]') || type === 'json' || type === 'jsonb';
52
+
53
+ /**
54
+ * The `--suggest-keys` probe for one matview: one aggregate scan measuring, per keyable
55
+ * column, the total row count, the non-null count, and the distinct count. Counts come back
56
+ * `::text` so bigints survive any runner. Null when no column is probeable. Pure — the
57
+ * caller runs it and feeds the row to {@link keyCandidates}.
58
+ */
59
+ export function keyProbeSql(identity: string, columns: ColumnSchema[]): { sql: string; cols: string[] } | null {
60
+ const cols = columns.filter((c) => !UNKEYABLE(c.type)).map((c) => c.name);
61
+ if (cols.length === 0) return null;
62
+ const parts = ['count(*)::text AS n'];
63
+ cols.forEach((c, i) => {
64
+ parts.push(`count("${c}")::text AS c${i}`, `count(DISTINCT "${c}")::text AS d${i}`);
65
+ });
66
+ const [schema, name] = identity.split('.');
67
+ return { sql: `SELECT ${parts.join(', ')} FROM "${schema}"."${name}"`, cols };
68
+ }
69
+
70
+ /**
71
+ * The columns that are FUNCTIONALLY unique over the live rows: non-null count == distinct
72
+ * count == total, and the matview is non-empty (zero rows prove nothing). These render as
73
+ * COMMENTED `.primaryKey()` suggestions — a surfaced decision, never a guessed constraint.
74
+ */
75
+ export function keyCandidates(row: Record<string, unknown>, cols: string[]): string[] {
76
+ const n = Number(row.n);
77
+ if (!Number.isFinite(n) || n === 0) return [];
78
+ return cols.filter((_, i) => Number(row[`c${i}`]) === n && Number(row[`d${i}`]) === n);
79
+ }
80
+
50
81
  /** A QueryRunner backed by the ops Lambda `db:query` action (read-only). */
51
82
  function lambdaRunner(region: string, fn: string): QueryRunner {
52
83
  return async (sql: string) => {
@@ -85,10 +116,16 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
85
116
  }
86
117
 
87
118
  const matviewsAsTables = flags['matviews-as-tables'] === 'true';
119
+ const suggestKeys = flags['suggest-keys'] === 'true';
120
+ if (suggestKeys && !matviewsAsTables) {
121
+ fail('--suggest-keys is a --matviews-as-tables companion (it suggests .primaryKey() candidates for the flipped tables) — pass both.');
122
+ process.exit(1);
123
+ }
88
124
 
89
125
  let current;
90
126
  let derivedCatalog: DerivedCatalog | undefined;
91
127
  let matviewColumns: Map<string, ColumnSchema[]> | undefined;
128
+ let candidatesByIdentity: Map<string, string[]> | undefined;
92
129
  try {
93
130
  let runner: QueryRunner;
94
131
  let end: (() => Promise<void>) | undefined;
@@ -110,6 +147,19 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
110
147
  // matview columns the derived layer (definition-only) doesn't carry.
111
148
  if (matviewsAsTables) {
112
149
  matviewColumns = matviewColumnsByIdentity((await runner(MATVIEW_COLUMNS_SQL)) as ColumnRow[]);
150
+ // --suggest-keys: one aggregate scan PER MATVIEW measuring functional uniqueness —
151
+ // the natural-key evidence a matview without a unique index can't otherwise give.
152
+ if (suggestKeys && matviewColumns.size) {
153
+ note(`Probing ${matviewColumns.size} matview(s) for key candidates (one full scan each)...`);
154
+ candidatesByIdentity = new Map();
155
+ for (const [identity, cols] of matviewColumns) {
156
+ const probe = keyProbeSql(identity, cols);
157
+ if (!probe) continue;
158
+ const [row] = await runner(probe.sql);
159
+ const found = keyCandidates((row ?? {}) as Record<string, unknown>, probe.cols);
160
+ if (found.length) candidatesByIdentity.set(identity, found);
161
+ }
162
+ }
113
163
  }
114
164
  await end?.();
115
165
  } catch (err: any) {
@@ -143,6 +193,7 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
143
193
  matviewsAsTables: {
144
194
  columns: matviewColumns ?? new Map(),
145
195
  enums: new Map((current.enums ?? []).map((e) => [e.name, e.values])),
196
+ ...(candidatesByIdentity ? { keyCandidates: candidatesByIdentity } : {}),
146
197
  },
147
198
  } : {}),
148
199
  });
@@ -152,7 +203,7 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
152
203
  detail(`Derived layer: ${derived.names.length} descriptor(s) + ${derived.sequenceNames.length} sequence(s) rendered (adopt with db:reconcile --baseline after committing).`);
153
204
  }
154
205
  if (derived.materializedTableNames.length) {
155
- detail(`${derived.materializedTableNames.length} matview(s) rendered as defineMaterializedTable — these are MODELS: wire them into your module's models array (spread \`materializedTables\` from the derived file).`);
206
+ detail(`${derived.materializedTableNames.length} matview(s) rendered as defineMaterializedTable — these are MODELS: spread \`materializedTables\` into your barrel's \`export const models\` (the array the verbs read) so defineModule and the export stay ONE array. db:check fails on any divergence.`);
156
207
  caution('Matviews carry no PK/NOT NULL — the rendered fields are nullable and unkeyed; tighten (.primaryKey()/.notNull()) as you review. Unique matview indexes render as index().unique() constraints.');
157
208
  }
158
209
 
@@ -11,7 +11,7 @@
11
11
  import path from 'node:path';
12
12
  import fs from 'node:fs/promises';
13
13
  import { pathToFileURL } from 'node:url';
14
- import type { Module, SequenceDescriptor, DerivedDescriptor } from '@everystack/model';
14
+ import type { Module, ModelDescriptor, SequenceDescriptor, DerivedDescriptor } from '@everystack/model';
15
15
  import { compileDerived } from './derived-compile.js';
16
16
  import { compileTableRenames } from './schema-compile.js';
17
17
  import type { SourceObject } from './derived-source.js';
@@ -27,6 +27,9 @@ export interface DeclaredDerived {
27
27
  sequences: SequenceDescriptor[];
28
28
  /** Pending table renames (`new qualified` → `old qualified`) from the models' markers. */
29
29
  renamedTables: Record<string, string>;
30
+ /** The models the modules compose — db:check's split-brain gate compares these against
31
+ * the barrel's `export const models` (the array every verb's state stream reads). */
32
+ models: ModelDescriptor[];
30
33
  }
31
34
 
32
35
  /**
@@ -166,6 +169,7 @@ export function composeDeclaredDerived(modules: Module[], modelsPath: string): D
166
169
  derived,
167
170
  sequences: modules.flatMap((m) => m.sequences),
168
171
  renamedTables: compileTableRenames(models, {}),
172
+ models,
169
173
  };
170
174
  } catch (err) {
171
175
  throw asModelComposeError(modelsPath, err);
@@ -52,6 +52,10 @@ export interface DerivedRenderOptions {
52
52
  columns: Map<string, ColumnSchema[]>;
53
53
  /** Enum name → values, for enum-typed matview columns (same map the model render uses). */
54
54
  enums?: Map<string, string[]>;
55
+ /** --suggest-keys: matview identity → columns measured functionally unique over the live
56
+ * rows. Rendered as a COMMENTED .primaryKey() suggestion on the field line — a surfaced
57
+ * decision (the authz-scaffold idiom), never applied code. */
58
+ keyCandidates?: Map<string, string[]>;
55
59
  };
56
60
  }
57
61
 
@@ -276,7 +280,12 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
276
280
  table: o.identity, columns: cols,
277
281
  primaryKey: [], uniques: [], checks: [], foreignKeys: [], indexes: [],
278
282
  };
279
- const fieldLines = renderFieldLines(shape, new Set(), opts.matviewsAsTables.enums ?? new Map());
283
+ // Field lines align with `cols` by index append the --suggest-keys comment in place.
284
+ const suggested = new Set(opts.matviewsAsTables.keyCandidates?.get(o.identity) ?? []);
285
+ const fieldLines = renderFieldLines(shape, new Set(), opts.matviewsAsTables.enums ?? new Map())
286
+ .map((line, i) => suggested.has(cols[i].name)
287
+ ? `${line} // PK candidate: non-null + all-distinct across live rows — add .primaryKey() if this is the natural key`
288
+ : line);
280
289
  const builders = o.indexes.map((def) => {
281
290
  const b = renderTableIndexBuilder(def);
282
291
  if (b === null) warnings.push(`${o.identity}: index not renderable: ${def}`);
@@ -439,8 +448,10 @@ export function renderDerivedFile(result: DerivedRenderResult, knownTables: Map<
439
448
  if (result.sequenceNames.length) {
440
449
  lines.push(`export const sequences = [\n${result.sequenceNames.map((n) => ` ${n},`).join('\n')}\n];`);
441
450
  }
442
- // Materialized tables (--matviews-as-tables) are MODELS: the consumer spreads this array
443
- // into their module's models — defineModule({ models: [...models, ...materializedTables] }).
451
+ // Materialized tables (--matviews-as-tables) are MODELS: spread this array into the
452
+ // barrel's `export const models`the array the verbs' state stream reads — and let
453
+ // defineModule take that same array. Spreading into defineModule ONLY leaves the export
454
+ // without these tables and the ephemeral compose fails (db:check's split-brain gate).
444
455
  if (result.materializedTableNames.length) {
445
456
  lines.push(`export const materializedTables = [\n${result.materializedTableNames.map((n) => ` ${n},`).join('\n')}\n];`);
446
457
  }
package/src/cli/index.ts CHANGED
@@ -364,7 +364,7 @@ Usage:
364
364
  everystack db:export --schema <name> [--stage <name> | --database-url <url> [--out <file.dump>]] [--models <barrel>] Schema-scoped pg_dump artifact, stamped with the DECLARED schema fingerprint (the canonical-sync export; db:swap gates on that stamp). --stage dumps the stage's private DB via the ops Lambda → S3; --database-url (explicit flag, never the env) dumps a reachable DB to a local .dump + .meta.json — the build-locally → publish → swap on-ramp
365
365
  everystack db:swap --schema <name> --database-url <url> --from <artifact.dump> [--fingerprint <hash>] Land a schema artifact atomically: fingerprint gate → restore into <schema>_incoming (COPY-safe rewrite) → one txn (drop+rename+recreate app→schema FKs, re-apply authz) → verify → drop retiring. Refresh-free; app.* untouched. DESTRUCTIVE (--stage/--direct ops venue rides stage-write-lanes)
366
366
  everystack db:generate [--stage <name> | --database-url <url>] [--name <label>] [--models db/models/index.ts] [--schema-out db/schema.generated.ts] [--allow-drops] [--apply] [--dry-run] Diff models vs the live DB → next migration file, or with --apply execute it directly (one transaction, schema_log recorded, verified by re-diff — no drizzle folder needed; direct connection only; DROPs held back unless --allow-drops). --dry-run prints the edge and writes NOTHING (no migration, no journal entry, no schema refresh) — the preview verb; db:diff computes a models-vs-models edge with no database at all. The resolved --schema-out is recorded in the migration journal: later flag-less runs reuse it (flag > recorded > default), a differing flag updates the record and says so
367
- everystack db:pull [--stage <name> | --database-url <url>] [--schema public] [--out <dir | file.ts>] [--derived-out <file.ts>] [--abilities public-read] Introspect the live DB → render field() Models (the brownfield on-ramp). --out <dir> writes one file per model + index.ts (the default shape); --out <file.ts> writes a single module; stdout otherwise. --derived-out <file.ts> extracts the derived layer (descriptors + sequences) as its own self-contained module — alone it leaves the models untouched (the hand-maintained-barrel splice); with --out the models render omits the now-external derived layer. Every model scaffolds its authz decision as comments (db:check fails until authored); --abilities public-read stamps the common stanza (public read, admin write) uncommented — explicit generated code, never a runtime default. --matviews-as-tables renders every matview as defineMaterializedTable with INTROSPECTED fields (the canonical-sync flip: a pipeline-owned table everystack migrates but never refreshes) — names land in an exported materializedTables array to spread into your models; fields come back nullable/unkeyed (matviews carry no PK/NOT NULL) — tighten on review
367
+ everystack db:pull [--stage <name> | --database-url <url>] [--schema public] [--out <dir | file.ts>] [--derived-out <file.ts>] [--abilities public-read] Introspect the live DB → render field() Models (the brownfield on-ramp). --out <dir> writes one file per model + index.ts (the default shape); --out <file.ts> writes a single module; stdout otherwise. --derived-out <file.ts> extracts the derived layer (descriptors + sequences) as its own self-contained module — alone it leaves the models untouched (the hand-maintained-barrel splice); with --out the models render omits the now-external derived layer. Every model scaffolds its authz decision as comments (db:check fails until authored); --abilities public-read stamps the common stanza (public read, admin write) uncommented — explicit generated code, never a runtime default. --matviews-as-tables renders every matview as defineMaterializedTable with INTROSPECTED fields (the canonical-sync flip: a pipeline-owned table everystack migrates but never refreshes) — names land in an exported materializedTables array to spread into your models; fields come back nullable/unkeyed (matviews carry no PK/NOT NULL) — tighten on review; add --suggest-keys to probe the LIVE rows for functionally-unique columns (one scan per matview) and surface each as a commented .primaryKey() suggestion. docs/derived-objects.md#flipping-a-matview-to-a-materialized-table---matviews-as-tables
368
368
  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.
369
369
  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
370
370
  everystack db:reconcile [--stage <name> | --database-url <url>] [--apply] [--check] [--baseline] [--rebuild] [--overwrite-drift] [--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 (direct connection only, atomic — DDL + provenance in one transaction) and records provenance + schema_log. 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.