@everystack/cli 0.2.39 → 0.3.3
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/README.md +28 -5
- package/package.json +12 -2
- package/src/cli/authz-compile.ts +364 -0
- package/src/cli/authz-contract-io.ts +92 -0
- package/src/cli/authz-contract.ts +589 -0
- package/src/cli/authz-owner-probe.ts +218 -0
- package/src/cli/authz-reconcile.ts +152 -0
- package/src/cli/authz-redteam.ts +251 -0
- package/src/cli/authz-render.ts +248 -0
- package/src/cli/aws.ts +100 -0
- package/src/cli/backup.ts +99 -0
- package/src/cli/commands/db-authz.ts +300 -0
- package/src/cli/commands/db-backup.ts +218 -0
- package/src/cli/commands/db-generate.ts +211 -0
- package/src/cli/commands/db-pull.ts +83 -0
- package/src/cli/commands/db-snapshot.ts +113 -0
- package/src/cli/commands/deploy.ts +46 -0
- package/src/cli/config.ts +8 -0
- package/src/cli/index.ts +68 -0
- package/src/cli/migration-compile.ts +133 -0
- package/src/cli/migration-generate.ts +178 -0
- package/src/cli/model-api.ts +36 -0
- package/src/cli/model-render.ts +262 -0
- package/src/cli/pg-ident.ts +59 -0
- package/src/cli/rds-snapshot.ts +47 -0
- package/src/cli/schema-compile.ts +496 -0
- package/src/cli/schema-diff.ts +608 -0
- package/src/cli/schema-introspect.ts +425 -0
- package/src/cli/schema-source.ts +416 -0
- package/src/cli/security-audit.ts +7 -0
- package/src/cli/security-catalog.ts +16 -1
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `compileDrizzleSource` — the typed-runtime-table slice of the one generator.
|
|
3
|
+
*
|
|
4
|
+
* The sibling of `compileTableSchema` (SQL DDL) and `compileTableContract` (RLS):
|
|
5
|
+
* those compile a {@link ModelDescriptor} to a migration target; this compiles it
|
|
6
|
+
* to drizzle-orm TypeScript SOURCE TEXT. The emitted `schema.generated.ts`, when
|
|
7
|
+
* imported, yields the SAME runtime table `toDrizzleTable(model)` builds — but as
|
|
8
|
+
* literal, fully-typed declarations, so the SSR loaders keep their precise column
|
|
9
|
+
* types (`toDrizzleTable`'s loop-built table infers `<any>`; literal source does
|
|
10
|
+
* not). One hand-authored source (`models/*.ts`), the drizzle schema a generated,
|
|
11
|
+
* drift-guarded artifact.
|
|
12
|
+
*
|
|
13
|
+
* The column mapping mirrors `toDrizzleTable` exactly, expressed as text. The two
|
|
14
|
+
* documented divergences from a hand-written schema (see docs/plans/model-drizzle-codegen.md):
|
|
15
|
+
*
|
|
16
|
+
* - Cross-schema FKs (e.g. `profiles.id → auth.users.id`) are OMITTED — the model
|
|
17
|
+
* can't express a reference to a non-Model table, so neither can the emitter.
|
|
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.
|
|
21
|
+
*
|
|
22
|
+
* Pure and deterministic: imports sorted, declarations in model order, relation
|
|
23
|
+
* keys in declared order — re-running on the same models is byte-identical.
|
|
24
|
+
*
|
|
25
|
+
* Input is a `@everystack/model` ModelDescriptor (type-only — no runtime dep).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import type { ModelDescriptor, FieldSpec } from '@everystack/model';
|
|
29
|
+
|
|
30
|
+
/** `author_id` is the SQL identifier; `authorId` the field key. The single snake-case rule. */
|
|
31
|
+
function toSnakeCase(name: string): string {
|
|
32
|
+
return name.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** `image_variants` → `imageVariants` (a snake table name to its camelCase drizzle export var). */
|
|
36
|
+
function toCamelCase(name: string): string {
|
|
37
|
+
return name.replace(/_([a-z0-9])/g, (_, c: string) => c.toUpperCase());
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A JS/TS string literal — single-quoted, the example's style. */
|
|
41
|
+
function strLiteral(value: string): string {
|
|
42
|
+
return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A `.default(<v>)` argument literal for a value default (the JSON forms drizzle accepts). */
|
|
46
|
+
function defaultValueLiteral(value: unknown): string {
|
|
47
|
+
if (typeof value === 'string') return strLiteral(value);
|
|
48
|
+
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
|
49
|
+
if (value === null) return 'null';
|
|
50
|
+
if (typeof value === 'number') return String(value);
|
|
51
|
+
// Arrays / objects: a JSON literal is valid TS for jsonb defaults.
|
|
52
|
+
return JSON.stringify(value);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The drizzle pg-core column builder *call* for a field — `text('bio')`,
|
|
57
|
+
* `timestamp('created_at', { withTimezone: true })`, `uuid('id')` — exactly the
|
|
58
|
+
* type + args `toDrizzleTable`'s `baseColumn` produces, as source text. Returns
|
|
59
|
+
* the call and the builder name it used (so the import set can be collected).
|
|
60
|
+
*/
|
|
61
|
+
function baseColumnSource(columnName: string, spec: FieldSpec): { call: string; builder: string } {
|
|
62
|
+
const n = strLiteral(columnName);
|
|
63
|
+
switch (spec.type) {
|
|
64
|
+
case 'text':
|
|
65
|
+
return { call: `text(${n})`, builder: 'text' };
|
|
66
|
+
case 'uuid':
|
|
67
|
+
return { call: `uuid(${n})`, builder: 'uuid' };
|
|
68
|
+
case 'integer':
|
|
69
|
+
return { call: `integer(${n})`, builder: 'integer' };
|
|
70
|
+
case 'bigint':
|
|
71
|
+
return { call: `bigint(${n}, { mode: 'number' })`, builder: 'bigint' };
|
|
72
|
+
case 'bigserial':
|
|
73
|
+
return { call: `bigserial(${n}, { mode: 'number' })`, builder: 'bigserial' };
|
|
74
|
+
case 'boolean':
|
|
75
|
+
return { call: `boolean(${n})`, builder: 'boolean' };
|
|
76
|
+
case 'timestamptz':
|
|
77
|
+
return { call: `timestamp(${n}, { withTimezone: true })`, builder: 'timestamp' };
|
|
78
|
+
case 'timestamp':
|
|
79
|
+
return { call: `timestamp(${n})`, builder: 'timestamp' };
|
|
80
|
+
case 'date':
|
|
81
|
+
return { call: `date(${n})`, builder: 'date' };
|
|
82
|
+
case 'numeric':
|
|
83
|
+
return spec.precision !== undefined
|
|
84
|
+
? { call: `numeric(${n}, { precision: ${spec.precision}, scale: ${spec.scale} })`, builder: 'numeric' }
|
|
85
|
+
: { call: `numeric(${n})`, builder: 'numeric' };
|
|
86
|
+
case 'varchar':
|
|
87
|
+
return spec.length !== undefined
|
|
88
|
+
? { call: `varchar(${n}, { length: ${spec.length} })`, builder: 'varchar' }
|
|
89
|
+
: { call: `varchar(${n})`, builder: 'varchar' };
|
|
90
|
+
case 'jsonb':
|
|
91
|
+
return { call: `jsonb(${n})`, builder: 'jsonb' };
|
|
92
|
+
case 'enum': {
|
|
93
|
+
if (!spec.enumName) throw new Error('enum field is missing enumName');
|
|
94
|
+
return { call: `${enumConstName(spec.enumName)}(${n})`, builder: enumConstName(spec.enumName) };
|
|
95
|
+
}
|
|
96
|
+
default: {
|
|
97
|
+
const exhaustive: never = spec.type;
|
|
98
|
+
throw new Error(`Unsupported field type: ${String(exhaustive)}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The hoisted const name for an enum type. `status` → `statusEnum`. */
|
|
104
|
+
function enumConstName(enumName: string): string {
|
|
105
|
+
return `${toCamelCase(enumName)}Enum`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The single primary-key field key for a model (FK targets reference its column). */
|
|
109
|
+
function primaryKeyKey(model: ModelDescriptor): string {
|
|
110
|
+
const pk = model.primaryKey[0];
|
|
111
|
+
if (!pk) throw new Error(`Referenced model "${model.table}" has no primary key`);
|
|
112
|
+
return pk;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** A model's `field key → snake column name` map (for relation `fields:`/`references:` text). */
|
|
116
|
+
function columnKeys(model: ModelDescriptor): Map<string, string> {
|
|
117
|
+
const m = new Map<string, string>();
|
|
118
|
+
for (const key of Object.keys(model.fields)) m.set(key, toSnakeCase(key));
|
|
119
|
+
return m;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The modifier chain for a column, in `toDrizzleTable` order: notNull, unique,
|
|
124
|
+
* default, primaryKey (single-PK only), references. Cross-schema references are
|
|
125
|
+
* impossible to model, so only in-set targets are emitted. `usedBuilders` collects
|
|
126
|
+
* the table-extra/import builders the references clause needs (none today).
|
|
127
|
+
*/
|
|
128
|
+
function columnModifiers(
|
|
129
|
+
spec: FieldSpec,
|
|
130
|
+
isComposite: boolean,
|
|
131
|
+
modelsByDescriptor: Map<ModelDescriptor, { camel: string }>,
|
|
132
|
+
): string {
|
|
133
|
+
let s = '';
|
|
134
|
+
if (spec.isArray) s += '.array()';
|
|
135
|
+
if (spec.isNotNull) s += '.notNull()';
|
|
136
|
+
if (spec.isUnique) s += '.unique()';
|
|
137
|
+
|
|
138
|
+
if (spec.defaultKind === 'now') s += '.defaultNow()';
|
|
139
|
+
else if (spec.defaultKind === 'random') s += '.defaultRandom()';
|
|
140
|
+
else if (spec.defaultKind === 'value') s += `.default(${defaultValueLiteral(spec.default)})`;
|
|
141
|
+
|
|
142
|
+
if (spec.isPrimaryKey && !isComposite) s += '.primaryKey()';
|
|
143
|
+
|
|
144
|
+
if (spec.references) {
|
|
145
|
+
const target = (spec.references as () => ModelDescriptor)();
|
|
146
|
+
const entry = modelsByDescriptor.get(target);
|
|
147
|
+
if (entry) {
|
|
148
|
+
const parentPk = primaryKeyKey(target);
|
|
149
|
+
const opts: string[] = [];
|
|
150
|
+
if (spec.onDelete) opts.push(`onDelete: ${strLiteral(spec.onDelete)}`);
|
|
151
|
+
if (spec.onUpdate) opts.push(`onUpdate: ${strLiteral(spec.onUpdate)}`);
|
|
152
|
+
const optsText = opts.length ? `, { ${opts.join(', ')} }` : '';
|
|
153
|
+
s += `.references(() => ${entry.camel}.${parentPk}${optsText})`;
|
|
154
|
+
}
|
|
155
|
+
// A reference to a model OUTSIDE the emitted set (a cross-schema FK) is omitted —
|
|
156
|
+
// the runtime + relations never needed it; the DB constraint stays unmanaged.
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return s;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** An unordered table-pair key, so `(follows, profiles)` and `(profiles, follows)` collide. */
|
|
163
|
+
function pairKey(a: string, b: string): string {
|
|
164
|
+
return a < b ? `${a}\x00${b}` : `${b}\x00${a}`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* A relation that needs a `relationName` carries one keyed by `(childTable,
|
|
169
|
+
* fkColumnKey)` — the same string on the belongsTo (child) and the inverse hasMany
|
|
170
|
+
* (parent), so drizzle pairs them. Derived from the model's OWN relation graph:
|
|
171
|
+
* a belongsTo names `(thisTable, column)`; a hasMany names `(targetTable, column)`
|
|
172
|
+
* — both resolve to the child table holding the FK and the FK column key, so the
|
|
173
|
+
* two sides agree without coordination.
|
|
174
|
+
*/
|
|
175
|
+
interface RelationNaming {
|
|
176
|
+
/** All synthesized names, keyed by `(childTable, fkColumnKey)`. */
|
|
177
|
+
needsName: Set<string>;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function relationPairId(childTable: string, fkColumnKey: string): string {
|
|
181
|
+
return `${childTable}\x00${fkColumnKey}`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function synthesizedName(childTable: string, fkColumnKey: string): string {
|
|
185
|
+
return `${childTable}_${toSnakeCase(fkColumnKey)}`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Decide which relations need a `relationName`: only pairs of tables connected by
|
|
190
|
+
* MORE than one relation. Counts every relation (both sides) per unordered table
|
|
191
|
+
* pair; a pair counted more than once flags every relation between those two tables.
|
|
192
|
+
*/
|
|
193
|
+
function planRelationNames(models: ModelDescriptor[]): RelationNaming {
|
|
194
|
+
// Ambiguity is per DISTINCT FK column connecting a table pair — NOT per relation
|
|
195
|
+
// declaration. profiles<->posts is one FK (author_id) declared from both sides
|
|
196
|
+
// (belongsTo + its inverse hasMany): drizzle pairs them by table, no name needed.
|
|
197
|
+
// profiles<->follows is TWO FKs (follower_id, following_id): drizzle cannot tell
|
|
198
|
+
// which hasMany pairs which belongsTo, so every relation on that pair needs a name.
|
|
199
|
+
//
|
|
200
|
+
// Each relation resolves to a (childTable, fkColumnKey) — the child table holds the
|
|
201
|
+
// FK column. Count distinct such pairs per unordered table pair.
|
|
202
|
+
const fkColumnsByPair = new Map<string, Set<string>>();
|
|
203
|
+
for (const model of models) {
|
|
204
|
+
for (const rel of Object.values(model.relations)) {
|
|
205
|
+
const other = rel.target().table;
|
|
206
|
+
const childTable = rel.kind === 'belongsTo' ? model.table : other;
|
|
207
|
+
const k = pairKey(model.table, other);
|
|
208
|
+
let set = fkColumnsByPair.get(k);
|
|
209
|
+
if (!set) {
|
|
210
|
+
set = new Set<string>();
|
|
211
|
+
fkColumnsByPair.set(k, set);
|
|
212
|
+
}
|
|
213
|
+
set.add(relationPairId(childTable, rel.column));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const needsName = new Set<string>();
|
|
218
|
+
for (const model of models) {
|
|
219
|
+
for (const rel of Object.values(model.relations)) {
|
|
220
|
+
const other = rel.target().table;
|
|
221
|
+
const k = pairKey(model.table, other);
|
|
222
|
+
// Ambiguous when more than one distinct FK column connects the two tables.
|
|
223
|
+
if ((fkColumnsByPair.get(k)?.size ?? 0) <= 1) continue;
|
|
224
|
+
const childTable = rel.kind === 'belongsTo' ? model.table : other;
|
|
225
|
+
needsName.add(relationPairId(childTable, rel.column));
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return { needsName };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** The `relationName: '…'` fragment for a relation, or '' when the pair is unambiguous. */
|
|
232
|
+
function relationNameFragment(
|
|
233
|
+
model: ModelDescriptor,
|
|
234
|
+
rel: { kind: 'belongsTo' | 'hasMany'; column: string; target: () => ModelDescriptor },
|
|
235
|
+
naming: RelationNaming,
|
|
236
|
+
): string {
|
|
237
|
+
const other = rel.target().table;
|
|
238
|
+
const childTable = rel.kind === 'belongsTo' ? model.table : other;
|
|
239
|
+
const id = relationPairId(childTable, rel.column);
|
|
240
|
+
if (!naming.needsName.has(id)) return '';
|
|
241
|
+
return `, relationName: ${strLiteral(synthesizedName(childTable, rel.column))}`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export interface CompileDrizzleSourceOptions {
|
|
245
|
+
/** Reserved for parity with the DDL compiler's `schema` option. Unused in the emit. */
|
|
246
|
+
schema?: string;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Emit the full text of a `schema.generated.ts` from a set of Models: hoisted
|
|
251
|
+
* `pgEnum`s, one `pgTable` per model (columns + table-extras), then a
|
|
252
|
+
* `relations()` block per model that declares any. The output is what an app's
|
|
253
|
+
* hand-written `db/schema.ts` is replaced by — fully-typed, derived, drift-guarded.
|
|
254
|
+
*/
|
|
255
|
+
export function compileDrizzleSource(allModels: ModelDescriptor[], _opts: CompileDrizzleSourceOptions = {}): string {
|
|
256
|
+
// A private model is package-owned — its drizzle runtime table ships with the
|
|
257
|
+
// package (e.g. @everystack/storage/schema), so it never appears in the app's
|
|
258
|
+
// generated schema. (The migration generator still emits its SQL + authz.) The
|
|
259
|
+
// command and the drift guard both call this, so filtering here keeps them in sync.
|
|
260
|
+
const models = allModels.filter((m) => !m.private);
|
|
261
|
+
|
|
262
|
+
const modelsByDescriptor = new Map<ModelDescriptor, { camel: string }>();
|
|
263
|
+
for (const model of models) modelsByDescriptor.set(model, { camel: toCamelCase(model.table) });
|
|
264
|
+
|
|
265
|
+
// --- Collect enums (deduped by name, sorted) -----------------------------
|
|
266
|
+
const enumsByName = new Map<string, readonly string[]>();
|
|
267
|
+
for (const model of models) {
|
|
268
|
+
for (const f of Object.values(model.fields)) {
|
|
269
|
+
const { type, enumName, enumValues } = f.spec;
|
|
270
|
+
if (type !== 'enum' || !enumName) continue;
|
|
271
|
+
const values = enumValues ?? [];
|
|
272
|
+
const existing = enumsByName.get(enumName);
|
|
273
|
+
if (existing && existing.join(',') !== values.join(',')) {
|
|
274
|
+
throw new Error(`enum '${enumName}' is declared with conflicting values: [${existing}] vs [${values}]`);
|
|
275
|
+
}
|
|
276
|
+
if (!existing) enumsByName.set(enumName, values);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
const enumNames = [...enumsByName.keys()].sort((a, b) => a.localeCompare(b));
|
|
280
|
+
|
|
281
|
+
// --- Track which pg-core builders + drizzle-orm symbols are used ----------
|
|
282
|
+
const pgCoreBuilders = new Set<string>();
|
|
283
|
+
pgCoreBuilders.add('pgTable');
|
|
284
|
+
if (enumNames.length) pgCoreBuilders.add('pgEnum');
|
|
285
|
+
|
|
286
|
+
const relationNaming = planRelationNames(models);
|
|
287
|
+
const anyRelations = models.some((m) => Object.keys(m.relations).length > 0);
|
|
288
|
+
|
|
289
|
+
// --- Emit each table -------------------------------------------------------
|
|
290
|
+
const tableBlocks: string[] = [];
|
|
291
|
+
for (const model of models) {
|
|
292
|
+
const camel = toCamelCase(model.table);
|
|
293
|
+
const isComposite = model.primaryKey.length > 1;
|
|
294
|
+
|
|
295
|
+
const colLines: string[] = [];
|
|
296
|
+
for (const [key, builder] of Object.entries(model.fields)) {
|
|
297
|
+
const spec = builder.spec;
|
|
298
|
+
const { call, builder: pgBuilder } = baseColumnSource(toSnakeCase(key), spec);
|
|
299
|
+
pgCoreBuilders.add(pgBuilder);
|
|
300
|
+
const mods = columnModifiers(spec, isComposite, modelsByDescriptor);
|
|
301
|
+
colLines.push(` ${key}: ${call}${mods},`);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Table-extras: composite PK, then table-level unique/check constraints.
|
|
305
|
+
const extras: string[] = [];
|
|
306
|
+
if (isComposite) {
|
|
307
|
+
pgCoreBuilders.add('primaryKey');
|
|
308
|
+
const cols = model.primaryKey.map((k) => `t.${k}`).join(', ');
|
|
309
|
+
extras.push(` primaryKey({ columns: [${cols}] }),`);
|
|
310
|
+
}
|
|
311
|
+
for (const con of model.constraints) {
|
|
312
|
+
if (con.kind === 'unique') {
|
|
313
|
+
pgCoreBuilders.add('unique');
|
|
314
|
+
const cols = con.columns.map((k) => `t.${k}`).join(', ');
|
|
315
|
+
extras.push(` unique().on(${cols}),`);
|
|
316
|
+
} else if (con.kind === 'check') {
|
|
317
|
+
pgCoreBuilders.add('check');
|
|
318
|
+
pgCoreBuilders.add('sql'); // imported from 'drizzle-orm', handled below
|
|
319
|
+
extras.push(` check(${strLiteral(`${model.table}_check`)}, sql.raw(${strLiteral(con.predicate)})),`);
|
|
320
|
+
}
|
|
321
|
+
// 'index' / 'foreignKey' table constraints are not materialized on the runtime
|
|
322
|
+
// drizzle table object (mirrors toDrizzleTable), so they are omitted here.
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const cols = `{\n${colLines.join('\n')}\n}`;
|
|
326
|
+
let block: string;
|
|
327
|
+
if (extras.length) {
|
|
328
|
+
block = `export const ${camel} = pgTable(${strLiteral(model.table)}, ${cols}, (t) => [\n${extras.join('\n')}\n]);`;
|
|
329
|
+
} else {
|
|
330
|
+
block = `export const ${camel} = pgTable(${strLiteral(model.table)}, ${cols});`;
|
|
331
|
+
}
|
|
332
|
+
tableBlocks.push(block);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// --- Emit relations() blocks ----------------------------------------------
|
|
336
|
+
const relationBlocks: string[] = [];
|
|
337
|
+
for (const model of models) {
|
|
338
|
+
const relEntries = Object.entries(model.relations);
|
|
339
|
+
if (relEntries.length === 0) continue;
|
|
340
|
+
const camel = toCamelCase(model.table);
|
|
341
|
+
|
|
342
|
+
const usesOne = relEntries.some(([, r]) => r.kind === 'belongsTo');
|
|
343
|
+
const usesMany = relEntries.some(([, r]) => r.kind === 'hasMany');
|
|
344
|
+
const helpers = [usesOne ? 'one' : null, usesMany ? 'many' : null].filter(Boolean).join(', ');
|
|
345
|
+
|
|
346
|
+
const lines: string[] = [];
|
|
347
|
+
for (const [key, rel] of relEntries) {
|
|
348
|
+
const target = rel.target();
|
|
349
|
+
const targetCamel = toCamelCase(target.table);
|
|
350
|
+
const nameFrag = relationNameFragment(model, rel, relationNaming);
|
|
351
|
+
if (rel.kind === 'belongsTo') {
|
|
352
|
+
// belongsTo(() => Target, column, references?) -> one(...)
|
|
353
|
+
const thisCol = rel.column; // FK FIELD KEY on this model
|
|
354
|
+
const targetPk = rel.references ?? primaryKeyKey(target);
|
|
355
|
+
lines.push(
|
|
356
|
+
` ${key}: one(${targetCamel}, { fields: [${camel}.${thisCol}], references: [${targetCamel}.${targetPk}]${nameFrag} }),`,
|
|
357
|
+
);
|
|
358
|
+
} else {
|
|
359
|
+
// hasMany(() => Target, column, references?) -> many(...)
|
|
360
|
+
if (nameFrag) {
|
|
361
|
+
lines.push(` ${key}: many(${targetCamel}, {${nameFrag.replace(/^, /, ' ')} }),`);
|
|
362
|
+
} else {
|
|
363
|
+
lines.push(` ${key}: many(${targetCamel}),`);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
relationBlocks.push(
|
|
369
|
+
`export const ${camel}Relations = relations(${camel}, ({ ${helpers} }) => ({\n${lines.join('\n')}\n}));`,
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// --- Header ----------------------------------------------------------------
|
|
374
|
+
const header = [
|
|
375
|
+
'// GENERATED by `everystack db:generate` from models/. Do not edit.',
|
|
376
|
+
'//',
|
|
377
|
+
'// Cross-schema foreign keys (e.g. profiles.id -> auth.users.id) are intentionally',
|
|
378
|
+
'// omitted: a Model cannot reference a table outside the model set, so the runtime',
|
|
379
|
+
'// table and its relations() never carry that FK. The DB constraint stays untouched.',
|
|
380
|
+
].join('\n');
|
|
381
|
+
|
|
382
|
+
// --- Imports ---------------------------------------------------------------
|
|
383
|
+
// pg-core: sorted, enum consts are not imports (they're declared below), `sql`
|
|
384
|
+
// comes from 'drizzle-orm', not pg-core — strip both from the pg-core set.
|
|
385
|
+
const enumConsts = new Set(enumNames.map(enumConstName));
|
|
386
|
+
const usesSqlRaw = pgCoreBuilders.has('sql');
|
|
387
|
+
const pgCoreNames = [...pgCoreBuilders].filter((b) => !enumConsts.has(b) && b !== 'sql').sort((a, b) => a.localeCompare(b));
|
|
388
|
+
|
|
389
|
+
const importLines: string[] = [];
|
|
390
|
+
importLines.push(`import { ${pgCoreNames.join(', ')} } from 'drizzle-orm/pg-core';`);
|
|
391
|
+
|
|
392
|
+
const drizzleOrmNames: string[] = [];
|
|
393
|
+
if (anyRelations) drizzleOrmNames.push('relations');
|
|
394
|
+
if (usesSqlRaw) drizzleOrmNames.push('sql');
|
|
395
|
+
drizzleOrmNames.sort((a, b) => a.localeCompare(b));
|
|
396
|
+
if (drizzleOrmNames.length) {
|
|
397
|
+
importLines.push(`import { ${drizzleOrmNames.join(', ')} } from 'drizzle-orm';`);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// --- Enum declarations -----------------------------------------------------
|
|
401
|
+
const enumDecls = enumNames.map((name) => {
|
|
402
|
+
const values = enumsByName.get(name) ?? [];
|
|
403
|
+
const valsText = values.map((v) => strLiteral(v)).join(', ');
|
|
404
|
+
return `export const ${enumConstName(name)} = pgEnum(${strLiteral(name)}, [${valsText}]);`;
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
// --- Assemble --------------------------------------------------------------
|
|
408
|
+
const sections: string[] = [];
|
|
409
|
+
sections.push(header);
|
|
410
|
+
sections.push(importLines.join('\n'));
|
|
411
|
+
if (enumDecls.length) sections.push(enumDecls.join('\n'));
|
|
412
|
+
for (const block of tableBlocks) sections.push(block);
|
|
413
|
+
for (const block of relationBlocks) sections.push(block);
|
|
414
|
+
|
|
415
|
+
return sections.join('\n\n') + '\n';
|
|
416
|
+
}
|
|
@@ -36,6 +36,13 @@ export interface FunctionDescriptor {
|
|
|
36
36
|
securityDefiner: boolean;
|
|
37
37
|
/** True when the function pins `SET search_path`. */
|
|
38
38
|
hasSearchPath: boolean;
|
|
39
|
+
/** Owning role (catalog path only — static SQL parsing can't know it). */
|
|
40
|
+
owner?: string;
|
|
41
|
+
/**
|
|
42
|
+
* True when the owner runs above RLS (superuser, BYPASSRLS, or rds_superuser member).
|
|
43
|
+
* A SECURITY DEFINER function owned by such a role is the maximum blast radius.
|
|
44
|
+
*/
|
|
45
|
+
ownerBypassesRls?: boolean;
|
|
39
46
|
}
|
|
40
47
|
|
|
41
48
|
export interface FunctionFinding {
|
|
@@ -32,7 +32,18 @@ SELECT
|
|
|
32
32
|
EXISTS (
|
|
33
33
|
SELECT 1 FROM unnest(coalesce(p.proconfig, '{}'::text[])) cfg
|
|
34
34
|
WHERE lower(cfg) LIKE 'search_path=%'
|
|
35
|
-
) AS has_search_path
|
|
35
|
+
) AS has_search_path,
|
|
36
|
+
pg_get_userbyid(p.proowner) AS owner,
|
|
37
|
+
-- Does the owner run above RLS? A SECURITY DEFINER function owned by such a role is
|
|
38
|
+
-- the maximum blast radius — search_path pinning is necessary but not sufficient.
|
|
39
|
+
-- RDS has no true superuser (rolsuper=false), so also check rds_superuser membership.
|
|
40
|
+
(
|
|
41
|
+
SELECT r.rolsuper OR r.rolbypassrls OR EXISTS (
|
|
42
|
+
SELECT 1 FROM pg_auth_members m JOIN pg_roles g ON g.oid = m.roleid
|
|
43
|
+
WHERE m.member = p.proowner AND g.rolname = 'rds_superuser'
|
|
44
|
+
)
|
|
45
|
+
FROM pg_roles r WHERE r.oid = p.proowner
|
|
46
|
+
) AS owner_bypasses_rls
|
|
36
47
|
FROM pg_proc p
|
|
37
48
|
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
38
49
|
JOIN pg_type t ON t.oid = p.prorettype
|
|
@@ -111,6 +122,8 @@ interface FunctionRow {
|
|
|
111
122
|
returns_set: unknown;
|
|
112
123
|
return_typtype: unknown;
|
|
113
124
|
has_search_path: unknown;
|
|
125
|
+
owner?: unknown;
|
|
126
|
+
owner_bypasses_rls?: unknown;
|
|
114
127
|
}
|
|
115
128
|
|
|
116
129
|
/** Map a pg_proc row to a descriptor, computing a precise return class. */
|
|
@@ -132,6 +145,8 @@ export function catalogFunctionToDescriptor(row: FunctionRow): FunctionDescripto
|
|
|
132
145
|
returnClassHint,
|
|
133
146
|
securityDefiner: truthy(row.security_definer),
|
|
134
147
|
hasSearchPath: truthy(row.has_search_path),
|
|
148
|
+
owner: row.owner != null ? String(row.owner) : undefined,
|
|
149
|
+
ownerBypassesRls: row.owner_bypasses_rls != null ? truthy(row.owner_bypasses_rls) : undefined,
|
|
135
150
|
};
|
|
136
151
|
}
|
|
137
152
|
|