@everystack/cli 0.4.99 → 0.4.102

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.
Files changed (48) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/package.json +3 -3
  3. package/src/cli/apply-execute.ts +15 -1
  4. package/src/cli/authz-baseline.ts +8 -4
  5. package/src/cli/authz-compile.ts +61 -7
  6. package/src/cli/authz-contract.ts +23 -14
  7. package/src/cli/authz-derive.ts +282 -30
  8. package/src/cli/authz-reconcile.ts +6 -4
  9. package/src/cli/catalog-rename.ts +115 -0
  10. package/src/cli/commands/db-check.ts +40 -0
  11. package/src/cli/commands/db-generate.ts +10 -9
  12. package/src/cli/commands/db-plan.ts +30 -13
  13. package/src/cli/commands/db-pull.ts +153 -63
  14. package/src/cli/commands/db-reconcile.ts +22 -3
  15. package/src/cli/commands/db-sync.ts +6 -5
  16. package/src/cli/commands/db.ts +91 -8
  17. package/src/cli/commands/update.ts +3 -2
  18. package/src/cli/db-build.ts +1 -0
  19. package/src/cli/declared-derived.ts +3 -1
  20. package/src/cli/declared-diff.ts +4 -1
  21. package/src/cli/derived-apply.ts +116 -10
  22. package/src/cli/derived-grants.ts +4 -2
  23. package/src/cli/derived-introspect.ts +23 -3
  24. package/src/cli/derived-plan.ts +37 -1
  25. package/src/cli/derived-render.ts +33 -34
  26. package/src/cli/edge-plan.ts +57 -9
  27. package/src/cli/generated-artifacts.ts +22 -0
  28. package/src/cli/generated-names.ts +108 -0
  29. package/src/cli/host-url.ts +48 -27
  30. package/src/cli/index.ts +5 -5
  31. package/src/cli/lifecycle-canon.ts +37 -0
  32. package/src/cli/lifecycle-derived.ts +721 -0
  33. package/src/cli/lifecycle-pull.ts +177 -0
  34. package/src/cli/lifecycle-verify.ts +232 -0
  35. package/src/cli/migration-compile.ts +8 -3
  36. package/src/cli/migration-generate.ts +23 -3
  37. package/src/cli/model-map.ts +255 -0
  38. package/src/cli/model-render.ts +243 -77
  39. package/src/cli/plain-output.ts +198 -10
  40. package/src/cli/runbook/model-section.ts +8 -5
  41. package/src/cli/schema-compile.ts +57 -18
  42. package/src/cli/schema-diff.ts +184 -41
  43. package/src/cli/schema-fingerprint.ts +39 -6
  44. package/src/cli/schema-introspect.ts +66 -2
  45. package/src/cli/schema-source.ts +40 -9
  46. package/src/cli/schema-swap.ts +3 -2
  47. package/src/cli/state-apply.ts +10 -4
  48. package/src/cli/ts-template.ts +52 -0
@@ -16,17 +16,28 @@
16
16
  * - Cross-schema FKs (e.g. `profiles.id → auth.users.id`) are OMITTED — the model
17
17
  * can't express a reference to a non-Model table, so neither can the emitter.
18
18
  * - `relationName` strings are synthesized deterministically (the `Relation` type
19
- * carries no name); only the pairing of a belongsTo with its inverse hasMany
20
- * matters, which the synthesis guarantees.
19
+ * carries no name); only the pairing of a belongsTo with its inverse hasMany or
20
+ * hasOne matters, which the synthesis guarantees. A hasOne renders as `one(target)`.
21
21
  *
22
22
  * Pure and deterministic: imports sorted, declarations in model order, relation
23
23
  * keys in declared order — re-running on the same models is byte-identical.
24
24
  *
25
- * Input is a `@everystack/model` ModelDescriptor (type-only — no runtime dep).
25
+ * Input is a `@everystack/model` ModelDescriptor. The one runtime import is
26
+ * `relationDeleteActions`: a parent's hasMany `dependent` sets a child FK's onDelete, derived
27
+ * from the set passed in (pure; no spec is written).
26
28
  */
27
29
 
28
- import type { ModelDescriptor, FieldSpec, DerivedDescriptor, ViewDescriptor, MaterializedViewDescriptor } from '@everystack/model';
30
+ import { relationDeleteActions, type ModelDescriptor, type ReferentialAction, type Relation, type FieldSpec, type DerivedDescriptor, type ViewDescriptor, type MaterializedViewDescriptor } from '@everystack/model';
29
31
  import { parseQualified } from './derived-source';
32
+ import { commentText } from './ts-template';
33
+
34
+ /**
35
+ * Drizzle relations carry no order, limit or filter, so `db.query.x.findMany({ with })` ignores a
36
+ * relation's display options and a hasOne returns any one matching row. Each such relation gets
37
+ * one note above it; a relation that declares none gets nothing (D8: byte-identical).
38
+ */
39
+ const DISPLAY_NOTE = 'display options (where/limit/orderBy) are not applied by drizzle `with`: filter in the query';
40
+ const HAS_ONE_NOTE = 'hasOne: drizzle `with` returns any one matching row (orderBy and where are not applied): order and filter in the query';
30
41
 
31
42
  /** `author_id` is the SQL identifier; `authorId` the field key. The single snake-case rule. */
32
43
  function toSnakeCase(name: string): string {
@@ -182,7 +193,7 @@ function columnModifiers(
182
193
  spec: FieldSpec,
183
194
  isComposite: boolean,
184
195
  modelsByDescriptor: Map<ModelDescriptor, { camel: string }>,
185
- ctx: { builder?: string; self?: ModelDescriptor; usedTypes?: Set<string> } = {},
196
+ ctx: { builder?: string; self?: ModelDescriptor; usedTypes?: Set<string>; onDelete?: ReferentialAction } = {},
186
197
  ): string {
187
198
  let s = '';
188
199
  if (spec.isArray) s += '.array()';
@@ -208,7 +219,9 @@ function columnModifiers(
208
219
  if (entry) {
209
220
  const parentPk = primaryKeyKey(target);
210
221
  const opts: string[] = [];
211
- if (spec.onDelete) opts.push(`onDelete: ${strLiteral(spec.onDelete)}`);
222
+ // A parent's hasMany `dependent` (ctx.onDelete, derived from the set) or the child's own.
223
+ const onDelete = ctx.onDelete ?? spec.onDelete;
224
+ if (onDelete) opts.push(`onDelete: ${strLiteral(onDelete)}`);
212
225
  if (spec.onUpdate) opts.push(`onUpdate: ${strLiteral(spec.onUpdate)}`);
213
226
  const optsText = opts.length ? `, { ${opts.join(', ')} }` : '';
214
227
  // A SELF-referential FK needs drizzle's documented return-type annotation. Without it
@@ -302,7 +315,7 @@ function planRelationNames(models: ModelDescriptor[]): RelationNaming {
302
315
  /** The `relationName: '…'` fragment for a relation, or '' when the pair is unambiguous. */
303
316
  function relationNameFragment(
304
317
  model: ModelDescriptor,
305
- rel: { kind: 'belongsTo' | 'hasMany'; column: string; target: () => ModelDescriptor },
318
+ rel: Pick<Relation, 'kind' | 'column' | 'target'>,
306
319
  naming: RelationNaming,
307
320
  ): string {
308
321
  const other = rel.target().table;
@@ -348,6 +361,8 @@ export function compileDrizzleSource(allModels: ModelDescriptor[], _opts: Compil
348
361
  // generated schema. (The migration generator still emits its SQL + authz.) The
349
362
  // command and the drift guard both call this, so filtering here keeps them in sync.
350
363
  const models = allModels.filter((m) => !m.private);
364
+ // Over the whole set, private models included: a hasMany `dependent` sets a child FK's ON DELETE.
365
+ const dependents = relationDeleteActions(allModels);
351
366
  const derivedRelations = typedRelations(_opts.derived ?? []);
352
367
 
353
368
  const modelsByDescriptor = new Map<ModelDescriptor, { camel: string }>();
@@ -396,7 +411,7 @@ export function compileDrizzleSource(allModels: ModelDescriptor[], _opts: Compil
396
411
  const { call, builder: pgBuilder } = baseColumnSource(toSnakeCase(key), spec);
397
412
  pgCoreBuilders.add(pgBuilder);
398
413
  if (spec.defaultKind === 'sql') pgCoreBuilders.add('sql'); // imported from 'drizzle-orm', handled below
399
- const mods = columnModifiers(spec, isComposite, modelsByDescriptor, { builder: pgBuilder, self: model, usedTypes: pgCoreTypes });
414
+ const mods = columnModifiers(spec, isComposite, modelsByDescriptor, { builder: pgBuilder, self: model, usedTypes: pgCoreTypes, onDelete: dependents.get(model)?.get(key) });
400
415
  if (spec.isDeprecated) {
401
416
  // The contract phase (decision 13): the column stays in the database and
402
417
  // stays readable; the strikethrough warns new code away at author time.
@@ -452,7 +467,7 @@ export function compileDrizzleSource(allModels: ModelDescriptor[], _opts: Compil
452
467
  if (relEntries.length === 0) continue;
453
468
  const camel = modelCamel(model);
454
469
 
455
- const usesOne = relEntries.some(([, r]) => r.kind === 'belongsTo');
470
+ const usesOne = relEntries.some(([, r]) => r.kind === 'belongsTo' || r.kind === 'hasOne');
456
471
  const usesMany = relEntries.some(([, r]) => r.kind === 'hasMany');
457
472
  const helpers = [usesOne ? 'one' : null, usesMany ? 'many' : null].filter(Boolean).join(', ');
458
473
 
@@ -461,6 +476,8 @@ export function compileDrizzleSource(allModels: ModelDescriptor[], _opts: Compil
461
476
  const target = rel.target();
462
477
  const targetCamel = modelCamel(target);
463
478
  const nameFrag = relationNameFragment(model, rel, relationNaming);
479
+ if (rel.kind === 'hasOne') lines.push(` // ${commentText(HAS_ONE_NOTE)}`);
480
+ else if (rel.orderBy !== undefined || rel.limit !== undefined || rel.where !== undefined) lines.push(` // ${commentText(DISPLAY_NOTE)}`);
464
481
  if (rel.kind === 'belongsTo') {
465
482
  // belongsTo(() => Target, column, references?) -> one(...)
466
483
  const thisCol = rel.column; // FK FIELD KEY on this model
@@ -468,6 +485,20 @@ export function compileDrizzleSource(allModels: ModelDescriptor[], _opts: Compil
468
485
  lines.push(
469
486
  ` ${key}: one(${targetCamel}, { fields: [${camel}.${thisCol}], references: [${targetCamel}.${targetPk}]${nameFrag} }),`,
470
487
  );
488
+ } else if (rel.kind === 'hasOne') {
489
+ // hasOne(() => Target, column) -> one(Target): the inverse of the target's belongsTo, as
490
+ // many(Target) is for a hasMany. Drizzle takes fields/references from that belongsTo, and
491
+ // a one() with no fields is typed nullable (a parent may have no such row). Its orderBy
492
+ // and where are display options with no drizzle form; F10 applies them per query.
493
+ // Drizzle's one() config requires fields, so a relationName alone is not an option: a
494
+ // pair joined by more than one FK is refused by name rather than emitted broken.
495
+ if (nameFrag) {
496
+ const at = model.schema === 'public' ? model.table : `${model.schema}.${model.table}`;
497
+ throw new Error(
498
+ `model '${at}': relation '${key}': a hasOne between two tables joined by more than one foreign key is not supported yet (${model.table} and ${target.table})`,
499
+ );
500
+ }
501
+ lines.push(` ${key}: one(${targetCamel}),`);
471
502
  } else {
472
503
  // hasMany(() => Target, column, references?) -> many(...)
473
504
  if (nameFrag) {
@@ -30,7 +30,7 @@
30
30
  * so the swap is a pointer flip, never a recompute.
31
31
  */
32
32
 
33
- import type { ModelDescriptor } from '@everystack/model';
33
+ import { relationDeleteActions, type ModelDescriptor } from '@everystack/model';
34
34
  import { modelForeignKeys, qualifiedTable } from './schema-compile';
35
35
  import { compileTableContract } from './authz-compile';
36
36
  import { emitSwapAuthzSql } from './authz-reconcile';
@@ -73,10 +73,11 @@ export interface CrossSchemaFk {
73
73
  */
74
74
  export function crossSchemaForeignKeys(models: ModelDescriptor[], targetSchema: string): CrossSchemaFk[] {
75
75
  const out: CrossSchemaFk[] = [];
76
+ const dependents = relationDeleteActions(models);
76
77
  for (const model of models) {
77
78
  if ((model.schema || 'public') === targetSchema) continue; // internal edge, not external
78
79
  const from = qualifiedTable(model);
79
- for (const fk of modelForeignKeys(model)) {
80
+ for (const fk of modelForeignKeys(model, dependents)) {
80
81
  if (schemaOf(fk.refTable) !== targetSchema) continue; // not pointing into the swapped schema
81
82
  const cols = fk.columns.map((c) => `"${c}"`).join(', ');
82
83
  const refCols = fk.refColumns.map((c) => `"${c}"`).join(', ');
@@ -20,7 +20,7 @@
20
20
  import { execSync, execFileSync } from 'node:child_process';
21
21
  import type { ModelDescriptor } from '@everystack/model';
22
22
  import { introspectContract, type AuthzContract, type QueryRunner } from './authz-contract';
23
- import { borrowedSessionRunner, type SessionRunner } from './session';
23
+ import { borrowedSessionRunner, CANONICAL_SEARCH_PATH, type SessionRunner } from './session';
24
24
  import { introspectSchema, type SchemaSnapshot } from './schema-introspect';
25
25
  import { FUNCTIONS_SQL, contractFunctionRow } from './security-catalog';
26
26
  import { generateMigrationSql, HELD_DROP_PREFIX } from './migration-generate';
@@ -572,9 +572,14 @@ export async function applyGeneratedStatements(
572
572
  // point; they join the row on the amendment, where their outcome is known.
573
573
  const prerequisiteSql = prerequisites
574
574
  .flatMap((prerequisite) => [renderStatePrerequisiteGuard(prerequisite), prerequisite.sql]);
575
+ // The plan is rendered against the canonical path — public bare, non-public qualified — so it
576
+ // must RUN under it. Under an operator path like `analytics, public`, a bare
577
+ // `CREATE TYPE "post_status"` would land in analytics. Transaction-local and inside the
578
+ // batch's own string, so it can neither be skipped nor outlive the batch.
579
+ const stateBatch = batch.length > 0 ? [renderLocalSearchPath(CANONICAL_SEARCH_PATH), ...batch] : [];
575
580
  // A SQL-language prerequisite may read a table this same state edge creates. PostgreSQL
576
581
  // validates such bodies at CREATE time unless this transaction-local setting defers it.
577
- let batchSql = [...(prerequisites.length > 0 ? ['SET LOCAL check_function_bodies = off'] : []), ...prerequisiteSql, ...batch].join('\n;\n');
582
+ let batchSql = [...(prerequisites.length > 0 ? ['SET LOCAL check_function_bodies = off'] : []), ...prerequisiteSql, ...stateBatch].join('\n;\n');
578
583
  const validationSql = validations.join('\n;\n');
579
584
  const started = now();
580
585
  try {
@@ -587,13 +592,14 @@ export async function applyGeneratedStatements(
587
592
  const originalPath = settings?.search_path;
588
593
  if (typeof originalPath !== 'string') throw new Error('could not capture prerequisite search_path; no state was applied.');
589
594
  // Defaults and SQL-standard bodies bind names at CREATE time. Use the full declared
590
- // path recorded at mint, then restore the exact connection setting before state DDL.
595
+ // path recorded at mint, then restore the exact connection setting; the state DDL
596
+ // that follows pins the canonical path itself.
591
597
  batchSql = ['SET LOCAL check_function_bodies = off', ...prerequisites.flatMap((prerequisite) => [
592
598
  renderStatePrerequisiteGuard(prerequisite),
593
599
  ...(prerequisite.searchPath === null ? [] : [renderLocalSearchPath(prerequisite.searchPath)]),
594
600
  prerequisite.sql,
595
601
  ...(prerequisite.searchPath === null ? [] : [renderLocalSearchPath(originalPath)]),
596
- ]), ...batch].join('\n;\n');
602
+ ]), ...stateBatch].join('\n;\n');
597
603
  await transactionRunner(batchSql);
598
604
  // `defHash` is PostgreSQL's canonical deparse, so source text cannot predict it. Read it
599
605
  // after the CREATE through the OPEN transaction, then write provenance before COMMIT.
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Escape live text for the body of a template literal in generated source, so the template
3
+ * evaluates back to EXACTLY that text: backslash first, then backtick, then `${`. Live database
4
+ * text is data. Unescaped, `${…}` in it ran when the generated file was imported, and a
5
+ * backslash before a backtick closed the template. Every renderer that puts live text inside
6
+ * sql`…` uses this one rule.
7
+ */
8
+ export function templateBody(text: string): string {
9
+ return text.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${');
10
+ }
11
+
12
+ /**
13
+ * Live text as a single-quoted string literal in generated source, evaluating back to EXACTLY
14
+ * that text. Backslash first, then the quote, then every line terminator: a raw newline or
15
+ * carriage return ends a string literal, so a live name holding one broke the generated file.
16
+ * U+2028 and U+2029 are legal inside a string since ES2019 but not in every older parser, so
17
+ * they are escaped too. Ordinary text comes out unchanged.
18
+ */
19
+ export function tsString(text: string): string {
20
+ const body = text
21
+ .replace(/\\/g, '\\\\')
22
+ .replace(/'/g, "\\'")
23
+ .replace(/\n/g, '\\n')
24
+ .replace(/\r/g, '\\r')
25
+ .replace(/\u2028/g, '\\u2028')
26
+ .replace(/\u2029/g, '\\u2029');
27
+ return `'${body}'`;
28
+ }
29
+
30
+ /**
31
+ * Live text bound for a `//` line comment in generated source. JavaScript ends a line comment
32
+ * at ANY line terminator (LF, CR, U+2028, U+2029), so a live name holding one ended the comment
33
+ * and the rest of the name ran as code. Each terminator becomes its visible escape sequence
34
+ * (`\n`, `\r`, `\u2028`, `\u2029`): the comment stays one line, and a reader still sees that
35
+ * the live name held a line break. Ordinary text comes out unchanged.
36
+ */
37
+ export function commentText(text: string): string {
38
+ return text
39
+ .replace(/\n/g, '\\n')
40
+ .replace(/\r/g, '\\r')
41
+ .replace(/\u2028/g, '\\u2028')
42
+ .replace(/\u2029/g, '\\u2029');
43
+ }
44
+
45
+ /**
46
+ * Live text that is MEANT to span several comment lines (a deparsed predicate in a note): split
47
+ * on every line terminator, so each piece gets its own `//`. Splitting on `\n` alone left a CR,
48
+ * U+2028 or U+2029 inside one line, which still ended the comment.
49
+ */
50
+ export function commentLines(text: string): string[] {
51
+ return text.split(/\r\n|[\n\r\u2028\u2029]/);
52
+ }