@everystack/cli 0.4.102 → 0.4.104
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 +62 -0
- package/package.json +3 -3
- package/src/cli/authz-compile.ts +48 -11
- package/src/cli/authz-contract.ts +6 -0
- package/src/cli/authz-derive.ts +3 -1
- package/src/cli/commands/db-apply.ts +34 -10
- package/src/cli/commands/db-backup.ts +62 -26
- package/src/cli/commands/db-fork.ts +2 -1
- package/src/cli/commands/db-plan.ts +5 -0
- package/src/cli/commands/db-pull.ts +48 -12
- package/src/cli/commands/db-reconcile.ts +34 -5
- package/src/cli/commands/db-sync.ts +32 -3
- package/src/cli/commands/db.ts +46 -21
- package/src/cli/db-build.ts +11 -1
- package/src/cli/declared-diff.ts +3 -2
- package/src/cli/derived-apply.ts +5 -1
- package/src/cli/derived-catchup.ts +4 -0
- package/src/cli/derived-compile.ts +2 -1
- package/src/cli/derived-introspect.ts +22 -1
- package/src/cli/derived-plan.ts +64 -0
- package/src/cli/derived-render.ts +1 -0
- package/src/cli/edge-plan.ts +35 -0
- package/src/cli/index-lock.ts +65 -0
- package/src/cli/index.ts +1 -1
- package/src/cli/lifecycle-canon.ts +2 -1
- package/src/cli/lifecycle-derived.ts +334 -45
- package/src/cli/lifecycle-preflight.ts +498 -0
- package/src/cli/lifecycle-pull.ts +119 -11
- package/src/cli/migration-compile.ts +4 -3
- package/src/cli/migration-generate.ts +3 -2
- package/src/cli/model-api.ts +9 -0
- package/src/cli/model-render.ts +80 -8
- package/src/cli/plain-output.ts +14 -1
- package/src/cli/schema-compile.ts +34 -67
- package/src/cli/schema-fingerprint.ts +3 -2
- package/src/cli/schema-introspect.ts +33 -2
- package/src/cli/state-function-deps.ts +2 -1
- package/src/cli/lifecycle-verify.ts +0 -232
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
* db:pull's lifecycle fold — reads a lifecycle table back as `lifecycle: true`.
|
|
3
3
|
*
|
|
4
4
|
* `lifecycle: true` generates, per table: four columns, the reserved role's grants and policies,
|
|
5
|
-
* a `deleted_at IS NULL` guard on every other role's SELECT policy,
|
|
6
|
-
*
|
|
7
|
-
* authored ability and the columns as authored fields (defineModel refuses both), and the
|
|
5
|
+
* a `deleted_at IS NULL` guard on every other role's SELECT policy, generated functions,
|
|
6
|
+
* and triggers. A pull that transcribed them would render the reserved role as an
|
|
7
|
+
* authored ability and the columns as authored fields (defineModel refuses both), and the generated
|
|
8
8
|
* objects as derived descriptors (they would be generated twice).
|
|
9
9
|
*
|
|
10
10
|
* So the fold removes all of it from the pull's inputs, and the renderer writes `lifecycle: true`.
|
|
@@ -22,6 +22,8 @@ import type { SchemaSnapshot, TableSchema } from './schema-introspect';
|
|
|
22
22
|
import type { AuthzContract, PolicyContract, TableContract } from './authz-contract';
|
|
23
23
|
import type { DerivedCatalog } from './derived-introspect';
|
|
24
24
|
import { compileTableContract } from './authz-compile';
|
|
25
|
+
import { lifecycleFkIndexName } from './schema-compile';
|
|
26
|
+
import { functionIdentity } from './pg-argtypes';
|
|
25
27
|
|
|
26
28
|
/** The generated columns as the catalog reports them (`format_type`), nullable, no default. */
|
|
27
29
|
const GENERATED_COLUMNS: Record<string, string> = {
|
|
@@ -32,6 +34,7 @@ const GENERATED_COLUMNS: Record<string, string> = {
|
|
|
32
34
|
};
|
|
33
35
|
|
|
34
36
|
const GUARD = '(deleted_at IS NULL)';
|
|
37
|
+
const DELETED_ONLY = '(deleted_at IS NOT NULL)';
|
|
35
38
|
|
|
36
39
|
/** The commands the measured effective matrix reports (`effectivePrivileges`). */
|
|
37
40
|
const EFFECTIVE_COMMANDS = new Set(['SELECT', 'INSERT', 'UPDATE', 'DELETE']);
|
|
@@ -42,6 +45,8 @@ export interface LifecycleFold {
|
|
|
42
45
|
catalog: DerivedCatalog;
|
|
43
46
|
/** Schema-qualified tables folded to `lifecycle: true`. */
|
|
44
47
|
tables: Set<string>;
|
|
48
|
+
/** lifecycle: { deleteWhen }'s predicate per folded table, read from its delete_when function's manifest. */
|
|
49
|
+
deleteWhen: Map<string, string>;
|
|
45
50
|
}
|
|
46
51
|
|
|
47
52
|
const split = (qualified: string): { schema: string; name: string } => {
|
|
@@ -79,8 +84,31 @@ function columnsAreGenerated(table: TableSchema): boolean {
|
|
|
79
84
|
});
|
|
80
85
|
}
|
|
81
86
|
|
|
87
|
+
/** The lifecycle columns a deleted reader is never granted (compileColumnGrants). */
|
|
88
|
+
const DELETED_READ_HIDDEN = new Set(['deleted_by', 'deleted_batch', 'deleted_xid']);
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* can('read', { role, deleted }) with no columns compiles to a column SELECT of every column but
|
|
92
|
+
* deleted_by, deleted_batch and deleted_xid. Fold exactly that grant back to the table SELECT the
|
|
93
|
+
* renderer reads as a plain role read, so the pull renders `deleted` without a columns list; the
|
|
94
|
+
* compile turns it back into the same column grant. Any other column set stays as it is.
|
|
95
|
+
*/
|
|
96
|
+
function foldDeletedReadGrants(folded: TableContract, table: TableSchema): void {
|
|
97
|
+
const every = sorted(table.columns.map((c) => c.name).filter((name) => !DELETED_READ_HIDDEN.has(name)));
|
|
98
|
+
for (const p of folded.policies) {
|
|
99
|
+
if (p.deleted === undefined || p.roles.length !== 1) continue;
|
|
100
|
+
const role = p.roles[0];
|
|
101
|
+
const cols = folded.columnGrants?.[role];
|
|
102
|
+
if (!cols || folded.grants[role]?.includes('SELECT') || sorted(cols.SELECT) !== every) continue;
|
|
103
|
+
folded.grants = { ...folded.grants, [role]: [...(folded.grants[role] ?? []), 'SELECT'].sort() };
|
|
104
|
+
const { SELECT: _select, ...rest } = cols;
|
|
105
|
+
const { [role]: _role, ...others } = folded.columnGrants!;
|
|
106
|
+
folded.columnGrants = Object.keys(rest).length ? { ...others, [role]: rest } : others;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
82
110
|
/** The folded contract for one table, or undefined when the table is not exactly lifecycle. */
|
|
83
|
-
function foldContract(live: TableContract): TableContract | undefined {
|
|
111
|
+
function foldContract(live: TableContract, table: TableSchema): TableContract | undefined {
|
|
84
112
|
const role = LIFECYCLE_OWNER_ROLE;
|
|
85
113
|
const expected = reservedRoleContract(live.table);
|
|
86
114
|
if (sorted(live.grants[role]) !== sorted(expected.grants[role])) return undefined;
|
|
@@ -111,8 +139,20 @@ function foldContract(live: TableContract): TableContract | undefined {
|
|
|
111
139
|
continue;
|
|
112
140
|
}
|
|
113
141
|
const rest = unguarded(p.using);
|
|
114
|
-
if (rest
|
|
115
|
-
|
|
142
|
+
if (rest !== undefined) {
|
|
143
|
+
policies.push({ ...p, using: rest ?? 'true' });
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
// can('read', { role, deleted }): a role's own read policy compiled without the guard
|
|
147
|
+
// ('include') or with (deleted_at IS NOT NULL) in its place ('only'). Any other SELECT policy
|
|
148
|
+
// without the guard leaves the table unfolded, as before.
|
|
149
|
+
const own = p.roles.length === 1 && !['anon', 'authenticated', 'public'].includes(p.roles[0])
|
|
150
|
+
&& p.name === `${split(live.table).name}_select_${p.roles[0]}`;
|
|
151
|
+
if (!own) return undefined;
|
|
152
|
+
if (p.using === DELETED_ONLY) policies.push({ ...p, using: 'true', deleted: 'only' });
|
|
153
|
+
else if (p.using?.startsWith(`(${DELETED_ONLY} AND `) && p.using.endsWith(')')) {
|
|
154
|
+
policies.push({ ...p, using: p.using.slice(DELETED_ONLY.length + 6, -1), deleted: 'only' });
|
|
155
|
+
} else policies.push({ ...p, deleted: 'include' });
|
|
116
156
|
}
|
|
117
157
|
|
|
118
158
|
const { [role]: _grant, ...grants } = live.grants;
|
|
@@ -125,11 +165,12 @@ function foldContract(live: TableContract): TableContract | undefined {
|
|
|
125
165
|
const { [role]: _eff, ...effectivePrivileges } = live.effectivePrivileges;
|
|
126
166
|
folded.effectivePrivileges = effectivePrivileges;
|
|
127
167
|
}
|
|
168
|
+
foldDeletedReadGrants(folded, table);
|
|
128
169
|
return folded;
|
|
129
170
|
}
|
|
130
171
|
|
|
131
172
|
/**
|
|
132
|
-
* The
|
|
173
|
+
* The generated objects of one lifecycle table, and the older CLI's retired restrict pair, exactly as the derived catalog keys them. The
|
|
133
174
|
* restore takes the primary key, and a function signature carries no type modifier
|
|
134
175
|
* (`character varying(255)` is `character varying` there). An author's own overload stays.
|
|
135
176
|
*/
|
|
@@ -140,31 +181,97 @@ function generatedIdentities(table: TableSchema): string[] {
|
|
|
140
181
|
return [
|
|
141
182
|
`${schema}.${name}_lifecycle_transition()`,
|
|
142
183
|
`${schema}.${name}_lifecycle_guard()`,
|
|
184
|
+
`${schema}.${name}_lifecycle_destroy_before()`,
|
|
185
|
+
functionIdentity(schema, `${name}_lifecycle_destroy`, [`${arg}[]`, 'timestamptz', 'integer']),
|
|
143
186
|
`${schema}.${name}_restore(${arg})`,
|
|
144
187
|
`${table.table}.${name}_lifecycle_transition`,
|
|
145
188
|
`${table.table}.${name}_lifecycle_guard`,
|
|
189
|
+
`${schema}.${name}_lifecycle_delete_when()`,
|
|
190
|
+
`${table.table}.${name}_lifecycle_delete_when`,
|
|
191
|
+
// CLI 0.4.101 also generated a per-row restrict pair. The transition carries that check now and
|
|
192
|
+
// db:apply removes the pair, so a stage built by 0.4.101 must not pull it back as authored code.
|
|
193
|
+
`${schema}.${name}_lifecycle_restrict()`,
|
|
194
|
+
`${table.table}.${name}_lifecycle_restrict`,
|
|
146
195
|
];
|
|
147
196
|
}
|
|
148
197
|
|
|
198
|
+
/**
|
|
199
|
+
* The generator's lifecycle FK index (lifecycleDependentIndexes): plain btree, exactly the columns of
|
|
200
|
+
* a foreign key into a folded lifecycle table, under exactly the generated name. Folded like the
|
|
201
|
+
* lifecycle columns, so the pull never renders it as an author index. Anything else stays.
|
|
202
|
+
*/
|
|
203
|
+
function withoutGeneratedFkIndexes(t: TableSchema, lifecycleTables: ReadonlySet<string>): TableSchema {
|
|
204
|
+
const name = t.table.slice(t.table.indexOf('.') + 1);
|
|
205
|
+
const indexes = t.indexes.filter((ix) => !(
|
|
206
|
+
!ix.unique && !ix.where && !ix.using && !ix.include?.length
|
|
207
|
+
&& ix.name === lifecycleFkIndexName(name, ix.columns)
|
|
208
|
+
&& t.foreignKeys.some((fk) => lifecycleTables.has(fk.refTable.includes('.') ? fk.refTable : `public.${fk.refTable}`)
|
|
209
|
+
&& JSON.stringify(fk.columns) === JSON.stringify(ix.columns))));
|
|
210
|
+
return indexes.length === t.indexes.length ? t : { ...t, indexes };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Fold only the exact read capability generated for a plain restricting child. */
|
|
214
|
+
function foldReferenceContract(live: TableContract, table: TableSchema, lifecycleTables: ReadonlySet<string>): TableContract | undefined {
|
|
215
|
+
const columns = [...new Set(table.foreignKeys.filter(fk =>
|
|
216
|
+
lifecycleTables.has(fk.refTable.includes('.') ? fk.refTable : `public.${fk.refTable}`)
|
|
217
|
+
&& (!fk.onDelete || fk.onDelete === 'no action' || fk.onDelete === 'restrict')).flatMap(fk => fk.columns))].sort();
|
|
218
|
+
if (!columns.length) return undefined;
|
|
219
|
+
const role = LIFECYCLE_OWNER_ROLE;
|
|
220
|
+
const grants = live.columnGrants?.[role] ?? {};
|
|
221
|
+
if ((live.grants[role]?.length ?? 0) !== 0 || sorted(Object.keys(grants)) !== sorted(['SELECT']) || sorted(grants.SELECT) !== sorted(columns)) return undefined;
|
|
222
|
+
const expected: PolicyContract = { name: `${split(table.table).name}_select_${role}`, command: 'SELECT', roles: [role], permissive: true, using: 'true', check: null };
|
|
223
|
+
const ownPolicies = live.policies.filter(p => p.roles.includes(role));
|
|
224
|
+
if (ownPolicies.length !== 1 || policyKey(ownPolicies[0]) !== policyKey(expected)) return undefined;
|
|
225
|
+
if (live.effectivePrivileges && sorted(live.effectivePrivileges[role]) !== sorted(['SELECT'])) return undefined;
|
|
226
|
+
const { [role]: _grant, ...tableGrants } = live.grants;
|
|
227
|
+
const { [role]: _cols, ...columnGrants } = live.columnGrants ?? {};
|
|
228
|
+
const { [role]: _effective, ...effectivePrivileges } = live.effectivePrivileges ?? {};
|
|
229
|
+
return { ...live, grants: tableGrants, columnGrants, policies: live.policies.filter(p => p !== ownPolicies[0]),
|
|
230
|
+
...(live.effectivePrivileges ? { effectivePrivileges } : {}) };
|
|
231
|
+
}
|
|
232
|
+
|
|
149
233
|
export function foldLifecycle(snapshot: SchemaSnapshot, contract: AuthzContract, catalog: DerivedCatalog): LifecycleFold {
|
|
150
234
|
const tables = new Set<string>();
|
|
151
235
|
const foldedContracts = new Map<string, TableContract>();
|
|
152
236
|
for (const table of snapshot.tables) {
|
|
153
237
|
if (table.primaryKey.length !== 1 || !columnsAreGenerated(table)) continue;
|
|
154
238
|
const live = contract.tables.find((t) => t.table === table.table);
|
|
155
|
-
const folded = live && live.rls.enabled ? foldContract(live) : undefined;
|
|
239
|
+
const folded = live && live.rls.enabled ? foldContract(live, table) : undefined;
|
|
156
240
|
if (!folded) continue;
|
|
157
241
|
tables.add(table.table);
|
|
158
242
|
foldedContracts.set(table.table, folded);
|
|
159
243
|
}
|
|
160
|
-
if (tables.size === 0) return { snapshot, contract, catalog, tables };
|
|
244
|
+
if (tables.size === 0) return { snapshot, contract, catalog, tables, deleteWhen: new Map() };
|
|
245
|
+
for (const table of snapshot.tables) {
|
|
246
|
+
if (tables.has(table.table)) continue;
|
|
247
|
+
const live = contract.tables.find(t => t.table === table.table);
|
|
248
|
+
const folded = live && foldReferenceContract(live, table, tables);
|
|
249
|
+
if (folded) foldedContracts.set(table.table, folded);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// A guarded table's predicate is the delete_when function's manifest input. The pull must render
|
|
253
|
+
// exactly that text, or the next plan would rebuild the guard; without it, refuse rather than drop
|
|
254
|
+
// the guard or render it as an author function.
|
|
255
|
+
const deleteWhen = new Map<string, string>();
|
|
256
|
+
for (const table of tables) {
|
|
257
|
+
const { schema, name } = split(table);
|
|
258
|
+
const fn = catalog.objects.find((o) => o.kind === 'function' && o.identity === `${schema}.${name}_lifecycle_delete_when()`);
|
|
259
|
+
if (!fn) continue;
|
|
260
|
+
let manifest: { role?: unknown; model?: unknown; deleteWhen?: unknown } | undefined;
|
|
261
|
+
try { manifest = typeof fn.comment === 'string' ? JSON.parse(fn.comment) : undefined; } catch { manifest = undefined; }
|
|
262
|
+
if (!manifest || manifest.role !== 'delete_when' || manifest.model !== table || typeof manifest.deleteWhen !== 'string' || manifest.deleteWhen.trim() === '') {
|
|
263
|
+
throw new Error(`db:pull: ${fn.identity} carries no readable deleteWhen in its lifecycle manifest, so ${table}'s lifecycle: { deleteWhen } `
|
|
264
|
+
+ `cannot be rendered exactly. Run db:apply from the checkout that declared it (it rewrites the manifest), then pull again.`);
|
|
265
|
+
}
|
|
266
|
+
deleteWhen.set(table, manifest.deleteWhen);
|
|
267
|
+
}
|
|
161
268
|
|
|
162
269
|
const generated = new Set(snapshot.tables.filter((t) => tables.has(t.table)).flatMap(generatedIdentities));
|
|
163
270
|
return {
|
|
164
271
|
snapshot: {
|
|
165
272
|
...snapshot,
|
|
166
|
-
tables: snapshot.tables.map((t) =>
|
|
167
|
-
tables.has(t.table) ? { ...t, columns: t.columns.filter((c) => !Object.hasOwn(GENERATED_COLUMNS, c.name)) } : t),
|
|
273
|
+
tables: snapshot.tables.map((t) => withoutGeneratedFkIndexes(
|
|
274
|
+
tables.has(t.table) ? { ...t, columns: t.columns.filter((c) => !Object.hasOwn(GENERATED_COLUMNS, c.name)) } : t, tables)),
|
|
168
275
|
},
|
|
169
276
|
contract: { ...contract, tables: contract.tables.map((t) => foldedContracts.get(t.table) ?? t) },
|
|
170
277
|
catalog: {
|
|
@@ -173,5 +280,6 @@ export function foldLifecycle(snapshot: SchemaSnapshot, contract: AuthzContract,
|
|
|
173
280
|
provenance: catalog.provenance.filter((p) => !generated.has(p.identity)),
|
|
174
281
|
},
|
|
175
282
|
tables,
|
|
283
|
+
deleteWhen,
|
|
176
284
|
};
|
|
177
285
|
}
|
|
@@ -19,9 +19,9 @@
|
|
|
19
19
|
* lands with whole-DB introspection. The emission is identical either way.
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
import { relationDeleteActions, type ModelDescriptor, type Module, type SequenceDescriptor } from '@everystack/model';
|
|
22
|
+
import { lifecycleDependentIndexes, relationDeleteActions, type ModelDescriptor, type Module, type SequenceDescriptor } from '@everystack/model';
|
|
23
23
|
import type { AuthzContract, TableContract } from './authz-contract';
|
|
24
|
-
import { compileCreateTable, compileEnums, compileSequences, sequenceCreateSql, foreignKeySql, modelConstraintSpecs, qualifiedTable, toSnakeCase, type CompileTableOptions } from './schema-compile';
|
|
24
|
+
import { compileCreateTable, compileEnums, compileSequences, sequenceCreateSql, foreignKeySql, modelConstraintSpecs, dependentIndexSpecs, qualifiedTable, toSnakeCase, type CompileTableOptions } from './schema-compile';
|
|
25
25
|
import { compileTableContract } from './authz-compile';
|
|
26
26
|
import { emitReconcileSql } from './authz-reconcile';
|
|
27
27
|
import { emitSchemaSql, nextvalSequence, type SchemaChange } from './schema-diff';
|
|
@@ -48,6 +48,7 @@ export function compileMigration(
|
|
|
48
48
|
// A parent's hasMany `dependent` sets a child FK's ON DELETE; resolved over the whole set
|
|
49
49
|
// from THIS set, before any FK is compiled.
|
|
50
50
|
const dependents = relationDeleteActions(models);
|
|
51
|
+
const dependentIndexes = lifecycleDependentIndexes(models);
|
|
51
52
|
|
|
52
53
|
// 0. Extensions — the BOOTSTRAP, before any table that might use one of their types.
|
|
53
54
|
// compileModuleMigration has always emitted these; compileMigration had no way to,
|
|
@@ -113,7 +114,7 @@ export function compileMigration(
|
|
|
113
114
|
// schema-qualified table, so a non-public index targets the right table.
|
|
114
115
|
const indexCreates: SchemaChange[] = [];
|
|
115
116
|
for (const model of models) {
|
|
116
|
-
for (const ix of modelConstraintSpecs(model).indexes) {
|
|
117
|
+
for (const ix of [...modelConstraintSpecs(model).indexes, ...dependentIndexSpecs(model, dependentIndexes)]) {
|
|
117
118
|
indexCreates.push({
|
|
118
119
|
kind: 'createIndex', table: qualifiedTable(model), name: ix.name, columns: ix.columns, unique: ix.unique,
|
|
119
120
|
...(ix.where ? { where: ix.where } : {}),
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import type { ModelDescriptor, SequenceDescriptor } from '@everystack/model';
|
|
19
|
-
import { assertExternalTargets, relationDeleteActions } from '@everystack/model';
|
|
19
|
+
import { assertExternalTargets, lifecycleDependentIndexes, relationDeleteActions } from '@everystack/model';
|
|
20
20
|
import type { SchemaSnapshot, ColumnSchema } from './schema-introspect';
|
|
21
21
|
import type { AuthzContract, SequenceGrantContract } from './authz-contract';
|
|
22
22
|
import { compileTableSchema, compileRenames, compileTableRenames, compileTableMoves, compileCreateTable, compileEnums, compileSequences } from './schema-compile';
|
|
@@ -144,12 +144,13 @@ export function generateMigration(
|
|
|
144
144
|
assertExternalTargets(models);
|
|
145
145
|
// hasMany `dependent` → the child FK's ON DELETE, derived from THIS set (and checked, for the same reason).
|
|
146
146
|
const dependents = relationDeleteActions(models);
|
|
147
|
+
const dependentIndexes = lifecycleDependentIndexes(models);
|
|
147
148
|
const schema = opts.schema ?? 'public';
|
|
148
149
|
// A table's schema comes from the model first (package tables live in dist/ops/auth),
|
|
149
150
|
// then the call default — so one run spans schemas.
|
|
150
151
|
const schemaOf = (m: ModelDescriptor): string => m.schema ?? schema;
|
|
151
152
|
const desiredEnums = compileEnums(models);
|
|
152
|
-
const desired: SchemaSnapshot = { tables: models.map((m) => compileTableSchema(m, { schema, dependents })), enums: desiredEnums, ...(opts.sequences?.length ? { sequences: compileSequences(opts.sequences) } : {}) };
|
|
153
|
+
const desired: SchemaSnapshot = { tables: models.map((m) => compileTableSchema(m, { schema, dependents, dependentIndexes })), enums: desiredEnums, ...(opts.sequences?.length ? { sequences: compileSequences(opts.sequences) } : {}) };
|
|
153
154
|
const declaredTables = new Set(desired.tables.map((t) => t.table));
|
|
154
155
|
const declaredEnums = new Set(desiredEnums.map((e) => e.name));
|
|
155
156
|
// Scope both tables and enums to what the Models declare — an undeclared table OR enum
|
package/src/cli/model-api.ts
CHANGED
|
@@ -23,6 +23,15 @@ export * from './model-render';
|
|
|
23
23
|
export * from './migration-compile';
|
|
24
24
|
export * from './migration-generate';
|
|
25
25
|
|
|
26
|
+
// --- the DECLARED state of a model set, as introspection would see it -------
|
|
27
|
+
// `compileDeclaredState(models, { schema })` → `{ snapshot, contract }`: the same compilation
|
|
28
|
+
// db:plan, db:check and the fingerprint use. Diff `.snapshot` against `introspectSchema(...)`
|
|
29
|
+
// to verify a database built from the Models. Prefer it to mapping `compileTableSchema` over
|
|
30
|
+
// the set yourself: the set derives table parts no single Model carries (the hasMany ON DELETE,
|
|
31
|
+
// the generated `<table>_<column>_lifecycle_fk_idx` indexes), and a hand-rolled loop that misses
|
|
32
|
+
// one reads the live object as a drop.
|
|
33
|
+
export { compileDeclaredState, type DeclaredState } from './declared-diff';
|
|
34
|
+
|
|
26
35
|
// --- authz layer: contract compile/introspect/diff/reconcile ---------------
|
|
27
36
|
export * from './authz-compile';
|
|
28
37
|
export * from './authz-contract';
|
package/src/cli/model-render.ts
CHANGED
|
@@ -112,6 +112,10 @@ export interface RenderOptions {
|
|
|
112
112
|
externalDerived?: ExternalDerived;
|
|
113
113
|
/** Tables db:pull folded to `lifecycle: true` (lifecycle-pull.ts): rendered with that key, no softDelete line. */
|
|
114
114
|
lifecycleTables?: ReadonlySet<string>;
|
|
115
|
+
/** Lifecycle children detected even when the default pull leaves their columns unfolded. */
|
|
116
|
+
lifecycleForeignKeyTables?: ReadonlySet<string>;
|
|
117
|
+
/** lifecycle: { deleteWhen }'s predicate per folded table (lifecycle-pull.ts), rendered verbatim. */
|
|
118
|
+
lifecycleDeleteWhen?: ReadonlyMap<string, string>;
|
|
115
119
|
}
|
|
116
120
|
|
|
117
121
|
/**
|
|
@@ -391,6 +395,48 @@ function knownSet(tables: Iterable<string>): Set<string> {
|
|
|
391
395
|
return new Set([...tables].map(qualifiedName));
|
|
392
396
|
}
|
|
393
397
|
|
|
398
|
+
/** A key into a rendered lifecycle parent, declared on that parent as a hasMany dependent. */
|
|
399
|
+
interface RenderedDependent { child: string; column: string; dependent: 'cascade' | 'restrict' | 'nullify' }
|
|
400
|
+
const DEPENDENT_OF: Record<string, RenderedDependent['dependent']> = { cascade: 'cascade', 'set null': 'nullify', restrict: 'restrict' };
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Every one-column foreign key from a table rendered `lifecycle: true` into another, grouped by the
|
|
404
|
+
* parent: the parent declares each one as a dependent (a key into a lifecycle table with none is
|
|
405
|
+
* refused), read from the live ON DELETE: CASCADE -> 'cascade', SET NULL -> 'nullify', RESTRICT or
|
|
406
|
+
* NO ACTION -> 'restrict'. A plain table's key into one has no dependent (only a lifecycle table may
|
|
407
|
+
* hold one): it renders as it is, with a comment, and the model's own refusal names it. So do SET
|
|
408
|
+
* DEFAULT and composite keys.
|
|
409
|
+
*/
|
|
410
|
+
function lifecycleDependentsOf(tables: readonly TableSchema[], lifecycleTables: ReadonlySet<string> | undefined): Map<string, RenderedDependent[]> {
|
|
411
|
+
const out = new Map<string, RenderedDependent[]>();
|
|
412
|
+
const rendered = new Set([...(lifecycleTables ?? [])].map(qualifiedName));
|
|
413
|
+
const known = knownSet(tables.map((t) => t.table));
|
|
414
|
+
for (const table of tables) {
|
|
415
|
+
for (const fk of table.foreignKeys) {
|
|
416
|
+
const parent = qualifiedName(fk.refTable);
|
|
417
|
+
const dependent = DEPENDENT_OF[fk.onDelete ?? 'restrict'];
|
|
418
|
+
if (fk.columns.length !== 1 || !dependent || !rendered.has(parent) || !known.has(parent) || !rendered.has(qualifiedName(table.table))) continue;
|
|
419
|
+
out.set(parent, [...(out.get(parent) ?? []), { child: qualifiedName(table.table), column: fk.columns[0], dependent }]);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
return out;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** The parent's `relations: { … }` stanza for its dependents ('' when none). Thunks, like `.references()`: a pulled barrel is not linked. */
|
|
426
|
+
function dependentsStanza(table: TableSchema, dependents: readonly RenderedDependent[]): string {
|
|
427
|
+
if (dependents.length === 0) return '';
|
|
428
|
+
const fieldKeys = new Set(table.columns.map((c) => toCamelCase(c.name)));
|
|
429
|
+
const base = (d: RenderedDependent): string => toCamelCase(schemaOf(d.child) === 'public' ? bareName(d.child) : `${schemaOf(d.child)}_${bareName(d.child)}`);
|
|
430
|
+
const lines = [...dependents].sort((a, b) => (`${a.child}\u0000${a.column}` < `${b.child}\u0000${b.column}` ? -1 : 1)).map((d) => {
|
|
431
|
+
const field = toCamelCase(d.column);
|
|
432
|
+
const plain = base(d);
|
|
433
|
+
const shared = dependents.filter((o) => base(o) === plain).length > 1 || fieldKeys.has(plain);
|
|
434
|
+
const key = shared ? `${plain}By${field[0].toUpperCase()}${field.slice(1)}` : plain;
|
|
435
|
+
return ` ${safeKey(key, `the relation for ${liveObject('table', d.child)}`)}: hasMany(() => ${modelVarName(d.child)}, ${tsLiteral(field)}, { dependent: ${tsLiteral(d.dependent)} }),`;
|
|
436
|
+
});
|
|
437
|
+
return `\n relations: {\n${lines.join('\n')}\n },`;
|
|
438
|
+
}
|
|
439
|
+
|
|
394
440
|
/**
|
|
395
441
|
* A table name → its model variable: PascalCase, naively singularized (one trailing
|
|
396
442
|
* `s` strips, whatever the word). `image_variants` → `ImageVariant`.
|
|
@@ -423,7 +469,7 @@ export function modelVarName(table: string): string {
|
|
|
423
469
|
* phantom rename for every digit-boundary column and COLLIDED distinct columns
|
|
424
470
|
* (`a_1` and `a1`) onto one key.
|
|
425
471
|
*/
|
|
426
|
-
function toCamelCase(name: string): string {
|
|
472
|
+
export function toCamelCase(name: string): string {
|
|
427
473
|
return name.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
428
474
|
}
|
|
429
475
|
|
|
@@ -792,7 +838,17 @@ function renderConstraintsBlock(table: TableSchema, checks: CheckConstraint[], k
|
|
|
792
838
|
}
|
|
793
839
|
|
|
794
840
|
/** One `export const X = defineModel(...)` block for a table. */
|
|
795
|
-
export function renderModelBlock(table: TableSchema, known: Set<string>, enums: Map<string, string[]> = new Map(), abilities = 'commented', liveAuthz?: Map<string, TableContract>, governRoles?: ReadonlySet<string>, fkTargets?: FkTargets, lifecycle = false): string {
|
|
841
|
+
export function renderModelBlock(table: TableSchema, known: Set<string>, enums: Map<string, string[]> = new Map(), abilities = 'commented', liveAuthz?: Map<string, TableContract>, governRoles?: ReadonlySet<string>, fkTargets?: FkTargets, lifecycle: boolean | string = false, lifecycleForeignKeys = Boolean(lifecycle), dependents: ReadonlyMap<string, readonly RenderedDependent[]> = new Map(), lifecycleTables: ReadonlySet<string> = new Set()): string {
|
|
842
|
+
const self = qualifiedName(table.table);
|
|
843
|
+
const asParent = dependents.get(self) ?? [];
|
|
844
|
+
// A key a lifecycle parent declares as a dependent carries no onDelete of its own: the dependent derives it.
|
|
845
|
+
const declared = new Set([...dependents.values()].flat().filter((d) => d.child === self).map((d) => d.column));
|
|
846
|
+
// Copy for rendering only; the raw catalog still owns the adoption fingerprint.
|
|
847
|
+
if (declared.size) table = { ...table, foreignKeys: table.foreignKeys.map(fk =>
|
|
848
|
+
fk.columns.length === 1 && declared.has(fk.columns[0]) ? { ...fk, onDelete: undefined } : fk) };
|
|
849
|
+
// A pull cannot infer the parent relation that makes any other lifecycle cascade legal.
|
|
850
|
+
if (lifecycleForeignKeys) table = { ...table, foreignKeys: table.foreignKeys.map(fk =>
|
|
851
|
+
fk.onDelete === 'cascade' ? { ...fk, onDelete: 'restrict' } : fk) };
|
|
796
852
|
// A CHECK that reverses to a single field's .validate() is rendered on the field (ergonomic);
|
|
797
853
|
// the rest stay table-level check(). Both round-trip — this only chooses the nicer form.
|
|
798
854
|
const validates = new Map<string, string>();
|
|
@@ -811,14 +867,24 @@ export function renderModelBlock(table: TableSchema, known: Set<string>, enums:
|
|
|
811
867
|
}
|
|
812
868
|
|
|
813
869
|
const pulled = knownSet(known);
|
|
814
|
-
|
|
870
|
+
// A plain table's key into a table rendered lifecycle: the models refuse it; say why and the fix.
|
|
871
|
+
const intoLifecycle = new Set(lifecycle ? [] : table.foreignKeys
|
|
872
|
+
.filter((fk) => fk.columns.length === 1 && lifecycleTables.has(qualifiedName(fk.refTable)))
|
|
873
|
+
.map((fk) => fk.columns[0]));
|
|
874
|
+
const fields = table.columns.map((c) => (intoLifecycle.has(c.name)
|
|
875
|
+
? ` // ${commentText(`${c.name} references a lifecycle table, and only a lifecycle table may hold a key into one: make this model lifecycle: true (and declare the dependent on the parent), or refer to it by value (drop .references())`)}\n`
|
|
876
|
+
: '') + renderField(table, c, pulled, enums, validates, fkTargets)).join('\n');
|
|
815
877
|
const constraints = renderConstraintsBlock(table, tableChecks, pulled, fkTargets);
|
|
816
878
|
// The authz decision renders FIRST — before fields — because it is the first thing a
|
|
817
879
|
// reviewer must resolve about a model (and where the field-report consumer's codemod
|
|
818
880
|
// put it, proving the position is mechanical-edit-friendly).
|
|
819
881
|
const stanza = abilitiesStanza(abilities, table, liveAuthz, governRoles);
|
|
820
882
|
const writtenBy = writtenByStanza(table, liveAuthz);
|
|
821
|
-
|
|
883
|
+
// A guarded table renders its predicate inside sql`…`, escaped so the template's cooked string
|
|
884
|
+
// is the predicate text exactly (the model stores it verbatim; the generated guard hashes it).
|
|
885
|
+
const softDelete = typeof lifecycle === 'string'
|
|
886
|
+
? ` lifecycle: { deleteWhen: sql\`${lifecycle.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${')}\` },\n`
|
|
887
|
+
: lifecycle ? ' lifecycle: true,\n' : softDeleteStanza(table, stanza.publicRead, stanza.grantsDelete);
|
|
822
888
|
|
|
823
889
|
// A non-public table carries `schema:` — defineModel stores the name VERBATIM, so the
|
|
824
890
|
// qualification cannot ride in the first argument (that would make the table literally
|
|
@@ -828,7 +894,7 @@ export function renderModelBlock(table: TableSchema, known: Set<string>, enums:
|
|
|
828
894
|
// already uses for defineMaterializedTable.
|
|
829
895
|
const owner = schemaOf(table.table);
|
|
830
896
|
const schemaProp = owner === 'public' ? '' : ` schema: ${tsLiteral(owner)},\n`;
|
|
831
|
-
return `export const ${modelVarName(table.table)} = defineModel(${tsLiteral(bareName(table.table))}, {\n${stanza.text}\n${schemaProp}${writtenBy}${softDelete} fields: {\n${fields}\n },${constraints}\n});`;
|
|
897
|
+
return `export const ${modelVarName(table.table)} = defineModel(${tsLiteral(bareName(table.table))}, {\n${stanza.text}\n${schemaProp}${writtenBy}${softDelete} fields: {\n${fields}\n },${constraints}${dependentsStanza(table, asParent)}\n});`;
|
|
832
898
|
}
|
|
833
899
|
|
|
834
900
|
/** The `import` lines a rendered body needs — only what it actually uses, so the file reads clean. */
|
|
@@ -841,6 +907,7 @@ function importHeader(body: string, extra: string[] = []): string {
|
|
|
841
907
|
if (/\bcheck\(/.test(body)) symbols.push('check');
|
|
842
908
|
if (/\bindex\(/.test(body)) symbols.push('index');
|
|
843
909
|
if (/\bforeignKey\(/.test(body)) symbols.push('foreignKey');
|
|
910
|
+
if (/\bhasMany\(/.test(body)) symbols.push('hasMany');
|
|
844
911
|
if (/\bdefineModule\(/.test(body)) symbols.push('defineModule');
|
|
845
912
|
if (/\bsql`/.test(body)) symbols.push('sql');
|
|
846
913
|
for (const s of extra) if (!symbols.includes(s)) symbols.push(s);
|
|
@@ -945,7 +1012,9 @@ export function renderModelSource(snapshot: SchemaSnapshot, opts: RenderOptions
|
|
|
945
1012
|
const known = knownSet(tables.map((t) => t.table));
|
|
946
1013
|
const enums = new Map((snapshot.enums ?? []).map((e) => [e.name, e.values]));
|
|
947
1014
|
|
|
948
|
-
const
|
|
1015
|
+
const dependents = lifecycleDependentsOf(tables, opts.lifecycleTables);
|
|
1016
|
+
const rendered = new Set([...(opts.lifecycleTables ?? [])].map(qualifiedName));
|
|
1017
|
+
const blocks = tables.map((t) => renderModelBlock(t, known, enums, opts.abilities ?? 'commented', opts.liveAuthz, opts.governRoles && new Set(opts.governRoles), opts.fkTargets, opts.lifecycleDeleteWhen?.get(t.table) ?? opts.lifecycleTables?.has(t.table), opts.lifecycleForeignKeyTables?.has(t.table) || opts.lifecycleTables?.has(t.table), dependents, rendered));
|
|
949
1018
|
// The derived layer (B5): sequences + views/matviews/functions, after the models they
|
|
950
1019
|
// reference, before the module wrapper that composes all three.
|
|
951
1020
|
if (opts.derived?.block) blocks.push(opts.derived.block);
|
|
@@ -1021,9 +1090,12 @@ export function renderModelFiles(snapshot: SchemaSnapshot, opts: RenderOptions =
|
|
|
1021
1090
|
const known = knownSet(tables.map((t) => t.table));
|
|
1022
1091
|
const enums = new Map((snapshot.enums ?? []).map((e) => [e.name, e.values]));
|
|
1023
1092
|
|
|
1093
|
+
const dependents = lifecycleDependentsOf(tables, opts.lifecycleTables);
|
|
1094
|
+
const rendered = new Set([...(opts.lifecycleTables ?? [])].map(qualifiedName));
|
|
1024
1095
|
const files: RenderedModelFile[] = tables.map((t) => {
|
|
1025
|
-
const block = renderModelBlock(t, known, enums, opts.abilities ?? 'commented', opts.liveAuthz, opts.governRoles && new Set(opts.governRoles), opts.fkTargets, opts.lifecycleTables?.has(t.table));
|
|
1026
|
-
const
|
|
1096
|
+
const block = renderModelBlock(t, known, enums, opts.abilities ?? 'commented', opts.liveAuthz, opts.governRoles && new Set(opts.governRoles), opts.fkTargets, opts.lifecycleDeleteWhen?.get(t.table) ?? opts.lifecycleTables?.has(t.table), opts.lifecycleForeignKeyTables?.has(t.table) || opts.lifecycleTables?.has(t.table), dependents, rendered);
|
|
1097
|
+
const children = (dependents.get(qualifiedName(t.table)) ?? []).map((d) => d.child).filter((c) => c !== qualifiedName(t.table));
|
|
1098
|
+
const crossImports = [...new Set([...referencedTables(t, known), ...children])].sort().map(
|
|
1027
1099
|
(target) => `import { ${modelVarName(target)} } from '${modelImportPath(target)}';`,
|
|
1028
1100
|
);
|
|
1029
1101
|
const header = [importHeader(block), ...crossImports].join('\n');
|
package/src/cli/plain-output.ts
CHANGED
|
@@ -17,6 +17,9 @@
|
|
|
17
17
|
* ⚠ ACCESS db:plan only: one summary line when the plan removes a policy, revokes a
|
|
18
18
|
* grant, turns row security off, or removes a function, view or trigger. Not destructive (no --confirm), but such a
|
|
19
19
|
* plan is never called "Safe to apply".
|
|
20
|
+
* ⚠ LOCK db:plan only: one line per plain CREATE INDEX on a live table with rows (it
|
|
21
|
+
* blocks writes while it builds), with the row estimate. Informational: the plan
|
|
22
|
+
* is still "Safe to apply", needs no --confirm and exits the same (index-lock.ts).
|
|
20
23
|
* ⚠ CONSTRAINT db:plan only: one line per primary key the plan replaces, naming the old key.
|
|
21
24
|
* Read from the replacement note the emitter writes from the change itself
|
|
22
25
|
* (schema-diff's parsePrimaryKeyReplacement), never from the DDL. Not
|
|
@@ -36,6 +39,7 @@
|
|
|
36
39
|
|
|
37
40
|
import { statementBody } from './sql-statement';
|
|
38
41
|
import { parsePrimaryKeyReplacement } from './schema-diff';
|
|
42
|
+
import { lockLines, type LockWarning } from './index-lock';
|
|
39
43
|
import type { ReconcileAction, ReconcilePlan } from './derived-plan';
|
|
40
44
|
import type { DerivedKind } from './derived-source';
|
|
41
45
|
|
|
@@ -440,6 +444,10 @@ export interface PlanReportInput {
|
|
|
440
444
|
* Absent, or with nothing that changes an object, the report is byte-identical to one without it.
|
|
441
445
|
*/
|
|
442
446
|
derived?: Pick<ReconcilePlan, 'actions' | 'regrants'>;
|
|
447
|
+
/** The plan's ⚠ LOCK data (EdgePlan.lockWarnings). Informational; absent or empty prints nothing. */
|
|
448
|
+
lockWarnings?: readonly LockWarning[];
|
|
449
|
+
/** The plan's deleted-row reads (EdgePlan.deletedReads): one ⚠ ACCESS line each. */
|
|
450
|
+
deletedReads?: readonly { table: string; role: string }[];
|
|
443
451
|
}
|
|
444
452
|
|
|
445
453
|
// ---------------------------------------------------------------------------
|
|
@@ -617,12 +625,17 @@ export function renderPlanReport(input: PlanReportInput): string[] {
|
|
|
617
625
|
|
|
618
626
|
const keys = constraintLines(executable);
|
|
619
627
|
lines.push(...keys);
|
|
628
|
+
lines.push(...lockLines(input.lockWarnings ?? []));
|
|
620
629
|
const access = accessLine(accessChanges([...executable, ...derived.statements]), derived.removed);
|
|
621
630
|
if (access) lines.push(access);
|
|
631
|
+
// can('read', { deleted }): a role that can now read deleted rows. Not destructive (no --confirm);
|
|
632
|
+
// like every other ACCESS line, the plan is then not called "Safe to apply".
|
|
633
|
+
const deletedReads = (input.deletedReads ?? []).map((d) => `${ACCESS_MARK} — role ${d.role} can read deleted rows of ${d.table}.`);
|
|
634
|
+
lines.push(...deletedReads);
|
|
622
635
|
lines.push(
|
|
623
636
|
destructive.size > 0
|
|
624
637
|
? `Verify the destructive ${plural(destructive.size, 'change', 'changes')}, then run db:backup before applying.`
|
|
625
|
-
: access
|
|
638
|
+
: access || deletedReads.length > 0
|
|
626
639
|
? 'Non-destructive. Verify the access changes before applying.'
|
|
627
640
|
: keys.length > 0
|
|
628
641
|
? 'Non-destructive. Verify the replaced keys before applying.'
|
|
@@ -19,8 +19,9 @@
|
|
|
19
19
|
* Input is a `@everystack/model` ModelDescriptor (type-only — no runtime dep).
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
import
|
|
23
|
-
import {
|
|
22
|
+
import { createHash } from 'node:crypto';
|
|
23
|
+
import type { ModelDescriptor, FieldSpec, SequenceDescriptor, DependentActions, DependentIndexes } from '@everystack/model';
|
|
24
|
+
import { pgType, positionalCheckName } from '@everystack/model';
|
|
24
25
|
import type { TableSchema, EnumType, SequenceSchema, UniqueConstraint, CheckConstraint, IndexSchema, ForeignKey } from './schema-introspect';
|
|
25
26
|
import { nextvalSequence, identityClause, primaryKeyConstraintName, type RenameMap } from './schema-diff';
|
|
26
27
|
import { normalizeDeparsedExpr } from './deparse-normal';
|
|
@@ -54,6 +55,33 @@ export function generatedIndexName(table: string, columns: string[]): string {
|
|
|
54
55
|
return `${table}_${parts.join('_')}_index`;
|
|
55
56
|
}
|
|
56
57
|
|
|
58
|
+
/**
|
|
59
|
+
* The name of a generated lifecycle FK index: `<table>_<columns>_lifecycle_fk_idx`. The suffix keeps
|
|
60
|
+
* it apart from author names (an author's generated name ends `_index`) and marks it generated, so
|
|
61
|
+
* db:pull folds it. Over PostgreSQL's 63 bytes it keeps 56 and appends `_` and 6 hex of a sha256 of
|
|
62
|
+
* the full name, branch-db's convention, so two long names never truncate to one.
|
|
63
|
+
*/
|
|
64
|
+
export function lifecycleFkIndexName(table: string, columns: readonly string[]): string {
|
|
65
|
+
const name = `${table}_${columns.join('_')}_lifecycle_fk_idx`;
|
|
66
|
+
if (Buffer.byteLength(name) <= 63) return name;
|
|
67
|
+
// 56 BYTES, never inside a character: a multi-byte name cut by characters overran 63 bytes, and
|
|
68
|
+
// PostgreSQL then truncated it to a name the generator never planned. ASCII cuts as before.
|
|
69
|
+
let prefix = '';
|
|
70
|
+
for (const ch of name) {
|
|
71
|
+
if (Buffer.byteLength(prefix + ch) > 56) break;
|
|
72
|
+
prefix += ch;
|
|
73
|
+
}
|
|
74
|
+
return `${prefix}_${createHash('sha256').update(name).digest('hex').slice(0, 6)}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The set's generated FK indexes for one model (`lifecycleDependentIndexes(models)`), as IR. */
|
|
78
|
+
export function dependentIndexSpecs(model: ModelDescriptor, dependentIndexes?: DependentIndexes): IndexSchema[] {
|
|
79
|
+
return (dependentIndexes?.get(model) ?? []).map((keys) => {
|
|
80
|
+
const columns = keys.map(toSnakeCase);
|
|
81
|
+
return { name: lifecycleFkIndexName(model.table, columns), columns, unique: false };
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
57
85
|
export function modelConstraintSpecs(model: ModelDescriptor): { uniques: UniqueConstraint[]; checks: CheckConstraint[]; indexes: IndexSchema[] } {
|
|
58
86
|
const uniques: UniqueConstraint[] = [];
|
|
59
87
|
const checks: CheckConstraint[] = [];
|
|
@@ -126,69 +154,8 @@ export function modelConstraintSpecs(model: ModelDescriptor): { uniques: UniqueC
|
|
|
126
154
|
return { uniques, checks, indexes };
|
|
127
155
|
}
|
|
128
156
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
* `format_type`'s vocabulary, because this feeds the IR that diffs against
|
|
132
|
-
* introspection. A serial field's *column type* is integer/bigint (`serial` is
|
|
133
|
-
* DDL shorthand, not a type — `format_type` never reports it); the sequence
|
|
134
|
-
* default is carried separately (see compileTableSchema), and the DDL renderers
|
|
135
|
-
* spell the shorthand (see SERIAL_DDL).
|
|
136
|
-
*/
|
|
137
|
-
const PG_TYPE: Record<string, string> = {
|
|
138
|
-
text: 'text',
|
|
139
|
-
uuid: 'uuid',
|
|
140
|
-
integer: 'integer',
|
|
141
|
-
boolean: 'boolean',
|
|
142
|
-
json: 'json',
|
|
143
|
-
jsonb: 'jsonb',
|
|
144
|
-
bigint: 'bigint',
|
|
145
|
-
serial: 'integer',
|
|
146
|
-
bigserial: 'bigint',
|
|
147
|
-
real: 'real',
|
|
148
|
-
doublePrecision: 'double precision',
|
|
149
|
-
timestamptz: 'timestamp with time zone',
|
|
150
|
-
// `format_type` canonicalizes a bare `timestamp` column to this spelling, and
|
|
151
|
-
// introspection compares against it — so the compiler must emit the same, or an
|
|
152
|
-
// unchanged `timestamp` column round-trips to a spurious ALTER COLUMN TYPE.
|
|
153
|
-
timestamp: 'timestamp without time zone',
|
|
154
|
-
date: 'date',
|
|
155
|
-
};
|
|
156
|
-
|
|
157
|
-
export function pgType(spec: FieldSpec): string {
|
|
158
|
-
// An array is the base type with `[]` appended — `text` → `text[]`, exactly as
|
|
159
|
-
// `format_type` spells it.
|
|
160
|
-
return spec.isArray ? `${scalarPgType(spec)}[]` : scalarPgType(spec);
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
function scalarPgType(spec: FieldSpec): string {
|
|
164
|
-
// Parameterized types build their own canonical string — exactly as `format_type`
|
|
165
|
-
// spells it (no space after the comma; `varchar` is `character varying`; a
|
|
166
|
-
// precision-only numeric carries scale 0), so an unchanged column round-trips clean.
|
|
167
|
-
if (spec.type === 'numeric') {
|
|
168
|
-
if (spec.precision != null) return `numeric(${spec.precision},${spec.scale ?? 0})`;
|
|
169
|
-
return 'numeric';
|
|
170
|
-
}
|
|
171
|
-
if (spec.type === 'varchar') {
|
|
172
|
-
return spec.length != null ? `character varying(${spec.length})` : 'character varying';
|
|
173
|
-
}
|
|
174
|
-
if (spec.type === 'enum') {
|
|
175
|
-
// The column's type is the enum's own type name; `format_type` reports the same bare
|
|
176
|
-
// name, so it round-trips. The `CREATE TYPE` is emitted separately, before the table.
|
|
177
|
-
if (!spec.enumName) throw new Error('field bridge: an enum field needs a type name — field.enum(name, values)');
|
|
178
|
-
return spec.enumName;
|
|
179
|
-
}
|
|
180
|
-
if (spec.type === 'pgType') {
|
|
181
|
-
// A verbatim type: the declared string IS the canonical `format_type` spelling
|
|
182
|
-
// (the factory refused anything else), so it round-trips byte-for-byte.
|
|
183
|
-
if (!spec.pgTypeName) throw new Error('field bridge: a pgType field needs its type name — field.pgType(name)');
|
|
184
|
-
return spec.pgTypeName;
|
|
185
|
-
}
|
|
186
|
-
const t = PG_TYPE[spec.type];
|
|
187
|
-
if (!t) {
|
|
188
|
-
throw new Error(`field bridge: no DDL mapping for field type '${spec.type}' yet`);
|
|
189
|
-
}
|
|
190
|
-
return t;
|
|
191
|
-
}
|
|
157
|
+
// Shared with the runtime destroy wrapper: casts and generated signatures must agree.
|
|
158
|
+
export { pgType } from '@everystack/model';
|
|
192
159
|
|
|
193
160
|
/**
|
|
194
161
|
* The FK referential actions declared on a field or a table-level `foreignKey(...)`, dropping
|
|
@@ -547,7 +514,7 @@ export function compileCreateTable(model: ModelDescriptor, opts: CompileTableOpt
|
|
|
547
514
|
* structured form for the brownfield diff. They share the field→type/default/check
|
|
548
515
|
* helpers, so they never disagree.)
|
|
549
516
|
*/
|
|
550
|
-
export function compileTableSchema(model: ModelDescriptor, opts: { schema?: string; dependents?: DependentActions } = {}): TableSchema {
|
|
517
|
+
export function compileTableSchema(model: ModelDescriptor, opts: { schema?: string; dependents?: DependentActions; dependentIndexes?: DependentIndexes } = {}): TableSchema {
|
|
551
518
|
const schema = model.schema ?? opts.schema ?? 'public';
|
|
552
519
|
const entries = Object.entries(model.fields);
|
|
553
520
|
|
|
@@ -607,7 +574,7 @@ export function compileTableSchema(model: ModelDescriptor, opts: { schema?: stri
|
|
|
607
574
|
uniques,
|
|
608
575
|
checks,
|
|
609
576
|
foreignKeys: modelForeignKeys(model, opts.dependents),
|
|
610
|
-
indexes: tableConstraints.indexes,
|
|
577
|
+
indexes: [...tableConstraints.indexes, ...dependentIndexSpecs(model, opts.dependentIndexes)],
|
|
611
578
|
};
|
|
612
579
|
}
|
|
613
580
|
|