@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.
- package/CHANGELOG.md +105 -0
- package/package.json +3 -3
- package/src/cli/apply-execute.ts +15 -1
- package/src/cli/authz-baseline.ts +8 -4
- package/src/cli/authz-compile.ts +61 -7
- package/src/cli/authz-contract.ts +23 -14
- package/src/cli/authz-derive.ts +282 -30
- package/src/cli/authz-reconcile.ts +6 -4
- package/src/cli/catalog-rename.ts +115 -0
- package/src/cli/commands/db-check.ts +40 -0
- package/src/cli/commands/db-generate.ts +10 -9
- package/src/cli/commands/db-plan.ts +30 -13
- package/src/cli/commands/db-pull.ts +153 -63
- package/src/cli/commands/db-reconcile.ts +22 -3
- package/src/cli/commands/db-sync.ts +6 -5
- package/src/cli/commands/db.ts +91 -8
- package/src/cli/commands/update.ts +3 -2
- package/src/cli/db-build.ts +1 -0
- package/src/cli/declared-derived.ts +3 -1
- package/src/cli/declared-diff.ts +4 -1
- package/src/cli/derived-apply.ts +116 -10
- package/src/cli/derived-grants.ts +4 -2
- package/src/cli/derived-introspect.ts +23 -3
- package/src/cli/derived-plan.ts +37 -1
- package/src/cli/derived-render.ts +33 -34
- package/src/cli/edge-plan.ts +57 -9
- package/src/cli/generated-artifacts.ts +22 -0
- package/src/cli/generated-names.ts +108 -0
- package/src/cli/host-url.ts +48 -27
- package/src/cli/index.ts +5 -5
- package/src/cli/lifecycle-canon.ts +37 -0
- package/src/cli/lifecycle-derived.ts +721 -0
- package/src/cli/lifecycle-pull.ts +177 -0
- package/src/cli/lifecycle-verify.ts +232 -0
- package/src/cli/migration-compile.ts +8 -3
- package/src/cli/migration-generate.ts +23 -3
- package/src/cli/model-map.ts +255 -0
- package/src/cli/model-render.ts +243 -77
- package/src/cli/plain-output.ts +198 -10
- package/src/cli/runbook/model-section.ts +8 -5
- package/src/cli/schema-compile.ts +57 -18
- package/src/cli/schema-diff.ts +184 -41
- package/src/cli/schema-fingerprint.ts +39 -6
- package/src/cli/schema-introspect.ts +66 -2
- package/src/cli/schema-source.ts +40 -9
- package/src/cli/schema-swap.ts +3 -2
- package/src/cli/state-apply.ts +10 -4
- package/src/cli/ts-template.ts +52 -0
package/src/cli/plain-output.ts
CHANGED
|
@@ -14,6 +14,13 @@
|
|
|
14
14
|
* - something is no longer true — dropped, revoked.
|
|
15
15
|
* ⚠ DESTRUCTIVE real data can be lost. Orthogonal to the other two: its own line, never
|
|
16
16
|
* combined with + or -.
|
|
17
|
+
* ⚠ ACCESS db:plan only: one summary line when the plan removes a policy, revokes a
|
|
18
|
+
* grant, turns row security off, or removes a function, view or trigger. Not destructive (no --confirm), but such a
|
|
19
|
+
* plan is never called "Safe to apply".
|
|
20
|
+
* ⚠ CONSTRAINT db:plan only: one line per primary key the plan replaces, naming the old key.
|
|
21
|
+
* Read from the replacement note the emitter writes from the change itself
|
|
22
|
+
* (schema-diff's parsePrimaryKeyReplacement), never from the DDL. Not
|
|
23
|
+
* destructive, but never called "Safe to apply" either.
|
|
17
24
|
*
|
|
18
25
|
* And a banned list (BANNED_DEFAULT_WORDS): words that mean something to us and nothing to the
|
|
19
26
|
* reader. Nothing is deleted — the full internal report is exactly as it was, behind --verbose.
|
|
@@ -28,6 +35,9 @@
|
|
|
28
35
|
*/
|
|
29
36
|
|
|
30
37
|
import { statementBody } from './sql-statement';
|
|
38
|
+
import { parsePrimaryKeyReplacement } from './schema-diff';
|
|
39
|
+
import type { ReconcileAction, ReconcilePlan } from './derived-plan';
|
|
40
|
+
import type { DerivedKind } from './derived-source';
|
|
31
41
|
|
|
32
42
|
// ---------------------------------------------------------------------------
|
|
33
43
|
// The banned vocabulary.
|
|
@@ -168,7 +178,12 @@ export function describeChange(statement: string, opts: DescribeOptions = {}): P
|
|
|
168
178
|
return { symbol: '-', text: `remove column ${table}.${unquote(a[1])}` };
|
|
169
179
|
}
|
|
170
180
|
if ((a = /^ADD\s+CONSTRAINT\s+("[^"]+"|[\w$]+)/i.exec(rest))) {
|
|
171
|
-
|
|
181
|
+
// A foreign key names what it points at, always as schema.table: the reviewer must see
|
|
182
|
+
// when a key reaches into a table no model declares (another system's auth.users).
|
|
183
|
+
const ref = /\sFOREIGN\s+KEY\s*\([^)]*\)\s*REFERENCES\s+((?:"[^"]*"|[^\s("]+)(?:\.(?:"[^"]*"|[^\s("]+))?)/i.exec(rest);
|
|
184
|
+
const target = ref ? unquote(ref[1]) : null;
|
|
185
|
+
const referencing = target ? ` referencing ${target.includes('.') ? target : `public.${target}`}` : '';
|
|
186
|
+
return { symbol: '+', text: `add constraint ${unquote(a[1])} on ${table}${referencing}` };
|
|
172
187
|
}
|
|
173
188
|
if ((a = /^DROP\s+CONSTRAINT\s+(?:IF\s+EXISTS\s+)?("[^"]+"|[\w$]+)/i.exec(rest))) {
|
|
174
189
|
return { symbol: '-', text: `remove constraint ${unquote(a[1])} on ${table}` };
|
|
@@ -420,25 +435,198 @@ export interface PlanReportInput {
|
|
|
420
435
|
planPath: string | null;
|
|
421
436
|
/** --verbose: the promise behind "(--verbose for the full list)" — show every line, uncapped. */
|
|
422
437
|
verbose?: boolean;
|
|
438
|
+
/**
|
|
439
|
+
* The function/view/trigger reconcile db:apply runs after the plan (planReconcile's output).
|
|
440
|
+
* Absent, or with nothing that changes an object, the report is byte-identical to one without it.
|
|
441
|
+
*/
|
|
442
|
+
derived?: Pick<ReconcilePlan, 'actions' | 'regrants'>;
|
|
423
443
|
}
|
|
424
444
|
|
|
445
|
+
// ---------------------------------------------------------------------------
|
|
446
|
+
// The functions, views and triggers db:apply reconciles after the plan.
|
|
447
|
+
// ---------------------------------------------------------------------------
|
|
448
|
+
|
|
449
|
+
/** The reader's word for each kind. A matview is a view to the reader, as it is above. */
|
|
450
|
+
const DERIVED_KIND_WORD: Record<DerivedKind, string> = {
|
|
451
|
+
function: 'function', view: 'view', 'materialized view': 'view', trigger: 'trigger', sql: 'object',
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
/** `public.posts.posts_touch` (a trigger's identity) → `posts_touch on posts`; anything else → rel(). */
|
|
455
|
+
function derivedName(a: Pick<ReconcileAction, 'identity' | 'kind'>): string {
|
|
456
|
+
if (a.kind === 'trigger') {
|
|
457
|
+
const dot = a.identity.lastIndexOf('.');
|
|
458
|
+
if (dot > 0) return `${a.identity.slice(dot + 1)} on ${rel(a.identity.slice(0, dot))}`;
|
|
459
|
+
}
|
|
460
|
+
return rel(a.identity);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* The reconcile plan db:apply will run, as plain change lines — one per OBJECT, never per
|
|
465
|
+
* statement, so the reader sees what changes rather than how the DDL has to be written.
|
|
466
|
+
*
|
|
467
|
+
* It reads `planReconcile`'s output and decides nothing: db:plan builds that plan with the same
|
|
468
|
+
* declared objects and the same options db:apply's catch-up passes to `executeReconcile`, so the
|
|
469
|
+
* list is what the apply will do. A rebuild (the drop and the create of one identity) is ONE
|
|
470
|
+
* `+ update` line, as it is for the table edge. Bookkeeping that changes no object — baseline,
|
|
471
|
+
* rebaseline, backfill, prune — is left out; db:apply's catch-up never asks for the first two.
|
|
472
|
+
*
|
|
473
|
+
* `statements` carries the GRANT/REVOKE SQL behind the grant lines, so the access count reads the
|
|
474
|
+
* same statements the lines describe. `removed` names the kind of every object that is dropped
|
|
475
|
+
* and NOT rebuilt: such an object can enforce access (a SECURITY DEFINER function, a soft-delete
|
|
476
|
+
* trigger, a definer view), and dropping a function takes its EXECUTE grants with it — no REVOKE
|
|
477
|
+
* statement exists for the count to see.
|
|
478
|
+
*/
|
|
479
|
+
export function describeDerivedChanges(plan: Pick<ReconcilePlan, 'actions' | 'regrants'>): {
|
|
480
|
+
changes: PlainChange[];
|
|
481
|
+
statements: string[];
|
|
482
|
+
removed: string[];
|
|
483
|
+
} {
|
|
484
|
+
const created = new Set(plan.actions.filter((a) => a.action === 'create').map((a) => a.identity));
|
|
485
|
+
const rebuilt = new Set(plan.actions.filter((a) => a.action === 'drop' && created.has(a.identity)).map((a) => a.identity));
|
|
486
|
+
const changes: PlainChange[] = [];
|
|
487
|
+
const statements: string[] = [];
|
|
488
|
+
const removed: string[] = [];
|
|
489
|
+
const grantLines = (sqls: readonly string[]): void => {
|
|
490
|
+
for (const s of sqls) {
|
|
491
|
+
statements.push(s);
|
|
492
|
+
changes.push(describeChange(s));
|
|
493
|
+
}
|
|
494
|
+
};
|
|
495
|
+
for (const a of plan.actions) {
|
|
496
|
+
const what = `${DERIVED_KIND_WORD[a.kind] ?? 'object'} ${derivedName(a)}`;
|
|
497
|
+
switch (a.action) {
|
|
498
|
+
case 'drop':
|
|
499
|
+
if (!rebuilt.has(a.identity)) {
|
|
500
|
+
changes.push({ symbol: '-', text: `remove ${what}` });
|
|
501
|
+
removed.push(DERIVED_KIND_WORD[a.kind] ?? 'object');
|
|
502
|
+
}
|
|
503
|
+
break;
|
|
504
|
+
case 'create':
|
|
505
|
+
changes.push(rebuilt.has(a.identity)
|
|
506
|
+
? { symbol: '+', text: `update ${what}: ${a.reason.startsWith('source changed') ? 'definition updated' : 'rebuilt'}` }
|
|
507
|
+
: { symbol: '+', text: `add ${what}` });
|
|
508
|
+
break;
|
|
509
|
+
case 'replace':
|
|
510
|
+
changes.push({ symbol: '+', text: `update ${what}: definition updated` });
|
|
511
|
+
grantLines(a.revokes ?? []);
|
|
512
|
+
break;
|
|
513
|
+
case 'refresh':
|
|
514
|
+
changes.push({ symbol: '+', text: `refresh ${what}` });
|
|
515
|
+
break;
|
|
516
|
+
default:
|
|
517
|
+
break;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
for (const r of plan.regrants) grantLines(r.statements);
|
|
521
|
+
return { changes, statements, removed };
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// ---------------------------------------------------------------------------
|
|
525
|
+
// Access: what a plan takes away, which is not the same as what it destroys.
|
|
526
|
+
// ---------------------------------------------------------------------------
|
|
527
|
+
|
|
528
|
+
export interface AccessChanges {
|
|
529
|
+
/** DROP POLICY statements. */
|
|
530
|
+
policies: number;
|
|
531
|
+
/** REVOKE statements. */
|
|
532
|
+
revokes: number;
|
|
533
|
+
/** Distinct tables with DISABLE / NO FORCE ROW LEVEL SECURITY (one table can get both). */
|
|
534
|
+
rowSecurityOff: number;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Count the statements that change who can read or write: a removed policy, a revoked grant,
|
|
539
|
+
* row security turned off. Read from the statement BODY (sql-statement's reading), so a
|
|
540
|
+
* `-- WARNING:` prologue that mentions a REVOKE does not count and one hiding a REVOKE does.
|
|
541
|
+
*
|
|
542
|
+
* NOT a destructive class. DESTRUCTIVE means data can be lost and gates --confirm, a snapshot
|
|
543
|
+
* and the stage refusal; none of those fit a policy change. This only decides the wording.
|
|
544
|
+
*/
|
|
545
|
+
export function accessChanges(statements: readonly string[]): AccessChanges {
|
|
546
|
+
const out: AccessChanges = { policies: 0, revokes: 0, rowSecurityOff: 0 };
|
|
547
|
+
const rlsOff = new Set<string>();
|
|
548
|
+
let m: RegExpExecArray | null;
|
|
549
|
+
for (const statement of statements) {
|
|
550
|
+
const s = head(statement);
|
|
551
|
+
if (/^DROP\s+POLICY\b/i.test(s)) out.policies++;
|
|
552
|
+
else if (/^REVOKE\b/i.test(s)) out.revokes++;
|
|
553
|
+
else if ((m = /^ALTER\s+TABLE\s+(?:ONLY\s+)?(\S+)\s[\s\S]*\b(?:DISABLE|NO\s+FORCE)\s+ROW\s+LEVEL\s+SECURITY\b/i.exec(s))) rlsOff.add(rel(m[1]));
|
|
554
|
+
}
|
|
555
|
+
out.rowSecurityOff = rlsOff.size;
|
|
556
|
+
return out;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** The order removed things are named in, after policies. */
|
|
560
|
+
const REMOVED_KIND_ORDER = ['function', 'trigger', 'view', 'object'];
|
|
561
|
+
|
|
562
|
+
/** The ⚠ ACCESS line, or null when the plan takes no access away. */
|
|
563
|
+
function accessLine(a: AccessChanges, removedKinds: readonly string[] = []): string | null {
|
|
564
|
+
const removed = [
|
|
565
|
+
...(a.policies ? [`${a.policies} ${plural(a.policies, 'policy', 'policies')}`] : []),
|
|
566
|
+
...REMOVED_KIND_ORDER.flatMap((kind) => {
|
|
567
|
+
const n = removedKinds.filter((k) => k === kind).length;
|
|
568
|
+
return n ? [`${n} ${plural(n, kind, `${kind}s`)}`] : [];
|
|
569
|
+
}),
|
|
570
|
+
];
|
|
571
|
+
const parts = [
|
|
572
|
+
...(removed.length ? [`removes ${removed.join(', ')}`] : []),
|
|
573
|
+
...(a.revokes ? [`revokes ${a.revokes} ${plural(a.revokes, 'grant', 'grants')}`] : []),
|
|
574
|
+
...(a.rowSecurityOff ? [`turns off row security on ${a.rowSecurityOff} ${plural(a.rowSecurityOff, 'table', 'tables')}`] : []),
|
|
575
|
+
];
|
|
576
|
+
return parts.length === 0 ? null : `${ACCESS_MARK} — ${parts.join(', ')}. Check who can still read and write.`;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const ACCESS_MARK = '⚠ ACCESS';
|
|
580
|
+
|
|
581
|
+
/** The ⚠ CONSTRAINT lines: one per primary key the plan replaces. */
|
|
582
|
+
function constraintLines(statements: readonly string[]): string[] {
|
|
583
|
+
return statements.flatMap((statement) => {
|
|
584
|
+
const r = parsePrimaryKeyReplacement(statement);
|
|
585
|
+
return r ? [`${CONSTRAINT_MARK} — primary key ${r.name} on ${rel(r.table)} is replaced by (${r.columns.join(', ')}). A foreign key on the old key fails the apply.`] : [];
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const CONSTRAINT_MARK = '⚠ CONSTRAINT';
|
|
590
|
+
|
|
425
591
|
/** db:plan's default output — the whole thing. */
|
|
426
592
|
export function renderPlanReport(input: PlanReportInput): string[] {
|
|
427
593
|
const { executable, target, stage, planPath, verbose = false } = input;
|
|
428
|
-
|
|
594
|
+
const derived = input.derived ? describeDerivedChanges(input.derived) : { changes: [], statements: [], removed: [] };
|
|
595
|
+
if (executable.length === 0 && derived.changes.length === 0) return ['Up to date. Nothing to plan.'];
|
|
429
596
|
|
|
597
|
+
const limit = (n: number): number => (verbose ? n : MAX_LISTED_CHANGES);
|
|
430
598
|
const destructive = new Set(input.destructive.map(statementKey));
|
|
431
|
-
const lines: string[] =
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
599
|
+
const lines: string[] = executable.length > 0
|
|
600
|
+
? [
|
|
601
|
+
`Plan: ${executable.length} ${plural(executable.length, 'change', 'changes')} (${target})`,
|
|
602
|
+
'',
|
|
603
|
+
...changeLines(executable, destructive, limit(executable.length)),
|
|
604
|
+
'',
|
|
605
|
+
]
|
|
606
|
+
: [`Plan: no table, column or grant changes (${target})`, ''];
|
|
607
|
+
|
|
608
|
+
// Not in the plan file, by design: db:apply reads these from the checkout at apply time and
|
|
609
|
+
// they go stale between mint and apply (see db-plan.ts). The header says so, so the file is
|
|
610
|
+
// never read as the whole change.
|
|
611
|
+
if (derived.changes.length > 0) {
|
|
612
|
+
const shown = derived.changes.slice(0, limit(derived.changes.length)).map((c) => ` ${c.symbol} ${c.text}`);
|
|
613
|
+
const hidden = derived.changes.length - shown.length;
|
|
614
|
+
if (hidden > 0) shown.push(` … and ${hidden} more (--verbose for the full list)`);
|
|
615
|
+
lines.push(`Also on db:apply (from the checkout, not in ${planPath ?? 'the plan'}):`, '', ...shown, '');
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const keys = constraintLines(executable);
|
|
619
|
+
lines.push(...keys);
|
|
620
|
+
const access = accessLine(accessChanges([...executable, ...derived.statements]), derived.removed);
|
|
621
|
+
if (access) lines.push(access);
|
|
438
622
|
lines.push(
|
|
439
623
|
destructive.size > 0
|
|
440
624
|
? `Verify the destructive ${plural(destructive.size, 'change', 'changes')}, then run db:backup before applying.`
|
|
441
|
-
:
|
|
625
|
+
: access
|
|
626
|
+
? 'Non-destructive. Verify the access changes before applying.'
|
|
627
|
+
: keys.length > 0
|
|
628
|
+
? 'Non-destructive. Verify the replaced keys before applying.'
|
|
629
|
+
: 'Non-destructive. Safe to apply.',
|
|
442
630
|
);
|
|
443
631
|
|
|
444
632
|
// No file, no command to give — `--out -` piped the plan somewhere only the caller knows.
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { ModelLinkError } from '@everystack/model';
|
|
11
|
-
import type
|
|
11
|
+
import { relationDeleteActions, type Ability, type FieldSpec, type ModelDescriptor, type ReferentialAction, type Relation } from '@everystack/model';
|
|
12
12
|
|
|
13
13
|
function typeLabel(spec: FieldSpec): string {
|
|
14
14
|
let base: string;
|
|
@@ -42,14 +42,15 @@ function referenceTarget(spec: FieldSpec): string | null {
|
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
function fieldLine(name: string, spec: FieldSpec): string {
|
|
45
|
+
function fieldLine(name: string, spec: FieldSpec, derivedOnDelete?: ReferentialAction): string {
|
|
46
|
+
const onDelete = derivedOnDelete ?? spec.onDelete;
|
|
46
47
|
const parts = [typeLabel(spec)];
|
|
47
48
|
if (spec.isPrimaryKey) parts.push('pk');
|
|
48
49
|
if (spec.isNotNull && !spec.isPrimaryKey) parts.push('not null');
|
|
49
50
|
if (spec.isUnique) parts.push('unique');
|
|
50
51
|
const target = referenceTarget(spec);
|
|
51
52
|
if (target) {
|
|
52
|
-
parts.push(`→ ${target}${
|
|
53
|
+
parts.push(`→ ${target}${onDelete && onDelete !== 'no action' ? ` (${onDelete})` : ''}`);
|
|
53
54
|
}
|
|
54
55
|
if (spec.defaultKind === 'now') parts.push('default: now()');
|
|
55
56
|
else if (spec.defaultKind === 'random') parts.push('default: random');
|
|
@@ -79,7 +80,7 @@ function relationLine(name: string, rel: Relation): string {
|
|
|
79
80
|
if (err instanceof ModelLinkError) throw err;
|
|
80
81
|
/* forward reference that can't resolve here — label stays unknown */
|
|
81
82
|
}
|
|
82
|
-
const shape = rel.kind === 'belongsTo' ? 'belongs to' : 'has many';
|
|
83
|
+
const shape = rel.kind === 'belongsTo' ? 'belongs to' : rel.kind === 'hasOne' ? 'has one' : 'has many';
|
|
83
84
|
return `- \`${name}\` — ${shape} ${target} (via \`${rel.column}\`)`;
|
|
84
85
|
}
|
|
85
86
|
|
|
@@ -155,6 +156,8 @@ export function renderDataModelSection(models: ModelDescriptor[], opts: DataMode
|
|
|
155
156
|
'',
|
|
156
157
|
"Compiled from the app's Models — edit `models/` and regenerate; never edit this section.",
|
|
157
158
|
];
|
|
159
|
+
// A parent's hasMany `dependent` sets a child FK's ON DELETE; derived from this set.
|
|
160
|
+
const dependents = relationDeleteActions(models);
|
|
158
161
|
for (const m of models) {
|
|
159
162
|
const heading = m.schema && m.schema !== 'public' ? `${m.schema}.${m.table}` : m.table;
|
|
160
163
|
lines.push('', `### ${heading}`, '');
|
|
@@ -163,7 +166,7 @@ export function renderDataModelSection(models: ModelDescriptor[], opts: DataMode
|
|
|
163
166
|
if (m.writtenBy !== 'app') annotations.push(`written by ${m.writtenBy}`);
|
|
164
167
|
if (annotations.length) lines.push(`_Operational table — ${annotations.join('; ')}._`, '');
|
|
165
168
|
for (const [name, builder] of Object.entries(m.fields)) {
|
|
166
|
-
lines.push(fieldLine(name, builder.spec));
|
|
169
|
+
lines.push(fieldLine(name, builder.spec, dependents.get(m)?.get(name)));
|
|
167
170
|
}
|
|
168
171
|
const relations = Object.entries(m.relations ?? {});
|
|
169
172
|
if (relations.length) {
|
|
@@ -19,9 +19,10 @@
|
|
|
19
19
|
* Input is a `@everystack/model` ModelDescriptor (type-only — no runtime dep).
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
import type { ModelDescriptor, FieldSpec, SequenceDescriptor } from '@everystack/model';
|
|
22
|
+
import type { ModelDescriptor, FieldSpec, SequenceDescriptor, DependentActions } from '@everystack/model';
|
|
23
|
+
import { positionalCheckName } from '@everystack/model';
|
|
23
24
|
import type { TableSchema, EnumType, SequenceSchema, UniqueConstraint, CheckConstraint, IndexSchema, ForeignKey } from './schema-introspect';
|
|
24
|
-
import { nextvalSequence, identityClause, type RenameMap } from './schema-diff';
|
|
25
|
+
import { nextvalSequence, identityClause, primaryKeyConstraintName, type RenameMap } from './schema-diff';
|
|
25
26
|
import { normalizeDeparsedExpr } from './deparse-normal';
|
|
26
27
|
|
|
27
28
|
/** `authorId` -> `author_id`. SQL identifiers are snake_case. */
|
|
@@ -59,6 +60,13 @@ export function modelConstraintSpecs(model: ModelDescriptor): { uniques: UniqueC
|
|
|
59
60
|
const indexes: IndexSchema[] = [];
|
|
60
61
|
let checkIndex = 0;
|
|
61
62
|
const usedIndexNames = new Set<string>();
|
|
63
|
+
// A field's `.validate()` CHECK is named `<table>_<column>_check`; a declared table check may
|
|
64
|
+
// not take that name, or CREATE TABLE fails with two constraints under one name.
|
|
65
|
+
const fieldCheckNames = new Map<string, string>();
|
|
66
|
+
for (const [key, field] of Object.entries(model.fields)) {
|
|
67
|
+
const col = toSnakeCase(key);
|
|
68
|
+
if (fieldCheckPredicate(col, field.spec)) fieldCheckNames.set(`${model.table}_${col}_check`, key);
|
|
69
|
+
}
|
|
62
70
|
for (const con of model.constraints) {
|
|
63
71
|
if (con.kind === 'unique') {
|
|
64
72
|
const cols = con.columns.map(toSnakeCase);
|
|
@@ -67,7 +75,20 @@ export function modelConstraintSpecs(model: ModelDescriptor): { uniques: UniqueC
|
|
|
67
75
|
// Normalized like the live producer (schema-introspect): a model that transcribes a
|
|
68
76
|
// pulled predicate must compare equal to the database it came from, whichever of the
|
|
69
77
|
// two equivalent deparse spellings each side happens to hold. See deparse-normal.ts.
|
|
70
|
-
|
|
78
|
+
//
|
|
79
|
+
// A DECLARED name is used verbatim and takes no position, so naming one check never
|
|
80
|
+
// renames its unnamed siblings; defineModel already refused duplicates and collisions
|
|
81
|
+
// with a positional name.
|
|
82
|
+
if (con.name !== undefined && fieldCheckNames.has(con.name)) {
|
|
83
|
+
throw new Error(
|
|
84
|
+
`model '${model.table}': check(sql\`${con.predicate}\`) is named '${con.name}', the name of the CHECK that field '${fieldCheckNames.get(con.name)}'.validate() compiles to. Choose another name.`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
checks.push({
|
|
88
|
+
name: con.name ?? positionalCheckName(model.table, checkIndex++),
|
|
89
|
+
expr: normalizeDeparsedExpr(con.predicate),
|
|
90
|
+
...(con.name !== undefined ? { nameDeclared: true } : {}),
|
|
91
|
+
});
|
|
71
92
|
} else if (con.kind === 'index') {
|
|
72
93
|
// Plain entries are field keys → snake_cased; raw sql`` entries (expressions,
|
|
73
94
|
// DESC, opclass) pass verbatim — they are already SQL (Brick E).
|
|
@@ -133,7 +154,7 @@ const PG_TYPE: Record<string, string> = {
|
|
|
133
154
|
date: 'date',
|
|
134
155
|
};
|
|
135
156
|
|
|
136
|
-
function pgType(spec: FieldSpec): string {
|
|
157
|
+
export function pgType(spec: FieldSpec): string {
|
|
137
158
|
// An array is the base type with `[]` appended — `text` → `text[]`, exactly as
|
|
138
159
|
// `format_type` spells it.
|
|
139
160
|
return spec.isArray ? `${scalarPgType(spec)}[]` : scalarPgType(spec);
|
|
@@ -193,16 +214,27 @@ function refActions(spec: { onDelete?: string; onUpdate?: string }): { onDelete?
|
|
|
193
214
|
* key's permanent state is installed-and-unproven, so the differ must never emit a `VALIDATE`
|
|
194
215
|
* for it and the from-scratch build must land it `NOT VALID` — otherwise the declared state and
|
|
195
216
|
* the state a build of that declaration produces would be two different states.
|
|
217
|
+
*
|
|
218
|
+
* `dependents` is the model SET's derived ON DELETE (`relationDeleteActions(models)`): a parent's
|
|
219
|
+
* hasMany `dependent` sets the action of this model's FK on that field. A caller holding the set
|
|
220
|
+
* passes it; it is never read from or written into a field spec, so the result depends only on
|
|
221
|
+
* the set. Without it a field compiles exactly as it declares.
|
|
196
222
|
*/
|
|
197
|
-
export function modelForeignKeys(model: ModelDescriptor): ForeignKey[] {
|
|
223
|
+
export function modelForeignKeys(model: ModelDescriptor, dependents?: DependentActions): ForeignKey[] {
|
|
198
224
|
const out: ForeignKey[] = [];
|
|
225
|
+
const derived = dependents?.get(model);
|
|
199
226
|
|
|
200
227
|
for (const [name, field] of Object.entries(model.fields)) {
|
|
228
|
+
// An external target (`{ schema, table, column }`, a table no model declares) yields the SAME
|
|
229
|
+
// fields a model target does, and nothing below this point knows which it was: the diff plans
|
|
230
|
+
// it and the fingerprint hashes it with no special case. Its column is a PostgreSQL name,
|
|
231
|
+
// verbatim; a model target's is its primary-key field, snake_cased.
|
|
232
|
+
const external = field.spec.referencesExternal;
|
|
201
233
|
const ref = field.spec.references;
|
|
202
|
-
if (!ref) continue;
|
|
203
|
-
const target = ref() as ModelDescriptor;
|
|
234
|
+
if (!ref && !external) continue;
|
|
235
|
+
const target = external ? { schema: external.schema, table: external.table } : ref!() as ModelDescriptor;
|
|
204
236
|
const col = toSnakeCase(name);
|
|
205
|
-
const targetPk = toSnakeCase(target.primaryKey[0] ?? 'id');
|
|
237
|
+
const targetPk = external ? external.column : toSnakeCase((target as ModelDescriptor).primaryKey[0] ?? 'id');
|
|
206
238
|
out.push({
|
|
207
239
|
// A DECLARED name wins; the generated one is the fallback. An adopted database keeps the
|
|
208
240
|
// FK names its previous tooling chose (`fk_rails_9a1b2c3d4e`) instead of being renamed
|
|
@@ -212,16 +244,17 @@ export function modelForeignKeys(model: ModelDescriptor): ForeignKey[] {
|
|
|
212
244
|
columns: [col],
|
|
213
245
|
refTable: refTableName(target),
|
|
214
246
|
refColumns: [targetPk],
|
|
215
|
-
...refActions(field.spec),
|
|
247
|
+
...refActions({ onDelete: derived?.get(name) ?? field.spec.onDelete, onUpdate: field.spec.onUpdate }),
|
|
216
248
|
...(field.spec.referencesValidate === false ? { notValid: true } : {}),
|
|
217
249
|
});
|
|
218
250
|
}
|
|
219
251
|
|
|
220
252
|
for (const con of model.constraints) {
|
|
221
253
|
if (con.kind !== 'foreignKey') continue;
|
|
222
|
-
|
|
254
|
+
// External (`{ schema, table }`): the reference columns are PostgreSQL names, verbatim.
|
|
255
|
+
const target = con.external ?? con.references!() as ModelDescriptor;
|
|
223
256
|
const cols = con.columns.map(toSnakeCase);
|
|
224
|
-
const refCols = con.refColumns.map(toSnakeCase);
|
|
257
|
+
const refCols = con.external ? [...con.refColumns] : con.refColumns.map(toSnakeCase);
|
|
225
258
|
out.push({
|
|
226
259
|
name: con.constraintName ?? generatedForeignKeyName(model.table, cols, target.table, refCols),
|
|
227
260
|
...(con.constraintName !== undefined ? { nameDeclared: true } : {}),
|
|
@@ -255,7 +288,7 @@ export function generatedForeignKeyName(table: string, columns: string[], refTab
|
|
|
255
288
|
* target schema is off the search_path — so the desired side must match, or an unchanged auth FK
|
|
256
289
|
* would read as drift. public stays bare because it is on the search_path (the proven output).
|
|
257
290
|
*/
|
|
258
|
-
function refTableName(target:
|
|
291
|
+
function refTableName(target: { schema?: string; table: string }): string {
|
|
259
292
|
return target.schema && target.schema !== 'public' ? `${target.schema}.${target.table}` : target.table;
|
|
260
293
|
}
|
|
261
294
|
|
|
@@ -458,7 +491,8 @@ export function qualifiedTable(model: ModelDescriptor): string {
|
|
|
458
491
|
/**
|
|
459
492
|
* `CREATE TABLE` for a model's fields. Single-column primary keys are inline;
|
|
460
493
|
* composite keys and uniques become table constraints, named the way drizzle
|
|
461
|
-
* names them (`<table>_<cols>_pk`, `<table>_<col>_unique`).
|
|
494
|
+
* names them (`<table>_<cols>_pk`, `<table>_<col>_unique`). The key's name, either
|
|
495
|
+
* way, is `primaryKeyConstraintName`'s — the differ names a replaced key with it too.
|
|
462
496
|
*/
|
|
463
497
|
export function compileCreateTable(model: ModelDescriptor, opts: CompileTableOptions = {}): string {
|
|
464
498
|
const indent = opts.indent ?? '\t';
|
|
@@ -474,7 +508,7 @@ export function compileCreateTable(model: ModelDescriptor, opts: CompileTableOpt
|
|
|
474
508
|
if (pkFields.length > 1) {
|
|
475
509
|
const cols = pkFields.map(toSnakeCase);
|
|
476
510
|
const quoted = cols.map((c) => `"${c}"`).join(',');
|
|
477
|
-
lines.push(`${indent}CONSTRAINT "${model.table
|
|
511
|
+
lines.push(`${indent}CONSTRAINT "${primaryKeyConstraintName(model.table, cols)}" PRIMARY KEY(${quoted})`);
|
|
478
512
|
}
|
|
479
513
|
|
|
480
514
|
for (const [name, field] of entries) {
|
|
@@ -513,7 +547,7 @@ export function compileCreateTable(model: ModelDescriptor, opts: CompileTableOpt
|
|
|
513
547
|
* structured form for the brownfield diff. They share the field→type/default/check
|
|
514
548
|
* helpers, so they never disagree.)
|
|
515
549
|
*/
|
|
516
|
-
export function compileTableSchema(model: ModelDescriptor, opts: { schema?: string } = {}): TableSchema {
|
|
550
|
+
export function compileTableSchema(model: ModelDescriptor, opts: { schema?: string; dependents?: DependentActions } = {}): TableSchema {
|
|
517
551
|
const schema = model.schema ?? opts.schema ?? 'public';
|
|
518
552
|
const entries = Object.entries(model.fields);
|
|
519
553
|
|
|
@@ -565,9 +599,14 @@ export function compileTableSchema(model: ModelDescriptor, opts: { schema?: stri
|
|
|
565
599
|
table: `${schema}.${model.table}`,
|
|
566
600
|
columns,
|
|
567
601
|
primaryKey: model.primaryKey.map(toSnakeCase),
|
|
602
|
+
// What a from-scratch build names the key — the same field introspection fills, so the two
|
|
603
|
+
// producers still meet at one IR. Never hashed (the fingerprint reads the columns only).
|
|
604
|
+
...(model.primaryKey.length > 0
|
|
605
|
+
? { primaryKeyName: primaryKeyConstraintName(model.table, model.primaryKey.map(toSnakeCase)) }
|
|
606
|
+
: {}),
|
|
568
607
|
uniques,
|
|
569
608
|
checks,
|
|
570
|
-
foreignKeys: modelForeignKeys(model),
|
|
609
|
+
foreignKeys: modelForeignKeys(model, opts.dependents),
|
|
571
610
|
indexes: tableConstraints.indexes,
|
|
572
611
|
};
|
|
573
612
|
}
|
|
@@ -627,8 +666,8 @@ export { sequenceCreateSql } from './schema-diff';
|
|
|
627
666
|
* unproven-against-old-rows, so a migration artifact that installed it validated would build a
|
|
628
667
|
* database that does not match the models it was generated from.
|
|
629
668
|
*/
|
|
630
|
-
export function foreignKeySql(model: ModelDescriptor): string[] {
|
|
631
|
-
return modelForeignKeys(model).map((fk) => {
|
|
669
|
+
export function foreignKeySql(model: ModelDescriptor, dependents?: DependentActions): string[] {
|
|
670
|
+
return modelForeignKeys(model, dependents).map((fk) => {
|
|
632
671
|
const cols = fk.columns.map((c) => `"${c}"`).join(', ');
|
|
633
672
|
const refCols = fk.refColumns.map((c) => `"${c}"`).join(', ');
|
|
634
673
|
// Both sides are schema-qualified for a non-public table — `ALTER TABLE "auth"."identities"`
|