@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
@@ -0,0 +1,255 @@
1
+ /**
2
+ * The model map: `db/model-map.generated.json`, the association map the admin reads with no
3
+ * database and no credentials (U3; T2). db:generate writes it beside the generated drizzle schema
4
+ * and db:check regenerates it in memory and compares byte for byte, exactly as it does the schema.
5
+ *
6
+ * A pure function of the linked model set. It carries PUBLIC METADATA only: per model its schema,
7
+ * table and primary key, the NOT NULL field keys, the relations it declares and the foreign keys it
8
+ * owns. No column types, no abilities, grants or policies, nothing from a database, no timestamp, path or git reference.
9
+ *
10
+ * The foreign keys are read from `modelForeignKeys(model, relationDeleteActions(models))`, the one
11
+ * source the generated SQL and the diff IR read, so the map's ON DELETE / ON UPDATE (the action a
12
+ * parent's hasMany `dependent` derives included) cannot disagree with the DDL.
13
+ *
14
+ * Privacy, fail closed: what the API handler hides never appears. A `private: true` model is left
15
+ * out, and so is every relation or foreign key that points at one. A relation or foreign key that
16
+ * names a `.private()` field anywhere (its column, its key, an orderBy or where field), by field key
17
+ * or SQL column name, is left out whole. `where` values and `limit` are published verbatim. Withholding is additive to undo later; publishing a hidden name cannot be taken back.
18
+ *
19
+ * `mapVersion` versions the format. `modelRevision` is the sha256 of the map's own content, so it
20
+ * changes if and only if the map does (an ability or a plain column edit does not move it).
21
+ */
22
+
23
+ import { createHash } from 'node:crypto';
24
+ import path from 'node:path';
25
+ import { linkModels, relationDeleteActions, type ModelDescriptor, type Relation } from '@everystack/model';
26
+ import { modelForeignKeys, toSnakeCase } from './schema-compile';
27
+
28
+ export const MODEL_MAP_VERSION = 1;
29
+ export const MODEL_MAP_FILE = 'model-map.generated.json';
30
+
31
+ export interface ModelMapRelation {
32
+ /** The key the relation is declared under. */
33
+ name: string;
34
+ kind: 'belongsTo' | 'hasMany' | 'hasOne';
35
+ /** The target model's map key (`schema.table`). */
36
+ target: string;
37
+ /** The foreign-key field: on this model for belongsTo, on the target for hasMany / hasOne. */
38
+ column: string;
39
+ /** The declared key on the other side, or null for the default (the primary key). */
40
+ references: string | null;
41
+ dependent: 'restrict' | 'nullify' | 'cascade' | null;
42
+ /** Normalized: the target's primary key is the tie-breaker. Null for a hasMany with none. */
43
+ orderBy: Array<{ field: string; order: 'asc' | 'desc' }> | null;
44
+ limit: number | null;
45
+ where: Record<string, string | number | boolean | null> | null;
46
+ }
47
+
48
+ export interface ModelMapForeignKey {
49
+ /** The constraint name the DDL uses. */
50
+ name: string;
51
+ /** This model's field keys, and their SQL columns, in the same order. */
52
+ fields: string[];
53
+ columns: string[];
54
+ /** `external` is true for a table no model declares (`.references({ schema, table, column })`). */
55
+ target: { schema: string; table: string; columns: string[]; external: boolean };
56
+ onDelete: string;
57
+ onUpdate: string;
58
+ }
59
+
60
+ export interface ModelMapEntry {
61
+ schema: string;
62
+ table: string;
63
+ /** Field keys; null when a key field is `.private()`. */
64
+ primaryKey: string[] | null;
65
+ /**
66
+ * Field keys of the NOT NULL columns, in declaration order (a serial, an identity and a primary
67
+ * key column included: Postgres makes them NOT NULL). A `.private()` field is never named. The
68
+ * admin reads it to hide the clear control on a value the database cannot empty; it does NOT
69
+ * make the field required in a form, since a NOT NULL column with a default may be left blank.
70
+ */
71
+ notNull: string[];
72
+ /**
73
+ * The field key of the soft-delete column when the model declares `softDelete: true` (a DELETE
74
+ * marks the row, so no foreign-key rule fires); null when it hard-deletes, or when that field is
75
+ * `.private()` (a private name is never published).
76
+ */
77
+ softDelete: string | null;
78
+ /**
79
+ * Present (and true) only on a `lifecycle: true` model: the database soft-deletes it and a
80
+ * restore goes through `<t>_restore`, never an update. Omitted on every other model, so an
81
+ * absent key reads false and a map with no lifecycle model is unchanged.
82
+ */
83
+ lifecycle?: true;
84
+ relations: ModelMapRelation[];
85
+ foreignKeys: ModelMapForeignKey[];
86
+ }
87
+
88
+ export interface ModelMap {
89
+ mapVersion: typeof MODEL_MAP_VERSION;
90
+ modelRevision: string;
91
+ models: Record<string, ModelMapEntry>;
92
+ }
93
+
94
+ /** Where the map lives: beside the resolved drizzle schema (so it follows `--schema-out`). */
95
+ export function modelMapPath(schemaOut: string): string {
96
+ return path.join(path.dirname(schemaOut), MODEL_MAP_FILE);
97
+ }
98
+
99
+ const keyOf = (m: { schema?: string; table: string }): string => `${m.schema ?? 'public'}.${m.table}`;
100
+
101
+ /**
102
+ * True when `name` spells a `.private()` field of `model` by its field key OR its SQL column (a
103
+ * relation may name `author_id` for `authorId`). Compared on the snake_case form of both, so every
104
+ * spelling that reaches the same column matches. A name that matches no private field is not hidden.
105
+ */
106
+ const hidden = (model: ModelDescriptor, name: string): boolean => {
107
+ const column = toSnakeCase(name);
108
+ return Object.entries(model.fields).some(([key, f]) => f.spec.isPrivate && (key === name || toSnakeCase(key) === column));
109
+ };
110
+
111
+ /** One foreign key as the declaration states it, in the order `modelForeignKeys` emits it. */
112
+ interface DeclaredFk {
113
+ fields: string[];
114
+ target: { schema: string; table: string; external: boolean; model: ModelDescriptor | null };
115
+ /** Target field keys (model target) or PostgreSQL column names (external target). */
116
+ targetKeys: string[];
117
+ }
118
+
119
+ /**
120
+ * The declaration side of each FK, walked in the same order as `modelForeignKeys` (fields, then
121
+ * table-level foreignKey constraints). The caller checks it lines up with the DDL IR entry by entry,
122
+ * so the two walks can never silently drift.
123
+ */
124
+ function declaredForeignKeys(model: ModelDescriptor): DeclaredFk[] {
125
+ const out: DeclaredFk[] = [];
126
+ for (const [name, f] of Object.entries(model.fields)) {
127
+ const external = f.spec.referencesExternal;
128
+ const ref = f.spec.references;
129
+ if (external) {
130
+ out.push({ fields: [name], target: { schema: external.schema, table: external.table, external: true, model: null }, targetKeys: [external.column] });
131
+ } else if (ref) {
132
+ const t = ref() as ModelDescriptor;
133
+ out.push({ fields: [name], target: { schema: t.schema ?? 'public', table: t.table, external: false, model: t }, targetKeys: [t.primaryKey[0] ?? 'id'] });
134
+ }
135
+ }
136
+ for (const c of model.constraints) {
137
+ if (c.kind !== 'foreignKey') continue;
138
+ if (c.external) {
139
+ out.push({ fields: [...c.columns], target: { schema: c.external.schema, table: c.external.table, external: true, model: null }, targetKeys: [...c.refColumns] });
140
+ } else {
141
+ const t = c.references!() as ModelDescriptor;
142
+ out.push({ fields: [...c.columns], target: { schema: t.schema ?? 'public', table: t.table, external: false, model: t }, targetKeys: [...c.refColumns] });
143
+ }
144
+ }
145
+ return out;
146
+ }
147
+
148
+ function mapRelation(model: ModelDescriptor, name: string, rel: Relation): ModelMapRelation | null {
149
+ const target = rel.target();
150
+ if (target.private) return null;
151
+ // Which side each carried name belongs to: belongsTo's column is ours and its key the target's;
152
+ // hasMany / hasOne the other way round. orderBy and where name target fields.
153
+ const [columnSide, referencesSide] = rel.kind === 'belongsTo' ? [model, target] : [target, model];
154
+ const names: Array<[ModelDescriptor, string]> = [
155
+ [columnSide, rel.column],
156
+ ...(rel.references !== undefined ? [[referencesSide, rel.references] as [ModelDescriptor, string]] : []),
157
+ ...(rel.orderBy ?? []).map((t) => [target, t.field] as [ModelDescriptor, string]),
158
+ ...Object.keys(rel.where ?? {}).map((k) => [target, k] as [ModelDescriptor, string]),
159
+ ];
160
+ if (names.some(([m, n]) => hidden(m, n))) return null;
161
+ return {
162
+ name,
163
+ kind: rel.kind,
164
+ target: keyOf(target),
165
+ column: rel.column,
166
+ references: rel.references ?? null,
167
+ dependent: rel.dependent ?? null,
168
+ orderBy: rel.orderBy ? rel.orderBy.map((t) => ({ field: t.field, order: t.order })) : null,
169
+ limit: rel.limit ?? null,
170
+ where: rel.where ? { ...rel.where } : null,
171
+ };
172
+ }
173
+
174
+ /** The soft-delete field, spelled as the model keys it (both spellings the model accepts). */
175
+ function softDeleteField(model: ModelDescriptor): string | null {
176
+ if (!model.softDelete) return null;
177
+ const key = ['deletedAt', 'deleted_at'].find((k) => k in model.fields);
178
+ return key && !hidden(model, key) ? key : null;
179
+ }
180
+
181
+ /** The map for a model set. Links the set first (idempotent), so the orderBy is normalized. */
182
+ export function compileModelMap(models: ModelDescriptor[]): ModelMap {
183
+ linkModels(models);
184
+ const dependents = relationDeleteActions(models);
185
+ const entries: Array<[string, ModelMapEntry]> = [];
186
+ const privateTables = new Set(models.filter((m) => m.private).map(keyOf));
187
+ // Table and schema names may hold a dot, so `schema.table` can collide ('x' + 'y.a' vs 'x.y' + 'a').
188
+ // A collision would silently drop a model from the map: fail loudly, naming both.
189
+ const owners = new Map<string, ModelDescriptor>();
190
+
191
+ for (const model of new Set(models)) {
192
+ if (model.private) continue;
193
+ const relations: ModelMapRelation[] = [];
194
+ for (const [name, rel] of Object.entries(model.relations) as Array<[string, Relation]>) {
195
+ const mapped = mapRelation(model, name, rel);
196
+ if (mapped) relations.push(mapped);
197
+ }
198
+
199
+ // The DDL IR (the SQL's own source) and the declaration, walked in lockstep.
200
+ const ir = modelForeignKeys(model, dependents);
201
+ const declared = declaredForeignKeys(model);
202
+ if (ir.length !== declared.length) {
203
+ throw new Error(`model map: ${keyOf(model)} has ${ir.length} foreign key(s) in the DDL but ${declared.length} declared`);
204
+ }
205
+ const foreignKeys: ModelMapForeignKey[] = [];
206
+ ir.forEach((fk, i) => {
207
+ const d = declared[i];
208
+ const expectedRef = d.target.schema !== 'public' ? `${d.target.schema}.${d.target.table}` : d.target.table;
209
+ if (fk.refTable !== expectedRef || fk.columns.join() !== d.fields.map(toSnakeCase).join()) {
210
+ throw new Error(`model map: ${keyOf(model)} foreign key ${fk.name} does not line up with its declaration`);
211
+ }
212
+ if (d.fields.some((f) => hidden(model, f))) return;
213
+ if (d.target.model && (d.target.model.private || d.targetKeys.some((k) => hidden(d.target.model!, k)))) return;
214
+ // An external target naming a private model's table: linkModels already refuses an external
215
+ // target any model declares ('use the model'); withheld here too, so this function alone holds.
216
+ if (d.target.external && privateTables.has(keyOf(d.target))) return;
217
+ foreignKeys.push({
218
+ name: fk.name,
219
+ fields: d.fields,
220
+ columns: [...fk.columns],
221
+ target: { schema: d.target.schema, table: d.target.table, columns: [...fk.refColumns], external: d.target.external },
222
+ onDelete: fk.onDelete ?? 'no action',
223
+ onUpdate: fk.onUpdate ?? 'no action',
224
+ });
225
+ });
226
+
227
+ const key = keyOf(model);
228
+ const clash = owners.get(key);
229
+ if (clash) {
230
+ const who = (m: ModelDescriptor): string => `schema '${m.schema ?? 'public'}', table '${m.table}'`;
231
+ throw new Error(`model map: two models share the key ${key} (${who(clash)} and ${who(model)}); rename one`);
232
+ }
233
+ owners.set(key, model);
234
+ entries.push([key, {
235
+ schema: model.schema ?? 'public',
236
+ table: model.table,
237
+ primaryKey: model.primaryKey.some((k) => hidden(model, k)) ? null : [...model.primaryKey],
238
+ notNull: Object.entries(model.fields).filter(([key, f]) => f.spec.isNotNull && !hidden(model, key)).map(([key]) => key),
239
+ softDelete: softDeleteField(model),
240
+ ...(model.lifecycle === true ? { lifecycle: true as const } : {}),
241
+ relations,
242
+ foreignKeys,
243
+ }]);
244
+ }
245
+
246
+ entries.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
247
+ const body = { mapVersion: MODEL_MAP_VERSION, models: Object.fromEntries(entries) };
248
+ const modelRevision = createHash('sha256').update(JSON.stringify(body)).digest('hex');
249
+ return { mapVersion: MODEL_MAP_VERSION, modelRevision, models: body.models };
250
+ }
251
+
252
+ /** The committed file's exact text: two-space JSON and a trailing newline. */
253
+ export function renderModelMap(models: ModelDescriptor[]): string {
254
+ return JSON.stringify(compileModelMap(models), null, 2) + '\n';
255
+ }