@everystack/cli 0.3.10 → 0.3.11
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/package.json +1 -1
- package/src/cli/model-render.ts +17 -2
- package/src/cli/runbook/model-section.ts +112 -0
package/package.json
CHANGED
package/src/cli/model-render.ts
CHANGED
|
@@ -84,6 +84,13 @@ const FIELD_FACTORY: Record<string, string> = {
|
|
|
84
84
|
* scale-0 numeric renders as the cleaner `field.numeric(p)` (identical to `(p, 0)`).
|
|
85
85
|
*/
|
|
86
86
|
export function fieldFactoryCall(type: string): string | null {
|
|
87
|
+
// An array is its base type + `.array()` — exactly how the compiler emits it
|
|
88
|
+
// (`format_type` spells arrays `text[]`). An array of an unmapped base stays null.
|
|
89
|
+
if (type.endsWith('[]')) {
|
|
90
|
+
const base = fieldFactoryCall(type.slice(0, -2));
|
|
91
|
+
return base ? `${base}.array()` : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
87
94
|
const direct = FIELD_FACTORY[type];
|
|
88
95
|
if (direct) return `field.${direct}()`;
|
|
89
96
|
|
|
@@ -124,9 +131,17 @@ export function modelVarName(table: string): string {
|
|
|
124
131
|
return singular.split('_').filter(Boolean).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join('');
|
|
125
132
|
}
|
|
126
133
|
|
|
127
|
-
/**
|
|
134
|
+
/**
|
|
135
|
+
* `author_id` → `authorId` — a SQL column to its field key, LOSSLESSLY: the compiler's
|
|
136
|
+
* snake_case must reconstruct the exact column (`snake(key) === column`, always). Only a
|
|
137
|
+
* letter after `_` camelizes, because only a capital letter survives the trip back — an
|
|
138
|
+
* underscore before a digit is preserved (`yardline_100` stays `yardline_100`,
|
|
139
|
+
* `top_3_best` → `top_3Best`). Swallowing it (the old `_([a-z0-9])` rule) generated a
|
|
140
|
+
* phantom rename for every digit-boundary column and COLLIDED distinct columns
|
|
141
|
+
* (`a_1` and `a1`) onto one key.
|
|
142
|
+
*/
|
|
128
143
|
function toCamelCase(name: string): string {
|
|
129
|
-
return name.replace(/_([a-
|
|
144
|
+
return name.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
130
145
|
}
|
|
131
146
|
|
|
132
147
|
/** The `.default*()` modifier for a column default, or null when the expression isn't one we map. */
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* renderDataModelSection — the app's Models, rendered for the runbook.
|
|
3
|
+
*
|
|
4
|
+
* The one chapter no generic doc can carry: this app's tables, fields,
|
|
5
|
+
* relations, and declared abilities. Compiled from the same ModelDescriptors
|
|
6
|
+
* db:generate consumes, so it can never describe a schema the app doesn't
|
|
7
|
+
* have — and an ability change shows up as runbook drift (`runbook --check`).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Ability, FieldSpec, ModelDescriptor, Relation } from '@everystack/model';
|
|
11
|
+
|
|
12
|
+
function typeLabel(spec: FieldSpec): string {
|
|
13
|
+
let base: string;
|
|
14
|
+
switch (spec.type) {
|
|
15
|
+
case 'varchar':
|
|
16
|
+
base = spec.length != null ? `varchar(${spec.length})` : 'varchar';
|
|
17
|
+
break;
|
|
18
|
+
case 'numeric':
|
|
19
|
+
base =
|
|
20
|
+
spec.precision != null
|
|
21
|
+
? `numeric(${spec.precision}${spec.scale != null ? `, ${spec.scale}` : ''})`
|
|
22
|
+
: 'numeric';
|
|
23
|
+
break;
|
|
24
|
+
case 'enum':
|
|
25
|
+
base = spec.enumValues ? `enum(${spec.enumValues.join(' | ')})` : 'enum';
|
|
26
|
+
break;
|
|
27
|
+
default:
|
|
28
|
+
base = spec.type;
|
|
29
|
+
}
|
|
30
|
+
return spec.isArray ? `${base}[]` : base;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function referenceTarget(spec: FieldSpec): string | null {
|
|
34
|
+
if (!spec.references) return null;
|
|
35
|
+
try {
|
|
36
|
+
const target = spec.references() as { table?: string } | undefined;
|
|
37
|
+
return target?.table ?? 'unknown';
|
|
38
|
+
} catch {
|
|
39
|
+
return 'unknown';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function fieldLine(name: string, spec: FieldSpec): string {
|
|
44
|
+
const parts = [typeLabel(spec)];
|
|
45
|
+
if (spec.isPrimaryKey) parts.push('pk');
|
|
46
|
+
if (spec.isNotNull && !spec.isPrimaryKey) parts.push('not null');
|
|
47
|
+
if (spec.isUnique) parts.push('unique');
|
|
48
|
+
const target = referenceTarget(spec);
|
|
49
|
+
if (target) {
|
|
50
|
+
parts.push(`→ ${target}${spec.onDelete && spec.onDelete !== 'no action' ? ` (${spec.onDelete})` : ''}`);
|
|
51
|
+
}
|
|
52
|
+
if (spec.defaultKind === 'now') parts.push('default: now()');
|
|
53
|
+
else if (spec.defaultKind === 'random') parts.push('default: random');
|
|
54
|
+
else if (spec.defaultKind === 'value') parts.push(`default: ${JSON.stringify(spec.default)}`);
|
|
55
|
+
if (spec.isPrivate) parts.push('private (never serialized)');
|
|
56
|
+
if (spec.isReadonly) parts.push('readonly (never writable via the API)');
|
|
57
|
+
return `- \`${name}\` — ${parts.join(', ')}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function abilityLine(a: Ability): string {
|
|
61
|
+
const verb = a.action === 'manage' ? 'manage (all actions)' : a.action;
|
|
62
|
+
const c = a.condition ?? {};
|
|
63
|
+
const scope: string[] = [];
|
|
64
|
+
if (c.role) scope.push(`role \`${c.role}\``);
|
|
65
|
+
if (c.owner) scope.push(`owner (\`${c.owner}\`)`);
|
|
66
|
+
if (c.via) scope.push(`owner via \`${c.via}\``);
|
|
67
|
+
if (c.where || c.sql) scope.push('custom predicate');
|
|
68
|
+
const columns = c.columns ? ` — columns: ${c.columns.map((col) => `\`${col}\``).join(', ')}` : '';
|
|
69
|
+
return `- ${verb}: ${scope.length ? scope.join(', ') : 'anyone'}${columns}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function relationLine(name: string, rel: Relation): string {
|
|
73
|
+
let target = 'unknown';
|
|
74
|
+
try {
|
|
75
|
+
target = rel.target().table;
|
|
76
|
+
} catch {
|
|
77
|
+
/* forward reference that can't resolve here — label stays unknown */
|
|
78
|
+
}
|
|
79
|
+
const shape = rel.kind === 'belongsTo' ? 'belongs to' : 'has many';
|
|
80
|
+
return `- \`${name}\` — ${shape} ${target} (via \`${rel.column}\`)`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function renderDataModelSection(models: ModelDescriptor[]): string {
|
|
84
|
+
const lines: string[] = [
|
|
85
|
+
'## Your data model',
|
|
86
|
+
'',
|
|
87
|
+
"Compiled from the app's Models — edit `models/` and regenerate; never edit this section.",
|
|
88
|
+
];
|
|
89
|
+
for (const m of models) {
|
|
90
|
+
const heading = m.schema && m.schema !== 'public' ? `${m.schema}.${m.table}` : m.table;
|
|
91
|
+
lines.push('', `### ${heading}`, '');
|
|
92
|
+
const annotations: string[] = [];
|
|
93
|
+
if (m.private) annotations.push('not exposed via the generic API');
|
|
94
|
+
if (m.writtenBy !== 'app') annotations.push(`written by ${m.writtenBy}`);
|
|
95
|
+
if (annotations.length) lines.push(`_Operational table — ${annotations.join('; ')}._`, '');
|
|
96
|
+
for (const [name, builder] of Object.entries(m.fields)) {
|
|
97
|
+
lines.push(fieldLine(name, builder.spec));
|
|
98
|
+
}
|
|
99
|
+
const relations = Object.entries(m.relations ?? {});
|
|
100
|
+
if (relations.length) {
|
|
101
|
+
lines.push('', 'Relations:', '');
|
|
102
|
+
for (const [name, rel] of relations) lines.push(relationLine(name, rel));
|
|
103
|
+
}
|
|
104
|
+
lines.push('', 'Access:', '');
|
|
105
|
+
if (m.abilities.length === 0) {
|
|
106
|
+
lines.push('- no abilities declared — nobody can reach this table through the API');
|
|
107
|
+
} else {
|
|
108
|
+
for (const a of m.abilities) lines.push(abilityLine(a));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return lines.join('\n');
|
|
112
|
+
}
|