@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.
@@ -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
- export function indexRowToDescriptor(row: IndexRow): { table: string; index: IndexSchema } {
306
- const columns = Array.isArray(row.columns) ? row.columns.map(String) : [];
307
- const pred = row.predicate == null ? '' : String(row.predicate);
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
- table: `${row.schema}.${row.table}`,
310
- index: { name: row.name, columns, unique: coerceBool(row.is_unique), ...(pred ? { where: pred } : {}) },
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
  }
@@ -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
- for (const model of models) {
274
- for (const f of Object.values(model.fields)) {
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 header = [
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
- ].join('\n');
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
  }
@@ -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 {