@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/schema-source.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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) {
|
package/src/cli/schema-swap.ts
CHANGED
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
* so the swap is a pointer flip, never a recompute.
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
|
-
import type
|
|
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(', ');
|
package/src/cli/state-apply.ts
CHANGED
|
@@ -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, ...
|
|
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
|
|
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
|
-
]), ...
|
|
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
|
+
}
|