@everystack/cli 0.3.31 → 0.4.0
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 +1 -1
- package/package.json +2 -2
- package/src/cli/authz-compile.ts +5 -2
- package/src/cli/backfill.ts +1 -1
- package/src/cli/commands/db-branch.ts +28 -4
- package/src/cli/commands/db-check.ts +84 -33
- package/src/cli/commands/db-fingerprint.ts +7 -2
- package/src/cli/commands/db-generate.ts +71 -20
- package/src/cli/commands/db-pull.ts +37 -5
- package/src/cli/commands/db-reconcile.ts +132 -41
- package/src/cli/commands/db-sync.ts +54 -19
- package/src/cli/db-build.ts +9 -3
- package/src/cli/db-source.ts +5 -1
- package/src/cli/declared-derived.ts +136 -0
- package/src/cli/derived-apply.ts +40 -6
- package/src/cli/derived-compile.ts +327 -0
- package/src/cli/derived-grants.ts +96 -0
- package/src/cli/derived-introspect.ts +258 -21
- package/src/cli/derived-lint.ts +261 -0
- package/src/cli/derived-plan.ts +176 -10
- package/src/cli/derived-render.ts +320 -0
- package/src/cli/derived-source.ts +53 -125
- package/src/cli/git-descent.ts +15 -2
- package/src/cli/index.ts +6 -6
- package/src/cli/migration-compile.ts +38 -7
- package/src/cli/migration-generate.ts +43 -4
- package/src/cli/model-render.ts +106 -13
- package/src/cli/models-path.ts +1 -1
- package/src/cli/pipeline-path.ts +1 -1
- package/src/cli/schema-compile.ts +41 -10
- package/src/cli/schema-diff.ts +123 -17
- package/src/cli/schema-fingerprint.ts +28 -18
- package/src/cli/schema-introspect.ts +175 -8
- package/src/cli/schema-source.ts +75 -6
- package/src/cli/state-apply.ts +4 -1
|
@@ -53,10 +53,17 @@ export interface CheckConstraint {
|
|
|
53
53
|
/** A standalone index (NOT one backing a PK or UNIQUE constraint — those are excluded). */
|
|
54
54
|
export interface IndexSchema {
|
|
55
55
|
name: string;
|
|
56
|
+
/** Canonical key text per position: a plain column name, or anything richer verbatim —
|
|
57
|
+
* `lower(email)`, `created_at DESC`, `title text_pattern_ops` (Brick E: expression
|
|
58
|
+
* indexes, direction, and opclass are IDENTITY, not noise). Default ASC is stripped. */
|
|
56
59
|
columns: string[];
|
|
57
60
|
unique: boolean;
|
|
58
61
|
/** Partial-index predicate (`WHERE …`), omitted when the index is total. */
|
|
59
62
|
where?: string;
|
|
63
|
+
/** Access method when not btree (`'gin'`, `'gist'`, …) — btree is never spelled. */
|
|
64
|
+
using?: string;
|
|
65
|
+
/** `INCLUDE (…)` covering columns. */
|
|
66
|
+
include?: string[];
|
|
60
67
|
}
|
|
61
68
|
|
|
62
69
|
export interface TableSchema {
|
|
@@ -78,10 +85,25 @@ export interface EnumType {
|
|
|
78
85
|
values: string[];
|
|
79
86
|
}
|
|
80
87
|
|
|
88
|
+
/** A STANDALONE sequence — one no serial column owns (those stay implicit in their
|
|
89
|
+
* column). It holds state (the counter position), so it lives in the base schema:
|
|
90
|
+
* migrated before tables, diffed by existence, fingerprinted. `start`/`increment`
|
|
91
|
+
* are omitted when they are PostgreSQL's defaults (1), so a declaration that omits
|
|
92
|
+
* them matches a live default exactly. */
|
|
93
|
+
export interface SequenceSchema {
|
|
94
|
+
/** Bare for public (`order_number_seq`), qualified otherwise (`reporting.order_seq`). */
|
|
95
|
+
name: string;
|
|
96
|
+
as: 'integer' | 'bigint';
|
|
97
|
+
start?: number;
|
|
98
|
+
increment?: number;
|
|
99
|
+
}
|
|
100
|
+
|
|
81
101
|
export interface SchemaSnapshot {
|
|
82
102
|
tables: TableSchema[];
|
|
83
103
|
/** Enum types in the schema (`CREATE TYPE … AS ENUM`). Absent/empty when none. */
|
|
84
104
|
enums?: EnumType[];
|
|
105
|
+
/** Standalone sequences (serial-owned ones excluded). Absent/empty when none. */
|
|
106
|
+
sequences?: SequenceSchema[];
|
|
85
107
|
}
|
|
86
108
|
|
|
87
109
|
// ---------------------------------------------------------------------------
|
|
@@ -163,6 +185,30 @@ GROUP BY n.nspname, t.typname
|
|
|
163
185
|
ORDER BY n.nspname, t.typname;
|
|
164
186
|
`.trim();
|
|
165
187
|
|
|
188
|
+
/**
|
|
189
|
+
* STANDALONE sequences — the ones no serial column owns (a serial's implicit sequence
|
|
190
|
+
* has an 'a'/'i' pg_depend link to its column and stays represented BY that column).
|
|
191
|
+
* These hold state, so they are base-schema: migrated, diffed, fingerprinted.
|
|
192
|
+
*/
|
|
193
|
+
export const SEQUENCES_SQL = `
|
|
194
|
+
SELECT
|
|
195
|
+
n.nspname AS schema,
|
|
196
|
+
c.relname AS name,
|
|
197
|
+
format_type(s.seqtypid, NULL) AS data_type,
|
|
198
|
+
s.seqstart AS start,
|
|
199
|
+
s.seqincrement AS increment
|
|
200
|
+
FROM pg_sequence s
|
|
201
|
+
JOIN pg_class c ON c.oid = s.seqrelid
|
|
202
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
203
|
+
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
204
|
+
AND n.nspname NOT LIKE 'pg_%'
|
|
205
|
+
AND NOT EXISTS (
|
|
206
|
+
SELECT 1 FROM pg_depend d
|
|
207
|
+
WHERE d.objid = c.oid AND d.deptype IN ('a', 'i') AND d.refobjsubid > 0
|
|
208
|
+
)
|
|
209
|
+
ORDER BY n.nspname, c.relname;
|
|
210
|
+
`.trim();
|
|
211
|
+
|
|
166
212
|
/**
|
|
167
213
|
* Standalone indexes — `CREATE INDEX` the app declares, NOT the implicit indexes that back a
|
|
168
214
|
* primary key or unique constraint (those are excluded by the `conindid` check, since they're
|
|
@@ -179,9 +225,12 @@ SELECT
|
|
|
179
225
|
(SELECT array_agg(a.attname ORDER BY k.ord)
|
|
180
226
|
FROM unnest(idx.indkey) WITH ORDINALITY k(attnum, ord)
|
|
181
227
|
JOIN pg_attribute a ON a.attrelid = idx.indrelid AND a.attnum = k.attnum) AS columns,
|
|
182
|
-
pg_get_expr(idx.indpred, idx.indrelid) AS predicate
|
|
228
|
+
pg_get_expr(idx.indpred, idx.indrelid) AS predicate,
|
|
229
|
+
pg_get_indexdef(idx.indexrelid) AS definition,
|
|
230
|
+
am.amname AS method
|
|
183
231
|
FROM pg_index idx
|
|
184
232
|
JOIN pg_class i ON i.oid = idx.indexrelid
|
|
233
|
+
JOIN pg_am am ON am.oid = i.relam
|
|
185
234
|
JOIN pg_class t ON t.oid = idx.indrelid
|
|
186
235
|
JOIN pg_namespace n ON n.oid = t.relnamespace
|
|
187
236
|
WHERE t.relkind = 'r'
|
|
@@ -300,17 +349,107 @@ export interface IndexRow {
|
|
|
300
349
|
is_unique: unknown;
|
|
301
350
|
columns: unknown;
|
|
302
351
|
predicate: unknown;
|
|
352
|
+
/** `pg_get_indexdef` — the authoritative key list (expressions, DESC, opclass, INCLUDE).
|
|
353
|
+
* Optional: a legacy row without it falls back to the plain columns array. */
|
|
354
|
+
definition?: unknown;
|
|
355
|
+
/** `pg_am.amname`. Optional; btree folds to absent. */
|
|
356
|
+
method?: unknown;
|
|
303
357
|
}
|
|
304
358
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
const
|
|
359
|
+
/** Split a parenthesized list on top-level commas (expression entries contain their own). */
|
|
360
|
+
function splitTopLevel(list: string): string[] {
|
|
361
|
+
const out: string[] = [];
|
|
362
|
+
let depth = 0;
|
|
363
|
+
let start = 0;
|
|
364
|
+
for (let i = 0; i < list.length; i++) {
|
|
365
|
+
const ch = list[i];
|
|
366
|
+
if (ch === '(') depth++;
|
|
367
|
+
else if (ch === ')') depth--;
|
|
368
|
+
else if (ch === ',' && depth === 0) {
|
|
369
|
+
out.push(list.slice(start, i));
|
|
370
|
+
start = i + 1;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
out.push(list.slice(start));
|
|
374
|
+
return out.map((s) => s.trim()).filter(Boolean);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** The parenthesized group starting at `open` (which must point at '('), content only. */
|
|
378
|
+
function parenGroup(text: string, open: number): string | null {
|
|
379
|
+
let depth = 0;
|
|
380
|
+
for (let i = open; i < text.length; i++) {
|
|
381
|
+
if (text[i] === '(') depth++;
|
|
382
|
+
else if (text[i] === ')' && --depth === 0) return text.slice(open + 1, i);
|
|
383
|
+
}
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Canonicalize one key entry: unquote a plain `"col"`, strip the default ` ASC`. */
|
|
388
|
+
function canonicalKeyEntry(entry: string): string {
|
|
389
|
+
let s = entry.trim().replace(/\s+ASC$/i, '');
|
|
390
|
+
const quoted = s.match(/^"((?:[^"]|"")*)"$/);
|
|
391
|
+
if (quoted) s = quoted[1].replace(/""/g, '"');
|
|
392
|
+
return s;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* The extended identity from a `pg_get_indexdef` text (Brick E): canonical key entries
|
|
397
|
+
* (expressions, direction, opclass — default ASC stripped, plain names unquoted), the
|
|
398
|
+
* access method from the `USING` clause (btree folds to absent), INCLUDE columns,
|
|
399
|
+
* uniqueness, and the partial predicate. Null when the text isn't index DDL.
|
|
400
|
+
*/
|
|
401
|
+
export function parseIndexDefinition(definition: string): Omit<IndexSchema, 'name'> | null {
|
|
402
|
+
const usingMatch = definition.match(/\sUSING\s+(\w+)\s*\(/i);
|
|
403
|
+
if (!usingMatch || usingMatch.index === undefined) return null;
|
|
404
|
+
const open = definition.indexOf('(', usingMatch.index);
|
|
405
|
+
const keyList = parenGroup(definition, open);
|
|
406
|
+
if (keyList === null) return null;
|
|
407
|
+
const columns = splitTopLevel(keyList).map(canonicalKeyEntry);
|
|
408
|
+
const method = usingMatch[1].toLowerCase();
|
|
409
|
+
const after = definition.slice(open + keyList.length + 2);
|
|
410
|
+
const includeAt = after.search(/^\s*INCLUDE\s*\(/i);
|
|
411
|
+
const include = includeAt >= 0
|
|
412
|
+
? splitTopLevel(parenGroup(after, after.indexOf('(')) ?? '').map(canonicalKeyEntry)
|
|
413
|
+
: undefined;
|
|
414
|
+
const whereMatch = after.match(/\sWHERE\s+(.+)$/i);
|
|
415
|
+
let where = whereMatch ? whereMatch[1].trim() : '';
|
|
416
|
+
// pg_get_indexdef wraps the predicate in one pair of parens — strip it.
|
|
417
|
+
if (where.startsWith('(') && parenGroup(where, 0) === where.slice(1, -1)) where = where.slice(1, -1).trim();
|
|
308
418
|
return {
|
|
309
|
-
|
|
310
|
-
|
|
419
|
+
columns,
|
|
420
|
+
unique: /^CREATE\s+UNIQUE\s+INDEX/i.test(definition),
|
|
421
|
+
...(where ? { where } : {}),
|
|
422
|
+
...(method !== 'btree' ? { using: method } : {}),
|
|
423
|
+
...(include?.length ? { include } : {}),
|
|
311
424
|
};
|
|
312
425
|
}
|
|
313
426
|
|
|
427
|
+
/** A row's index descriptor: the definition text when present (the extended identity);
|
|
428
|
+
* a legacy row without one keeps the old plain-columns path, so fixtures stay valid.
|
|
429
|
+
* `is_unique`/`predicate`/`method` row fields take precedence over the parsed text. */
|
|
430
|
+
export function indexRowToDescriptor(row: IndexRow): { table: string; index: IndexSchema } {
|
|
431
|
+
const pred = row.predicate == null ? '' : String(row.predicate);
|
|
432
|
+
const table = `${row.schema}.${row.table}`;
|
|
433
|
+
const base = { name: row.name, unique: coerceBool(row.is_unique), ...(pred ? { where: pred } : {}) };
|
|
434
|
+
|
|
435
|
+
const parsed = row.definition == null ? null : parseIndexDefinition(String(row.definition));
|
|
436
|
+
if (parsed) {
|
|
437
|
+
const method = row.method == null ? parsed.using : String(row.method).toLowerCase();
|
|
438
|
+
return {
|
|
439
|
+
table,
|
|
440
|
+
index: {
|
|
441
|
+
...base,
|
|
442
|
+
columns: parsed.columns,
|
|
443
|
+
...(method && method !== 'btree' ? { using: method } : {}),
|
|
444
|
+
...(parsed.include?.length ? { include: parsed.include } : {}),
|
|
445
|
+
},
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const columns = Array.isArray(row.columns) ? row.columns.map(String) : [];
|
|
450
|
+
return { table, index: { ...base, columns } };
|
|
451
|
+
}
|
|
452
|
+
|
|
314
453
|
export interface EnumRow {
|
|
315
454
|
schema: string;
|
|
316
455
|
name: string;
|
|
@@ -322,11 +461,32 @@ export function enumRowToDescriptor(row: EnumRow): EnumType {
|
|
|
322
461
|
return { name: row.name, values };
|
|
323
462
|
}
|
|
324
463
|
|
|
464
|
+
export interface SequenceRow {
|
|
465
|
+
schema: string;
|
|
466
|
+
name: string;
|
|
467
|
+
data_type: unknown;
|
|
468
|
+
start: unknown;
|
|
469
|
+
increment: unknown;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
export function sequenceRowToDescriptor(row: SequenceRow): SequenceSchema {
|
|
473
|
+
const start = Number(row.start ?? 1);
|
|
474
|
+
const increment = Number(row.increment ?? 1);
|
|
475
|
+
return {
|
|
476
|
+
name: row.schema === 'public' ? row.name : `${row.schema}.${row.name}`,
|
|
477
|
+
as: String(row.data_type) === 'integer' ? 'integer' : 'bigint',
|
|
478
|
+
// PostgreSQL's defaults are omitted, so declared-without matches live-default.
|
|
479
|
+
...(start !== 1 ? { start } : {}),
|
|
480
|
+
...(increment !== 1 ? { increment } : {}),
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
|
|
325
484
|
export interface SchemaRows {
|
|
326
485
|
columns: ColumnRow[];
|
|
327
486
|
constraints: ConstraintRow[];
|
|
328
487
|
enums?: EnumRow[];
|
|
329
488
|
indexes?: IndexRow[];
|
|
489
|
+
sequences?: SequenceRow[];
|
|
330
490
|
}
|
|
331
491
|
|
|
332
492
|
/**
|
|
@@ -400,9 +560,15 @@ export function assembleSchema(rows: SchemaRows): SchemaSnapshot {
|
|
|
400
560
|
.map(enumRowToDescriptor)
|
|
401
561
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
402
562
|
|
|
563
|
+
const sequences = (rows.sequences ?? [])
|
|
564
|
+
.filter((r) => !IGNORED_SCHEMAS.has(r.schema))
|
|
565
|
+
.map(sequenceRowToDescriptor)
|
|
566
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
567
|
+
|
|
403
568
|
return {
|
|
404
569
|
tables: [...tables.values()].sort((a, b) => a.table.localeCompare(b.table)),
|
|
405
570
|
enums,
|
|
571
|
+
...(sequences.length ? { sequences } : {}),
|
|
406
572
|
};
|
|
407
573
|
}
|
|
408
574
|
|
|
@@ -413,13 +579,14 @@ export function assembleSchema(rows: SchemaRows): SchemaSnapshot {
|
|
|
413
579
|
* `assembleSchema`. This is the `current` side `db:generate` diffs the compiled Models against.
|
|
414
580
|
*/
|
|
415
581
|
export async function introspectSchema(run: QueryRunner): Promise<SchemaSnapshot> {
|
|
416
|
-
const [columns, constraints, enums, indexes] = await Promise.all([
|
|
417
|
-
run(COLUMNS_SQL), run(CONSTRAINTS_SQL), run(ENUMS_SQL), run(INDEXES_SQL),
|
|
582
|
+
const [columns, constraints, enums, indexes, sequences] = await Promise.all([
|
|
583
|
+
run(COLUMNS_SQL), run(CONSTRAINTS_SQL), run(ENUMS_SQL), run(INDEXES_SQL), run(SEQUENCES_SQL),
|
|
418
584
|
]);
|
|
419
585
|
return assembleSchema({
|
|
420
586
|
columns: columns as ColumnRow[],
|
|
421
587
|
constraints: constraints as ConstraintRow[],
|
|
422
588
|
enums: enums as EnumRow[],
|
|
423
589
|
indexes: indexes as IndexRow[],
|
|
590
|
+
sequences: sequences as SequenceRow[],
|
|
424
591
|
});
|
|
425
592
|
}
|
package/src/cli/schema-source.ts
CHANGED
|
@@ -25,7 +25,8 @@
|
|
|
25
25
|
* Input is a `@everystack/model` ModelDescriptor (type-only — no runtime dep).
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
-
import type { ModelDescriptor, FieldSpec } from '@everystack/model';
|
|
28
|
+
import type { ModelDescriptor, FieldSpec, DerivedDescriptor, ViewDescriptor, MaterializedViewDescriptor } from '@everystack/model';
|
|
29
|
+
import { parseQualified } from './derived-source.js';
|
|
29
30
|
|
|
30
31
|
/** `author_id` is the SQL identifier; `authorId` the field key. The single snake-case rule. */
|
|
31
32
|
function toSnakeCase(name: string): string {
|
|
@@ -144,6 +145,7 @@ function columnModifiers(
|
|
|
144
145
|
if (spec.defaultKind === 'now') s += '.defaultNow()';
|
|
145
146
|
else if (spec.defaultKind === 'random') s += '.defaultRandom()';
|
|
146
147
|
else if (spec.defaultKind === 'value') s += `.default(${defaultValueLiteral(spec.default)})`;
|
|
148
|
+
else if (spec.defaultKind === 'sql') s += `.default(sql.raw(${strLiteral(String(spec.default))}))`;
|
|
147
149
|
|
|
148
150
|
if (spec.isPrimaryKey && !isComposite) s += '.primaryKey()';
|
|
149
151
|
|
|
@@ -250,6 +252,25 @@ function relationNameFragment(
|
|
|
250
252
|
export interface CompileDrizzleSourceOptions {
|
|
251
253
|
/** Reserved for parity with the DDL compiler's `schema` option. Unused in the emit. */
|
|
252
254
|
schema?: string;
|
|
255
|
+
/**
|
|
256
|
+
* The declared derived layer (B6a): views/matviews that declare `fields` are emitted
|
|
257
|
+
* as typed `pgView(...)`/`pgMaterializedView(...)` `.existing()` blocks — the
|
|
258
|
+
* reconciler owns their DDL; the generated schema carries only the read shape.
|
|
259
|
+
* Relations without `fields` and non-relation kinds are skipped.
|
|
260
|
+
*/
|
|
261
|
+
derived?: DerivedDescriptor[];
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** The derived relations that opted into typed emission, identity-sorted (deterministic). */
|
|
265
|
+
function typedRelations(derived: DerivedDescriptor[]): (ViewDescriptor | MaterializedViewDescriptor)[] {
|
|
266
|
+
return derived
|
|
267
|
+
.filter((d): d is ViewDescriptor | MaterializedViewDescriptor =>
|
|
268
|
+
(d.kind === 'view' || d.kind === 'materialized view') && !!d.fields && Object.keys(d.fields).length > 0)
|
|
269
|
+
.sort((a, b) => {
|
|
270
|
+
const qa = parseQualified(a.name);
|
|
271
|
+
const qb = parseQualified(b.name);
|
|
272
|
+
return qa.schema.localeCompare(qb.schema) || qa.name.localeCompare(qb.name);
|
|
273
|
+
});
|
|
253
274
|
}
|
|
254
275
|
|
|
255
276
|
/**
|
|
@@ -264,14 +285,15 @@ export function compileDrizzleSource(allModels: ModelDescriptor[], _opts: Compil
|
|
|
264
285
|
// generated schema. (The migration generator still emits its SQL + authz.) The
|
|
265
286
|
// command and the drift guard both call this, so filtering here keeps them in sync.
|
|
266
287
|
const models = allModels.filter((m) => !m.private);
|
|
288
|
+
const derivedRelations = typedRelations(_opts.derived ?? []);
|
|
267
289
|
|
|
268
290
|
const modelsByDescriptor = new Map<ModelDescriptor, { camel: string }>();
|
|
269
291
|
for (const model of models) modelsByDescriptor.set(model, { camel: toCamelCase(model.table) });
|
|
270
292
|
|
|
271
293
|
// --- Collect enums (deduped by name, sorted) -----------------------------
|
|
272
294
|
const enumsByName = new Map<string, readonly string[]>();
|
|
273
|
-
|
|
274
|
-
for (const f of Object.values(
|
|
295
|
+
const collectEnums = (fields: Record<string, { spec: FieldSpec }>) => {
|
|
296
|
+
for (const f of Object.values(fields)) {
|
|
275
297
|
const { type, enumName, enumValues } = f.spec;
|
|
276
298
|
if (type !== 'enum' || !enumName) continue;
|
|
277
299
|
const values = enumValues ?? [];
|
|
@@ -281,7 +303,9 @@ export function compileDrizzleSource(allModels: ModelDescriptor[], _opts: Compil
|
|
|
281
303
|
}
|
|
282
304
|
if (!existing) enumsByName.set(enumName, values);
|
|
283
305
|
}
|
|
284
|
-
}
|
|
306
|
+
};
|
|
307
|
+
for (const model of models) collectEnums(model.fields);
|
|
308
|
+
for (const rel of derivedRelations) collectEnums(rel.fields!);
|
|
285
309
|
const enumNames = [...enumsByName.keys()].sort((a, b) => a.localeCompare(b));
|
|
286
310
|
|
|
287
311
|
// --- Track which pg-core builders + drizzle-orm symbols are used ----------
|
|
@@ -303,6 +327,7 @@ export function compileDrizzleSource(allModels: ModelDescriptor[], _opts: Compil
|
|
|
303
327
|
const spec = builder.spec;
|
|
304
328
|
const { call, builder: pgBuilder } = baseColumnSource(toSnakeCase(key), spec);
|
|
305
329
|
pgCoreBuilders.add(pgBuilder);
|
|
330
|
+
if (spec.defaultKind === 'sql') pgCoreBuilders.add('sql'); // imported from 'drizzle-orm', handled below
|
|
306
331
|
const mods = columnModifiers(spec, isComposite, modelsByDescriptor);
|
|
307
332
|
if (spec.isDeprecated) {
|
|
308
333
|
// The contract phase (decision 13): the column stays in the database and
|
|
@@ -381,14 +406,57 @@ export function compileDrizzleSource(allModels: ModelDescriptor[], _opts: Compil
|
|
|
381
406
|
);
|
|
382
407
|
}
|
|
383
408
|
|
|
409
|
+
// --- Emit derived relation blocks (typed reads — B6a) ----------------------
|
|
410
|
+
// Views/matviews that declared `fields` become `.existing()` declarations: the
|
|
411
|
+
// reconciler owns their DDL, so drizzle never creates them — the block carries
|
|
412
|
+
// ONLY the read shape (type, nullability, array). Identity-sorted for the
|
|
413
|
+
// drift-guard byte-match; column names snake_case by the same single rule.
|
|
414
|
+
const derivedBlocks: string[] = [];
|
|
415
|
+
for (const rel of derivedRelations) {
|
|
416
|
+
const { schema, name } = parseQualified(rel.name);
|
|
417
|
+
const camel = toCamelCase(schema === 'public' ? name : `${schema}_${name}`);
|
|
418
|
+
|
|
419
|
+
const colLines: string[] = [];
|
|
420
|
+
for (const [key, builder] of Object.entries(rel.fields!)) {
|
|
421
|
+
const spec = builder.spec;
|
|
422
|
+
const { call, builder: pgBuilder } = baseColumnSource(toSnakeCase(key), spec);
|
|
423
|
+
pgCoreBuilders.add(pgBuilder);
|
|
424
|
+
// Only .array()/.notNull() can appear — defineView/defineMaterializedView
|
|
425
|
+
// reject every table-structural modifier at define time.
|
|
426
|
+
const mods = columnModifiers(spec, true, modelsByDescriptor);
|
|
427
|
+
colLines.push(` ${key}: ${call}${mods},`);
|
|
428
|
+
}
|
|
429
|
+
const cols = `{\n${colLines.join('\n')}\n}`;
|
|
430
|
+
|
|
431
|
+
if (schema === 'public') {
|
|
432
|
+
const topFn = rel.kind === 'view' ? 'pgView' : 'pgMaterializedView';
|
|
433
|
+
pgCoreBuilders.add(topFn);
|
|
434
|
+
derivedBlocks.push(`export const ${camel} = ${topFn}(${strLiteral(name)}, ${cols}).existing();`);
|
|
435
|
+
} else {
|
|
436
|
+
const schemaFn = rel.kind === 'view' ? 'view' : 'materializedView';
|
|
437
|
+
pgCoreBuilders.add('pgSchema');
|
|
438
|
+
derivedBlocks.push(`export const ${camel} = pgSchema(${strLiteral(schema)}).${schemaFn}(${strLiteral(name)}, ${cols}).existing();`);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
384
442
|
// --- Header ----------------------------------------------------------------
|
|
385
|
-
const
|
|
443
|
+
const headerLines = [
|
|
386
444
|
'// GENERATED by `everystack db:generate` from models/. Do not edit.',
|
|
387
445
|
'//',
|
|
388
446
|
'// Cross-schema foreign keys (e.g. profiles.id -> auth.users.id) are intentionally',
|
|
389
447
|
'// omitted: a Model cannot reference a table outside the model set, so the runtime',
|
|
390
448
|
'// table and its relations() never carry that FK. The DB constraint stays untouched.',
|
|
391
|
-
]
|
|
449
|
+
];
|
|
450
|
+
if (derivedBlocks.length) {
|
|
451
|
+
headerLines.push(
|
|
452
|
+
'//',
|
|
453
|
+
'// Derived relations (views/matviews) are declared `.existing()`: db:reconcile owns',
|
|
454
|
+
'// their DDL — these blocks carry only the read shape their descriptors declare.',
|
|
455
|
+
'// That shape is author-declared, NOT introspected: a wrong field type or',
|
|
456
|
+
'// nullability in the descriptor mistypes reads silently. Fix the descriptor.',
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
const header = headerLines.join('\n');
|
|
392
460
|
|
|
393
461
|
// --- Imports ---------------------------------------------------------------
|
|
394
462
|
// pg-core: sorted, enum consts are not imports (they're declared below), `sql`
|
|
@@ -422,6 +490,7 @@ export function compileDrizzleSource(allModels: ModelDescriptor[], _opts: Compil
|
|
|
422
490
|
if (enumDecls.length) sections.push(enumDecls.join('\n'));
|
|
423
491
|
for (const block of tableBlocks) sections.push(block);
|
|
424
492
|
for (const block of relationBlocks) sections.push(block);
|
|
493
|
+
for (const block of derivedBlocks) sections.push(block);
|
|
425
494
|
|
|
426
495
|
return sections.join('\n\n') + '\n';
|
|
427
496
|
}
|
package/src/cli/state-apply.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { introspectContract, type AuthzContract, type QueryRunner } from './auth
|
|
|
23
23
|
import { introspectSchema, type SchemaSnapshot } from './schema-introspect.js';
|
|
24
24
|
import { FUNCTIONS_SQL, contractFunctionRow } from './security-catalog.js';
|
|
25
25
|
import { generateMigrationSql, HELD_DROP_PREFIX } from './migration-generate.js';
|
|
26
|
+
import type { SequenceDescriptor } from '@everystack/model';
|
|
26
27
|
import { fingerprintLive } from './schema-fingerprint.js';
|
|
27
28
|
import { ENSURE_RECONCILER_SQL, renderSchemaLogInsert, renderSchemaLogFingerprintUpdate } from './derived-apply.js';
|
|
28
29
|
|
|
@@ -170,6 +171,8 @@ export async function applyGeneratedStatements(
|
|
|
170
171
|
export interface StateSyncOptions extends StateApplyOptions {
|
|
171
172
|
/** Passed through to the verifying re-diff so held-vs-real matches the plan. */
|
|
172
173
|
allowDrops?: boolean;
|
|
174
|
+
/** Standalone sequences — the verify re-diff must see the same declared state as the plan. */
|
|
175
|
+
sequences?: SequenceDescriptor[];
|
|
173
176
|
}
|
|
174
177
|
|
|
175
178
|
export interface StateSyncOutcome {
|
|
@@ -228,7 +231,7 @@ export async function applyStateAndVerify(
|
|
|
228
231
|
}
|
|
229
232
|
|
|
230
233
|
const remaining = classifyGeneratedStatements(
|
|
231
|
-
generateMigrationSql(models, snapshot, { allowDrops: options.allowDrops, liveAuthz: contract }),
|
|
234
|
+
generateMigrationSql(models, snapshot, { allowDrops: options.allowDrops, liveAuthz: contract, sequences: options.sequences }),
|
|
232
235
|
).executable;
|
|
233
236
|
|
|
234
237
|
return {
|