@everystack/cli 0.4.13 → 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 +1 -1
- package/src/cli/commands/db-check.ts +30 -0
- package/src/cli/commands/db-pull.ts +73 -3
- package/src/cli/declared-derived.ts +5 -1
- package/src/cli/derived-render.ts +98 -2
- package/src/cli/index.ts +1 -1
- package/src/cli/model-render.ts +15 -2
- package/src/cli/schema-introspect.ts +25 -0
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}`);
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
|
|
30
30
|
import fs from 'node:fs/promises';
|
|
31
31
|
import path from 'node:path';
|
|
32
|
-
import { introspectSchema } from '../schema-introspect.js';
|
|
32
|
+
import { introspectSchema, MATVIEW_COLUMNS_SQL, matviewColumnsByIdentity, type ColumnRow, type ColumnSchema } from '../schema-introspect.js';
|
|
33
33
|
import { introspectDerived, type DerivedCatalog } from '../derived-introspect.js';
|
|
34
34
|
import { renderDerivedSource, renderDerivedFile, type DerivedRenderResult } from '../derived-render.js';
|
|
35
35
|
import { renderModelSource, renderModelFiles, pullableTables, modelVarName, ABILITY_PRESETS } from '../model-render.js';
|
|
@@ -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) => {
|
|
@@ -84,8 +115,17 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
84
115
|
process.exit(1);
|
|
85
116
|
}
|
|
86
117
|
|
|
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
|
+
}
|
|
124
|
+
|
|
87
125
|
let current;
|
|
88
126
|
let derivedCatalog: DerivedCatalog | undefined;
|
|
127
|
+
let matviewColumns: Map<string, ColumnSchema[]> | undefined;
|
|
128
|
+
let candidatesByIdentity: Map<string, string[]> | undefined;
|
|
89
129
|
try {
|
|
90
130
|
let runner: QueryRunner;
|
|
91
131
|
let end: (() => Promise<void>) | undefined;
|
|
@@ -103,6 +143,24 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
103
143
|
// The derived layer rides the same pull (B5) — views/matviews/functions/sequences
|
|
104
144
|
// render as descriptors; adoption is pull → commit → --baseline → clean reconcile.
|
|
105
145
|
derivedCatalog = await introspectDerived(runner);
|
|
146
|
+
// --matviews-as-tables: the flip needs real fields — one extra catalog read for the
|
|
147
|
+
// matview columns the derived layer (definition-only) doesn't carry.
|
|
148
|
+
if (matviewsAsTables) {
|
|
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
|
+
}
|
|
163
|
+
}
|
|
106
164
|
await end?.();
|
|
107
165
|
} catch (err: any) {
|
|
108
166
|
fail(err.message);
|
|
@@ -131,12 +189,23 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
131
189
|
const derived: DerivedRenderResult = renderDerivedSource(derivedCatalog ?? { objects: [], edges: [], provenance: [] }, {
|
|
132
190
|
knownTables,
|
|
133
191
|
sequences: current.sequences,
|
|
192
|
+
...(matviewsAsTables ? {
|
|
193
|
+
matviewsAsTables: {
|
|
194
|
+
columns: matviewColumns ?? new Map(),
|
|
195
|
+
enums: new Map((current.enums ?? []).map((e) => [e.name, e.values])),
|
|
196
|
+
...(candidatesByIdentity ? { keyCandidates: candidatesByIdentity } : {}),
|
|
197
|
+
},
|
|
198
|
+
} : {}),
|
|
134
199
|
});
|
|
135
200
|
for (const w of derived.warnings) caution(w);
|
|
136
201
|
const derivedCount = derived.names.length + derived.sequenceNames.length;
|
|
137
202
|
if (derivedCount > 0) {
|
|
138
203
|
detail(`Derived layer: ${derived.names.length} descriptor(s) + ${derived.sequenceNames.length} sequence(s) rendered (adopt with db:reconcile --baseline after committing).`);
|
|
139
204
|
}
|
|
205
|
+
if (derived.materializedTableNames.length) {
|
|
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.`);
|
|
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.');
|
|
208
|
+
}
|
|
140
209
|
|
|
141
210
|
// The standalone derived file. The models output (below) then omits the derived
|
|
142
211
|
// layer — it lives here, and the module wires both: defineModule({ models, sequences, derived }).
|
|
@@ -149,8 +218,9 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
149
218
|
fail(`Could not write ${derivedOut}: ${err.message}`);
|
|
150
219
|
process.exit(1);
|
|
151
220
|
}
|
|
152
|
-
|
|
153
|
-
|
|
221
|
+
const mtPart = derived.materializedTableNames.length ? ` + ${derived.materializedTableNames.length} materialized table(s)` : '';
|
|
222
|
+
ok(`Wrote ${path.relative(process.cwd(), outPath)} — ${derived.names.length} descriptor(s) + ${derived.sequenceNames.length} sequence(s)${mtPart}, self-contained.`);
|
|
223
|
+
note(`Wire it on your module — defineModule({ models${derived.materializedTableNames.length ? ': [...models, ...materializedTables]' : ''}${derived.sequenceNames.length ? ', sequences' : ''}, derived }) — then adopt with db:reconcile --apply --baseline (--rebaseline over db/sql-era provenance).`);
|
|
154
224
|
if (!flags.out) {
|
|
155
225
|
// The brownfield case: the barrel is hand-maintained; leave the models alone.
|
|
156
226
|
note('Models were NOT rendered (--derived-out extracts the derived layer only; add --out to render models too).');
|
|
@@ -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);
|
|
@@ -14,9 +14,9 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import type { DerivedCatalog, LiveObject } from './derived-introspect.js';
|
|
17
|
-
import type { SequenceSchema } from './schema-introspect.js';
|
|
17
|
+
import type { ColumnSchema, SequenceSchema, TableSchema } from './schema-introspect.js';
|
|
18
18
|
import { parseIndexDefinition } from './schema-introspect.js';
|
|
19
|
-
import { modelFileName } from './model-render.js';
|
|
19
|
+
import { modelFileName, renderFieldLines } from './model-render.js';
|
|
20
20
|
|
|
21
21
|
export interface DerivedRenderResult {
|
|
22
22
|
/** The source block: `export const … = defineView(…)` etc., dependency-ordered. */
|
|
@@ -25,6 +25,9 @@ export interface DerivedRenderResult {
|
|
|
25
25
|
names: string[];
|
|
26
26
|
/** Rendered sequence variable names — feed `defineModule({ sequences })`. */
|
|
27
27
|
sequenceNames: string[];
|
|
28
|
+
/** defineMaterializedTable variable names (--matviews-as-tables) — these are MODELS:
|
|
29
|
+
* feed `defineModule({ models })`, never the derived array. */
|
|
30
|
+
materializedTableNames: string[];
|
|
28
31
|
/** Model-package symbols the block uses (defineView, can, sql, arg, index, …). */
|
|
29
32
|
imports: string[];
|
|
30
33
|
/** Qualified tables the block references through knownTables — what a standalone
|
|
@@ -39,6 +42,21 @@ export interface DerivedRenderOptions {
|
|
|
39
42
|
knownTables: Map<string, string>;
|
|
40
43
|
/** Standalone sequences from the snapshot (serial-owned stay implicit). */
|
|
41
44
|
sequences?: SequenceSchema[];
|
|
45
|
+
/**
|
|
46
|
+
* `db:pull --matviews-as-tables` (the gap-B consumer flip): render every matview as
|
|
47
|
+
* defineMaterializedTable with fields from its LIVE columns (matview identity → columns),
|
|
48
|
+
* instead of a defineMaterializedView descriptor. Matviews carry no PK/NOT NULL, so the
|
|
49
|
+
* fields land nullable and unkeyed — honest to the catalog; the consumer tightens on adoption.
|
|
50
|
+
*/
|
|
51
|
+
matviewsAsTables?: {
|
|
52
|
+
columns: Map<string, ColumnSchema[]>;
|
|
53
|
+
/** Enum name → values, for enum-typed matview columns (same map the model render uses). */
|
|
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[]>;
|
|
59
|
+
};
|
|
42
60
|
}
|
|
43
61
|
|
|
44
62
|
const PLAIN_IDENT = /^[a-z_][a-z0-9_$]*$/;
|
|
@@ -130,10 +148,28 @@ function renderIndexBuilder(indexdef: string): string | null {
|
|
|
130
148
|
return s;
|
|
131
149
|
}
|
|
132
150
|
|
|
151
|
+
/**
|
|
152
|
+
* One matview index → the builder for a MODEL's `constraints: [...]` — same Brick E parse as
|
|
153
|
+
* {@link renderIndexBuilder}, but plain identifiers camelize to FIELD KEYS (the model
|
|
154
|
+
* constraints idiom; the compiler snake_cases them back), while expressions stay raw sql``.
|
|
155
|
+
*/
|
|
156
|
+
function renderTableIndexBuilder(indexdef: string): string | null {
|
|
157
|
+
const parsed = parseIndexDefinition(indexdef);
|
|
158
|
+
if (!parsed) return null;
|
|
159
|
+
const entry = (c: string): string => (PLAIN_IDENT.test(c) ? tsString(toCamelCase(c)) : `sql\`${tsTemplate(c)}\``);
|
|
160
|
+
let s = `index(${parsed.columns.map(entry).join(', ')})`;
|
|
161
|
+
if (parsed.using) s += `.using(${tsString(parsed.using)})`;
|
|
162
|
+
if (parsed.unique) s += '.unique()';
|
|
163
|
+
if (parsed.include?.length) s += `.include(${parsed.include.map((c) => tsString(toCamelCase(c))).join(', ')})`;
|
|
164
|
+
if (parsed.where) s += `.where(sql\`${tsTemplate(parsed.where)}\`)`;
|
|
165
|
+
return s;
|
|
166
|
+
}
|
|
167
|
+
|
|
133
168
|
export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRenderOptions): DerivedRenderResult {
|
|
134
169
|
const warnings: string[] = [];
|
|
135
170
|
const lines: string[] = [];
|
|
136
171
|
const names: string[] = [];
|
|
172
|
+
const materializedTableNames: string[] = [];
|
|
137
173
|
const used = new Set<string>();
|
|
138
174
|
const need = (sym: string): void => { used.add(sym); };
|
|
139
175
|
|
|
@@ -227,6 +263,58 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
|
|
|
227
263
|
lines.push(`// FIXME: ${o.identity} skipped — live grants (${grantText}) are not expressible as abilities (views are read surfaces).`, '');
|
|
228
264
|
continue;
|
|
229
265
|
}
|
|
266
|
+
|
|
267
|
+
// --matviews-as-tables (the gap-B consumer flip): the matview renders as a
|
|
268
|
+
// defineMaterializedTable MODEL — introspected fields, index constraints on field
|
|
269
|
+
// keys, the defining query as metadata. Same topo slot (its dependents still
|
|
270
|
+
// reference it by variable); the name lands in materializedTableNames, not names.
|
|
271
|
+
if (o.kind === 'materialized view' && opts.matviewsAsTables) {
|
|
272
|
+
const cols = opts.matviewsAsTables.columns.get(o.identity);
|
|
273
|
+
if (!cols || cols.length === 0) {
|
|
274
|
+
warnings.push(`${o.identity}: no live columns found for the table render — rendered as defineMaterializedView; re-pull or flip it by hand.`);
|
|
275
|
+
} else {
|
|
276
|
+
if (o.populated === false) {
|
|
277
|
+
warnings.push(`${o.identity}: the live matview is UNPOPULATED — rendered anyway (a materialized table's contents are pipeline-owned).`);
|
|
278
|
+
}
|
|
279
|
+
const shape: TableSchema = {
|
|
280
|
+
table: o.identity, columns: cols,
|
|
281
|
+
primaryKey: [], uniques: [], checks: [], foreignKeys: [], indexes: [],
|
|
282
|
+
};
|
|
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);
|
|
289
|
+
const builders = o.indexes.map((def) => {
|
|
290
|
+
const b = renderTableIndexBuilder(def);
|
|
291
|
+
if (b === null) warnings.push(`${o.identity}: index not renderable: ${def}`);
|
|
292
|
+
return b;
|
|
293
|
+
}).filter((b): b is string => b !== null);
|
|
294
|
+
if (builders.length) need('index');
|
|
295
|
+
if (abilities.length) need('can');
|
|
296
|
+
need('defineMaterializedTable');
|
|
297
|
+
need('field');
|
|
298
|
+
need('sql');
|
|
299
|
+
lines.push(
|
|
300
|
+
...fixmes,
|
|
301
|
+
`export const ${varName} = defineMaterializedTable(${tsString(declaredName(o))}, {`,
|
|
302
|
+
` ${abilities.length ? `abilities: [${abilities.join(', ')}],` : 'private: true,'}`,
|
|
303
|
+
' fields: {',
|
|
304
|
+
...fieldLines,
|
|
305
|
+
' },',
|
|
306
|
+
...(builders.length ? [` constraints: [${builders.join(', ')}],`] : []),
|
|
307
|
+
...(refs.length ? [` dependsOn: [${refs.join(', ')}],`] : []),
|
|
308
|
+
...(o.comment ? [` comment: ${tsString(o.comment)},`] : []),
|
|
309
|
+
` as: sql\`${tsTemplate(o.definition.trim().replace(/;$/, ''))}\`,`,
|
|
310
|
+
'});',
|
|
311
|
+
'',
|
|
312
|
+
);
|
|
313
|
+
materializedTableNames.push(varName);
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
230
318
|
const props: string[] = [];
|
|
231
319
|
if (o.kind === 'view') props.push(`securityInvoker: ${o.securityInvoker === true},`);
|
|
232
320
|
if (abilities.length === 0) props.push('private: true,');
|
|
@@ -327,6 +415,7 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
|
|
|
327
415
|
block: lines.join('\n').trimEnd(),
|
|
328
416
|
names,
|
|
329
417
|
sequenceNames,
|
|
418
|
+
materializedTableNames,
|
|
330
419
|
imports: [...used].sort(),
|
|
331
420
|
modelRefs: [...modelRefs].sort(),
|
|
332
421
|
warnings,
|
|
@@ -359,6 +448,13 @@ export function renderDerivedFile(result: DerivedRenderResult, knownTables: Map<
|
|
|
359
448
|
if (result.sequenceNames.length) {
|
|
360
449
|
lines.push(`export const sequences = [\n${result.sequenceNames.map((n) => ` ${n},`).join('\n')}\n];`);
|
|
361
450
|
}
|
|
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).
|
|
455
|
+
if (result.materializedTableNames.length) {
|
|
456
|
+
lines.push(`export const materializedTables = [\n${result.materializedTableNames.map((n) => ` ${n},`).join('\n')}\n];`);
|
|
457
|
+
}
|
|
362
458
|
if (result.names.length) {
|
|
363
459
|
lines.push(`export const derived = [\n${result.names.map((n) => ` ${n},`).join('\n')}\n];`);
|
|
364
460
|
}
|
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
|
|
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.
|
package/src/cli/model-render.ts
CHANGED
|
@@ -345,6 +345,16 @@ function renderField(table: TableSchema, col: ColumnSchema, known: Set<string>,
|
|
|
345
345
|
return ` ${toCamelCase(col.name)}: ${expr},${comment}`;
|
|
346
346
|
}
|
|
347
347
|
|
|
348
|
+
/**
|
|
349
|
+
* The rendered field lines for a table shape — one ` key: field.…(),` line per column,
|
|
350
|
+
* 4-space indented, comma-terminated (ready to nest under `fields: {`). Exported for the
|
|
351
|
+
* derived renderer: a matview flipped to defineMaterializedTable (--matviews-as-tables)
|
|
352
|
+
* renders its introspected columns with the SAME field vocabulary a pulled table gets.
|
|
353
|
+
*/
|
|
354
|
+
export function renderFieldLines(table: TableSchema, known: Set<string>, enums: Map<string, string[]> = new Map()): string[] {
|
|
355
|
+
return table.columns.map((c) => renderField(table, c, known, enums, new Map()));
|
|
356
|
+
}
|
|
357
|
+
|
|
348
358
|
/**
|
|
349
359
|
* The `constraints: [...]` block for a table's composite uniques, checks, indexes, and
|
|
350
360
|
* multi-column foreign keys — the table-level surface a column-scoped `field()` can't carry.
|
|
@@ -434,9 +444,12 @@ function importHeader(body: string, extra: string[] = []): string {
|
|
|
434
444
|
|
|
435
445
|
/** The barrel's module wrapper: models always; sequences/derived when the pull found any (B5). */
|
|
436
446
|
function moduleFooter(modelNames: string[], derived?: DerivedRenderResult, multiline = false): string {
|
|
447
|
+
// A materialized table (--matviews-as-tables) is a MODEL — its block rides the derived
|
|
448
|
+
// render (topo-ordered with the objects around it) but its name belongs in `models`.
|
|
449
|
+
const allModels = [...modelNames, ...(derived?.materializedTableNames ?? [])];
|
|
437
450
|
const models = multiline
|
|
438
|
-
? `export const models = [\n${
|
|
439
|
-
: `export const models = [${
|
|
451
|
+
? `export const models = [\n${allModels.map((n) => ` ${n},`).join('\n')}\n];`
|
|
452
|
+
: `export const models = [${allModels.join(', ')}];`;
|
|
440
453
|
const parts = [models];
|
|
441
454
|
const keys = ['models'];
|
|
442
455
|
if (derived?.sequenceNames.length) {
|
|
@@ -139,6 +139,31 @@ WHERE c.relkind = 'r'
|
|
|
139
139
|
ORDER BY n.nspname, c.relname, a.attnum;
|
|
140
140
|
`.trim();
|
|
141
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Every column of every MATERIALIZED VIEW — same shape as COLUMNS_SQL, relkind 'm'.
|
|
144
|
+
* Only `db:pull --matviews-as-tables` runs it: the flip to defineMaterializedTable
|
|
145
|
+
* needs real fields, and the matview's columns are catalog facts the derived layer
|
|
146
|
+
* (definition-only) doesn't carry. Matviews hold no NOT NULL/defaults/PK — the rows
|
|
147
|
+
* come back nullable and unkeyed, honestly.
|
|
148
|
+
*/
|
|
149
|
+
export const MATVIEW_COLUMNS_SQL = COLUMNS_SQL.replace(`WHERE c.relkind = 'r'`, `WHERE c.relkind = 'm'`);
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Fold MATVIEW_COLUMNS_SQL rows into matview identity (`stats.player_totals`) → its
|
|
153
|
+
* columns in ordinal order — the columns map the derived renderer's table mode takes.
|
|
154
|
+
*/
|
|
155
|
+
export function matviewColumnsByIdentity(rows: ColumnRow[]): Map<string, ColumnSchema[]> {
|
|
156
|
+
const out = new Map<string, { position: number; column: ColumnSchema }[]>();
|
|
157
|
+
for (const row of rows) {
|
|
158
|
+
const d = columnRowToDescriptor(row);
|
|
159
|
+
const list = out.get(d.table) ?? [];
|
|
160
|
+
list.push({ position: d.position, column: d.column });
|
|
161
|
+
out.set(d.table, list);
|
|
162
|
+
}
|
|
163
|
+
return new Map([...out.entries()].map(([identity, cols]) =>
|
|
164
|
+
[identity, cols.sort((a, b) => a.position - b.position).map((c) => c.column)]));
|
|
165
|
+
}
|
|
166
|
+
|
|
142
167
|
/**
|
|
143
168
|
* Every table constraint — primary key, unique, check, foreign key — as its
|
|
144
169
|
* canonical `pg_get_constraintdef` text. The mapper parses that text (stable
|