@everystack/cli 0.4.14 → 0.4.16
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
|
@@ -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:
|
|
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
|
-
|
|
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}`);
|
|
@@ -287,10 +296,15 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
|
|
|
287
296
|
need('defineMaterializedTable');
|
|
288
297
|
need('field');
|
|
289
298
|
need('sql');
|
|
299
|
+
// MODEL naming idiom: bare table + a schema prop. The derived layer's
|
|
300
|
+
// qualified-first-arg spelling must NOT leak here — defineModel stores the
|
|
301
|
+
// table name verbatim, so 'stats.x' would become a dot-containing identifier
|
|
302
|
+
// in public and 3-part-qualify (public.stats.x) downstream.
|
|
290
303
|
lines.push(
|
|
291
304
|
...fixmes,
|
|
292
|
-
`export const ${varName} = defineMaterializedTable(${tsString(
|
|
305
|
+
`export const ${varName} = defineMaterializedTable(${tsString(o.name)}, {`,
|
|
293
306
|
` ${abilities.length ? `abilities: [${abilities.join(', ')}],` : 'private: true,'}`,
|
|
307
|
+
...(o.schema !== 'public' ? [` schema: ${tsString(o.schema)},`] : []),
|
|
294
308
|
' fields: {',
|
|
295
309
|
...fieldLines,
|
|
296
310
|
' },',
|
|
@@ -439,8 +453,10 @@ export function renderDerivedFile(result: DerivedRenderResult, knownTables: Map<
|
|
|
439
453
|
if (result.sequenceNames.length) {
|
|
440
454
|
lines.push(`export const sequences = [\n${result.sequenceNames.map((n) => ` ${n},`).join('\n')}\n];`);
|
|
441
455
|
}
|
|
442
|
-
// Materialized tables (--matviews-as-tables) are MODELS:
|
|
443
|
-
//
|
|
456
|
+
// Materialized tables (--matviews-as-tables) are MODELS: spread this array into the
|
|
457
|
+
// barrel's `export const models` — the array the verbs' state stream reads — and let
|
|
458
|
+
// defineModule take that same array. Spreading into defineModule ONLY leaves the export
|
|
459
|
+
// without these tables and the ephemeral compose fails (db:check's split-brain gate).
|
|
444
460
|
if (result.materializedTableNames.length) {
|
|
445
461
|
lines.push(`export const materializedTables = [\n${result.materializedTableNames.map((n) => ` ${n},`).join('\n')}\n];`);
|
|
446
462
|
}
|
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.
|