@everystack/cli 0.3.0 → 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/package.json +2 -2
- package/src/cli/authz-compile.ts +186 -34
- package/src/cli/authz-owner-probe.ts +22 -4
- package/src/cli/authz-reconcile.ts +25 -0
- package/src/cli/commands/db-authz.ts +13 -5
- package/src/cli/commands/db-generate.ts +79 -1
- package/src/cli/migration-compile.ts +71 -7
- package/src/cli/migration-generate.ts +4 -1
- package/src/cli/model-api.ts +1 -0
- package/src/cli/schema-compile.ts +66 -9
- package/src/cli/schema-diff.ts +4 -1
- package/src/cli/schema-source.ts +416 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/cli",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "CLI and OTA updates for Expo apps on everystack",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"author": "Scalable Technology, Inc. <licensing@scalable.technology>",
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
"node-forge": "1.4.0",
|
|
66
66
|
"structured-headers": "1.0.1",
|
|
67
67
|
"tsx": "4.21.0",
|
|
68
|
-
"@everystack/model": "0.3.
|
|
68
|
+
"@everystack/model": "0.3.3"
|
|
69
69
|
},
|
|
70
70
|
"peerDependencies": {
|
|
71
71
|
"@everystack/server": ">=0.1.0",
|
package/src/cli/authz-compile.ts
CHANGED
|
@@ -75,6 +75,22 @@ function softDeleteGuard(sqlColumn: string): string {
|
|
|
75
75
|
return `(${sqlColumn} IS NULL)`;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
/**
|
|
79
|
+
* The transitive-owner (`via:`) predicate, in deparser normal form — a parent
|
|
80
|
+
* subquery that scopes the child to rows whose FK points at a parent the caller
|
|
81
|
+
* owns. The exact whitespace (3 spaces before FROM, 2 before WHERE, the newlines)
|
|
82
|
+
* is what `pg_get_expr` emits and the contract stores, so it is reproduced byte
|
|
83
|
+
* for byte; the inner owner predicate reuses {@link ownerPredicate} (table-qualified):
|
|
84
|
+
*
|
|
85
|
+
* (upload_id IN ( SELECT uploads.id
|
|
86
|
+
* FROM uploads
|
|
87
|
+
* WHERE (uploads.user_id = ((current_setting('request.jwt.claims'::text, true))::jsonb ->> 'sub'::text))))
|
|
88
|
+
*/
|
|
89
|
+
function viaPredicate(v: ViaRef): string {
|
|
90
|
+
const parentOwner = ownerPredicate(`${v.parentTable}.${v.parentOwner.sqlColumn}`, v.parentOwner.type, v.parentOwner.claim);
|
|
91
|
+
return `(${v.fkColumn} IN ( SELECT ${v.parentTable}.${v.parentPk}\n FROM ${v.parentTable}\n WHERE ${parentOwner}))`;
|
|
92
|
+
}
|
|
93
|
+
|
|
78
94
|
// ---------------------------------------------------------------------------
|
|
79
95
|
// Ability classification.
|
|
80
96
|
// ---------------------------------------------------------------------------
|
|
@@ -96,10 +112,18 @@ interface OwnerRef {
|
|
|
96
112
|
export function modelOwner(model: ModelDescriptor, opts: CompileOptions = {}): { table: string; ownerColumn: string; claim: string } | null {
|
|
97
113
|
const owner = resolveOwner(model);
|
|
98
114
|
if (!owner) return null;
|
|
99
|
-
const schema = opts
|
|
115
|
+
const schema = resolveSchema(model, opts);
|
|
100
116
|
return { table: `${schema}.${model.table}`, ownerColumn: owner.sqlColumn, claim: owner.claim };
|
|
101
117
|
}
|
|
102
118
|
|
|
119
|
+
/**
|
|
120
|
+
* The schema a model's table lives in. The model's own `schema` wins (a 'dist'
|
|
121
|
+
* model is dist in any run), then the generate call's default, then 'public'.
|
|
122
|
+
*/
|
|
123
|
+
function resolveSchema(model: ModelDescriptor, opts: CompileOptions): string {
|
|
124
|
+
return model.schema ?? opts.schema ?? 'public';
|
|
125
|
+
}
|
|
126
|
+
|
|
103
127
|
/** Resolve the owner column (SQL name + type + claim) from the first owner ability. */
|
|
104
128
|
function resolveOwner(model: ModelDescriptor): OwnerRef | null {
|
|
105
129
|
const ownerAbility = model.abilities.find((a) => a.condition.owner);
|
|
@@ -112,11 +136,79 @@ function resolveOwner(model: ModelDescriptor): OwnerRef | null {
|
|
|
112
136
|
};
|
|
113
137
|
}
|
|
114
138
|
|
|
139
|
+
interface ViaRef {
|
|
140
|
+
/** The child's FK column, snake_cased (e.g. `upload_id`). */
|
|
141
|
+
fkColumn: string;
|
|
142
|
+
/** The referenced parent table (e.g. `uploads`). */
|
|
143
|
+
parentTable: string;
|
|
144
|
+
/** The parent's primary-key column, snake_cased (e.g. `id`). */
|
|
145
|
+
parentPk: string;
|
|
146
|
+
/** The parent's resolved owner — the predicate scopes the subquery on this. */
|
|
147
|
+
parentOwner: OwnerRef;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Resolve the transitive-owner spec from the first `via:` ability. The named field
|
|
152
|
+
* must carry `.references()`; the compiler follows that thunk to the parent
|
|
153
|
+
* descriptor and resolves the parent's own `owner` ability. Throws a clear error
|
|
154
|
+
* when the field has no FK or the parent declares no owner — a `via:` that cannot
|
|
155
|
+
* reach an owner is a model bug, not a silently-empty policy.
|
|
156
|
+
*/
|
|
157
|
+
function resolveVia(model: ModelDescriptor): ViaRef | null {
|
|
158
|
+
const viaAbility = model.abilities.find((a) => a.condition.via);
|
|
159
|
+
if (!viaAbility) return null;
|
|
160
|
+
const fieldKey = viaAbility.condition.via!;
|
|
161
|
+
const ref = model.fields[fieldKey]?.spec.references;
|
|
162
|
+
if (!ref) {
|
|
163
|
+
throw new Error(`via: '${fieldKey}' on '${model.table}' must name a field declared with .references()`);
|
|
164
|
+
}
|
|
165
|
+
const parent = ref() as ModelDescriptor;
|
|
166
|
+
const parentOwner = resolveOwner(parent);
|
|
167
|
+
if (!parentOwner) {
|
|
168
|
+
throw new Error(`via: parent model '${parent.table}' (from '${model.table}.${fieldKey}') declares no owner ability`);
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
fkColumn: toSnakeCase(fieldKey),
|
|
172
|
+
parentTable: parent.table,
|
|
173
|
+
parentPk: toSnakeCase(parent.primaryKey[0] ?? 'id'),
|
|
174
|
+
parentOwner,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
115
178
|
/** The conventional soft-delete column SQL name, when the model has one. */
|
|
116
179
|
function softDeleteColumn(model: ModelDescriptor): string | null {
|
|
117
180
|
return 'deletedAt' in model.fields ? 'deleted_at' : null;
|
|
118
181
|
}
|
|
119
182
|
|
|
183
|
+
/**
|
|
184
|
+
* A column-scoped self read — `can('read', { owner, columns })`. It compiles to a COLUMN
|
|
185
|
+
* grant (`GRANT SELECT (cols)`) plus a `<table>_select_self` policy, NOT a table grant and
|
|
186
|
+
* NOT a `_select_own` policy: the row is scoped by the owner predicate, the fields by the
|
|
187
|
+
* column list (`auth.users` → id/email/role to the owner, never the password hash). It is
|
|
188
|
+
* excluded from the table-grant and owner-read paths so the two never double-emit.
|
|
189
|
+
*/
|
|
190
|
+
function isColumnRead(a: Ability): boolean {
|
|
191
|
+
return a.action === 'read' && Boolean(a.condition.owner) && Boolean(a.condition.columns?.length);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Column grants from the column-scoped read abilities: the read role (`authenticated`, since
|
|
196
|
+
* the ability is owner-scoped, not role-gated) gets `SELECT (cols)`. Field keys are snake_cased
|
|
197
|
+
* to SQL columns and sorted, matching the introspected contract. Returns undefined when no
|
|
198
|
+
* ability declares columns — an introspected contract omits the key rather than carry an empty
|
|
199
|
+
* object, so the compiled one must too (or the round-trip diff would false-fire).
|
|
200
|
+
*/
|
|
201
|
+
function compileColumnGrants(abilities: readonly Ability[]): Record<string, Record<string, string[]>> | undefined {
|
|
202
|
+
const out: Record<string, Record<string, string[]>> = {};
|
|
203
|
+
for (const a of abilities) {
|
|
204
|
+
if (!isColumnRead(a)) continue;
|
|
205
|
+
const role = a.condition.role ?? 'authenticated';
|
|
206
|
+
const cols = [...a.condition.columns!].map(toSnakeCase).sort();
|
|
207
|
+
(out[role] ??= {}).SELECT = cols;
|
|
208
|
+
}
|
|
209
|
+
return Object.keys(out).length ? out : undefined;
|
|
210
|
+
}
|
|
211
|
+
|
|
120
212
|
// ---------------------------------------------------------------------------
|
|
121
213
|
// Compile.
|
|
122
214
|
// ---------------------------------------------------------------------------
|
|
@@ -127,20 +219,34 @@ function softDeleteColumn(model: ModelDescriptor): string | null {
|
|
|
127
219
|
* output is byte-comparable with an introspected contract.
|
|
128
220
|
*/
|
|
129
221
|
export function compileTableContract(model: ModelDescriptor, opts: CompileOptions = {}): TableContract {
|
|
130
|
-
const schema = opts
|
|
222
|
+
const schema = resolveSchema(model, opts);
|
|
131
223
|
const table = `${schema}.${model.table}`;
|
|
132
224
|
const owner = resolveOwner(model);
|
|
225
|
+
const via = resolveVia(model);
|
|
133
226
|
const sdCol = softDeleteColumn(model);
|
|
134
|
-
|
|
227
|
+
// The row-scoping predicate: a direct owner column, else a transitive `via:`
|
|
228
|
+
// parent subquery. Both drive the same owner-gated policies (select/insert/
|
|
229
|
+
// update/delete_own); only the predicate text differs.
|
|
230
|
+
const rowPred = owner
|
|
231
|
+
? ownerPredicate(owner.sqlColumn, owner.type, owner.claim)
|
|
232
|
+
: via
|
|
233
|
+
? viaPredicate(via)
|
|
234
|
+
: null;
|
|
135
235
|
const sdGuard = sdCol ? softDeleteGuard(sdCol) : null;
|
|
136
236
|
|
|
237
|
+
// A "row-scoped" condition is either a direct owner or a transitive via.
|
|
238
|
+
const rowScoped = (a: Ability): boolean => Boolean(a.condition.owner || a.condition.via);
|
|
239
|
+
|
|
137
240
|
const abilities = model.abilities;
|
|
138
241
|
const hasAdminManage = abilities.some((a) => a.action === 'manage' && a.condition.role === 'admin');
|
|
139
|
-
const hasPublicRead = abilities.some((a) => a.action === 'read' && !a.condition.role && !a
|
|
140
|
-
|
|
242
|
+
const hasPublicRead = abilities.some((a) => a.action === 'read' && !a.condition.role && !rowScoped(a));
|
|
243
|
+
// A column-scoped read is row-scoped but emits a self policy + column grant, not the
|
|
244
|
+
// full-row owner read — so it is excluded here and handled by its own branch.
|
|
245
|
+
const hasOwnerRead = abilities.some((a) => a.action === 'read' && rowScoped(a) && !isColumnRead(a));
|
|
246
|
+
const hasColumnRead = abilities.some(isColumnRead);
|
|
141
247
|
const canCreate = abilities.some((a) => a.action === 'create');
|
|
142
|
-
const hasUpdateOwner = abilities.some((a) => a.action === 'update' && a
|
|
143
|
-
const hasDeleteOwner = abilities.some((a) => a.action === 'delete' && a
|
|
248
|
+
const hasUpdateOwner = abilities.some((a) => a.action === 'update' && rowScoped(a));
|
|
249
|
+
const hasDeleteOwner = abilities.some((a) => a.action === 'delete' && rowScoped(a));
|
|
144
250
|
|
|
145
251
|
const t = model.table;
|
|
146
252
|
const policies: PolicyContract[] = [];
|
|
@@ -161,51 +267,97 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
161
267
|
policy(`${t}_select_anon`, 'SELECT', ['anon'], anonUsing, null);
|
|
162
268
|
|
|
163
269
|
let authedUsing = anonUsing;
|
|
164
|
-
if (sdGuard &&
|
|
165
|
-
else if (
|
|
270
|
+
if (sdGuard && rowPred) authedUsing = `(${sdGuard} OR ${rowPred})`;
|
|
271
|
+
else if (rowPred && !sdGuard) authedUsing = rowPred;
|
|
166
272
|
policy(`${t}_select_authenticated`, 'SELECT', ['authenticated'], authedUsing, null);
|
|
167
|
-
} else if (hasOwnerRead &&
|
|
168
|
-
// owner-
|
|
169
|
-
|
|
273
|
+
} else if (hasOwnerRead && rowPred) {
|
|
274
|
+
// owner-scoped read — no anon visibility; you see only the rows you own
|
|
275
|
+
// (directly, or transitively through a `via:` parent).
|
|
276
|
+
policy(`${t}_select_own`, 'SELECT', ['authenticated'], rowPred, null);
|
|
277
|
+
} else if (hasColumnRead && rowPred) {
|
|
278
|
+
// column-scoped self read — `authenticated` reads only its OWN row, and only the
|
|
279
|
+
// granted columns (the GRANT scopes fields; this policy scopes rows). Named
|
|
280
|
+
// `_select_self`, distinct from the full-row `_select_own`.
|
|
281
|
+
policy(`${t}_select_self`, 'SELECT', ['authenticated'], rowPred, null);
|
|
170
282
|
}
|
|
171
283
|
|
|
172
284
|
// owner-gated writes. INSERT checks ownership (you can only create rows you own);
|
|
173
|
-
// UPDATE gates + checks; DELETE gates.
|
|
174
|
-
if (
|
|
175
|
-
if (canCreate) policy(`${t}_insert_own`, 'INSERT', ['authenticated'], null,
|
|
176
|
-
if (hasUpdateOwner) policy(`${t}_update_own`, 'UPDATE', ['authenticated'],
|
|
177
|
-
if (hasDeleteOwner) policy(`${t}_delete_own`, 'DELETE', ['authenticated'],
|
|
285
|
+
// UPDATE gates + checks; DELETE gates. `rowPred` is the direct-owner or `via:` predicate.
|
|
286
|
+
if (rowPred) {
|
|
287
|
+
if (canCreate) policy(`${t}_insert_own`, 'INSERT', ['authenticated'], null, rowPred);
|
|
288
|
+
if (hasUpdateOwner) policy(`${t}_update_own`, 'UPDATE', ['authenticated'], rowPred, rowPred);
|
|
289
|
+
if (hasDeleteOwner) policy(`${t}_delete_own`, 'DELETE', ['authenticated'], rowPred, null);
|
|
178
290
|
}
|
|
179
291
|
|
|
180
292
|
policies.sort((a, b) => a.name.localeCompare(b.name));
|
|
181
293
|
|
|
182
294
|
return {
|
|
183
295
|
table,
|
|
184
|
-
|
|
185
|
-
|
|
296
|
+
// The FORCE axiom: FORCE iff the write principal is subject to its own RLS
|
|
297
|
+
// policies. 'app' writes through its policies -> FORCE; 'worker'/'functions'
|
|
298
|
+
// write on the owner connection and bypass RLS -> ENABLE-not-FORCE (on RDS the
|
|
299
|
+
// owner is not a superuser, so a FORCEd table would block the owner's own writes).
|
|
300
|
+
rls: { enabled: true, forced: model.writtenBy === 'app' },
|
|
301
|
+
grants: compileGrants(abilities),
|
|
302
|
+
...(compileColumnGrants(abilities) ? { columnGrants: compileColumnGrants(abilities) } : {}),
|
|
186
303
|
policies,
|
|
187
304
|
};
|
|
188
305
|
}
|
|
189
306
|
|
|
307
|
+
/** The four DML privileges, sorted — the expansion of a `manage` ability. */
|
|
308
|
+
const CRUD = ['DELETE', 'INSERT', 'SELECT', 'UPDATE'];
|
|
309
|
+
|
|
310
|
+
/** The SQL privilege a single CRUD verb grants. `manage` expands to all of CRUD. */
|
|
311
|
+
const VERB: Record<Exclude<Ability['action'], 'manage'>, string> = {
|
|
312
|
+
read: 'SELECT',
|
|
313
|
+
create: 'INSERT',
|
|
314
|
+
update: 'UPDATE',
|
|
315
|
+
delete: 'DELETE',
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
/** The privileges an ability's action grants (a single verb, or all of CRUD for `manage`). */
|
|
319
|
+
function verbsFor(action: Ability['action']): string[] {
|
|
320
|
+
return action === 'manage' ? [...CRUD] : [VERB[action]];
|
|
321
|
+
}
|
|
322
|
+
|
|
190
323
|
/**
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
324
|
+
* Precise grants — derive each role's privilege set from the abilities, not a
|
|
325
|
+
* uniform tier. everystack's model is coarse grants + fine-grained RLS: the grant
|
|
326
|
+
* is the gate (which roles may touch the table at all), RLS restricts which rows.
|
|
327
|
+
* A role gets a privilege iff some ability grants that verb to it:
|
|
328
|
+
*
|
|
329
|
+
* - `can(action, { role })` -> that role gets the verb(s)
|
|
330
|
+
* - public/owner/via ability (no role) -> `authenticated` gets the verb(s)
|
|
331
|
+
* - a *public* read (no role, no owner/via) -> `anon` also gets SELECT
|
|
196
332
|
*
|
|
197
|
-
*
|
|
198
|
-
* table
|
|
199
|
-
*
|
|
200
|
-
*
|
|
333
|
+
* No ability for a role means no grant for that role: an ability-less (internal)
|
|
334
|
+
* table grants nothing, an owner-only table grants no anon SELECT, an admin-managed
|
|
335
|
+
* table grants admin CRUD only. Deterministic: roles and privilege lists sorted,
|
|
336
|
+
* so the output is byte-comparable with an introspected contract.
|
|
201
337
|
*/
|
|
202
|
-
function compileGrants(
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
...(hasAdminManage ? { admin: [...crud] } : {}),
|
|
338
|
+
function compileGrants(abilities: readonly Ability[]): Record<string, string[]> {
|
|
339
|
+
const grants: Record<string, Set<string>> = {};
|
|
340
|
+
const add = (role: string, verbs: string[]): void => {
|
|
341
|
+
const set = (grants[role] ??= new Set<string>());
|
|
342
|
+
for (const v of verbs) set.add(v);
|
|
208
343
|
};
|
|
344
|
+
for (const a of abilities) {
|
|
345
|
+
// A column-scoped read grants `SELECT (cols)` (a column grant), not a table-level
|
|
346
|
+
// privilege — emitted by compileColumnGrants, skipped here so it never leaks a
|
|
347
|
+
// whole-table grant that would defeat the column scoping.
|
|
348
|
+
if (isColumnRead(a)) continue;
|
|
349
|
+
const verbs = verbsFor(a.action);
|
|
350
|
+
if (a.condition.role) {
|
|
351
|
+
add(a.condition.role, verbs);
|
|
352
|
+
} else {
|
|
353
|
+
add('authenticated', verbs);
|
|
354
|
+
// A public read (no role, not owner/via-scoped) is anon-visible.
|
|
355
|
+
if (a.action === 'read' && !a.condition.owner && !a.condition.via) add('anon', ['SELECT']);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
const out: Record<string, string[]> = {};
|
|
359
|
+
for (const role of Object.keys(grants).sort()) out[role] = [...grants[role]].sort();
|
|
360
|
+
return out;
|
|
209
361
|
}
|
|
210
362
|
|
|
211
363
|
// Kept exported for callers that want just the privilege set for a role tier.
|
|
@@ -34,6 +34,13 @@ export interface OwnerProbe {
|
|
|
34
34
|
claim?: string;
|
|
35
35
|
/** App role to assume. Default `authenticated`. */
|
|
36
36
|
role?: string;
|
|
37
|
+
/**
|
|
38
|
+
* The table has an intended PUBLIC read (a `can('read')` with no owner/role/via — anon /
|
|
39
|
+
* authenticated may SELECT every row). Reader isolation is then NOT expected (a Twitter-clone's
|
|
40
|
+
* posts/profiles/likes/follows are public), so a visible other-owner row is by design, not an
|
|
41
|
+
* IDOR. Writer isolation is still enforced — the rows are owner-WRITE even when public-read.
|
|
42
|
+
*/
|
|
43
|
+
publicRead?: boolean;
|
|
37
44
|
}
|
|
38
45
|
|
|
39
46
|
export type OwnerCheck = 'discover' | 'read-own' | 'read-other' | 'write-other';
|
|
@@ -49,7 +56,7 @@ export interface OwnerProbeResult {
|
|
|
49
56
|
}
|
|
50
57
|
|
|
51
58
|
export interface OwnerFinding {
|
|
52
|
-
severity: 'read-leak' | 'write-leak' | 'vacuous' | 'unprobed';
|
|
59
|
+
severity: 'read-leak' | 'write-leak' | 'vacuous' | 'unprobed' | 'public-read';
|
|
53
60
|
table: string;
|
|
54
61
|
detail: string;
|
|
55
62
|
}
|
|
@@ -164,8 +171,12 @@ export function toOwnerProbeResult(row: any): OwnerProbeResult {
|
|
|
164
171
|
* - unprobed — fewer than two distinct owners in the table; isolation can't be tested (a
|
|
165
172
|
* warning, not a verdict — seed a second owner's row, or accept the gap).
|
|
166
173
|
* A correctly-isolated table with two owners produces zero findings.
|
|
174
|
+
*
|
|
175
|
+
* `publicReadTables` names tables with an intended public read: their reader-isolation check is
|
|
176
|
+
* informational (`public-read`), not a leak — but writer isolation is still enforced, so a
|
|
177
|
+
* write-leak on a public-read table is still a failure.
|
|
167
178
|
*/
|
|
168
|
-
export function evaluateOwnerProbe(results: OwnerProbeResult[]): OwnerFinding[] {
|
|
179
|
+
export function evaluateOwnerProbe(results: OwnerProbeResult[], publicReadTables: Set<string> = new Set()): OwnerFinding[] {
|
|
169
180
|
const findings: OwnerFinding[] = [];
|
|
170
181
|
const byTable = new Map<string, Map<OwnerCheck, OwnerProbeResult>>();
|
|
171
182
|
for (const r of results) {
|
|
@@ -181,6 +192,7 @@ export function evaluateOwnerProbe(results: OwnerProbeResult[]): OwnerFinding[]
|
|
|
181
192
|
const own = checks.get('read-own');
|
|
182
193
|
const other = checks.get('read-other');
|
|
183
194
|
const write = checks.get('write-other');
|
|
195
|
+
const publicRead = publicReadTables.has(table);
|
|
184
196
|
|
|
185
197
|
if (own && own.outcome !== 'visible') {
|
|
186
198
|
findings.push({ severity: 'vacuous', table,
|
|
@@ -188,8 +200,14 @@ export function evaluateOwnerProbe(results: OwnerProbeResult[]): OwnerFinding[]
|
|
|
188
200
|
continue; // the other checks are meaningless while the owner can't see own rows
|
|
189
201
|
}
|
|
190
202
|
if (other && other.outcome === 'visible') {
|
|
191
|
-
|
|
192
|
-
|
|
203
|
+
// A public-read table is SUPPOSED to expose every row — that is not an IDOR. Report it as
|
|
204
|
+
// informational so the operator sees it was recognized, not a leak. Owner-private tables
|
|
205
|
+
// (no public read) still fail here.
|
|
206
|
+
findings.push(publicRead
|
|
207
|
+
? { severity: 'public-read', table,
|
|
208
|
+
detail: `${table} has an intended public read — reader isolation not applicable (the ${other.rows} other-owner row(s) are public by design); writer isolation still enforced` }
|
|
209
|
+
: { severity: 'read-leak', table,
|
|
210
|
+
detail: `as the owner, ${other.rows} of another user's row(s) in ${table} were visible — RLS does not isolate readers (IDOR read)` });
|
|
193
211
|
}
|
|
194
212
|
if (write && write.outcome === 'visible') {
|
|
195
213
|
findings.push({ severity: 'write-leak', table,
|
|
@@ -84,6 +84,7 @@ export function emitReconcileSql(declared: AuthzContract, live: AuthzContract):
|
|
|
84
84
|
reconcileRls(table, d, l, sql);
|
|
85
85
|
reconcilePolicies(table, d, l, sql);
|
|
86
86
|
reconcileGrants(table, d, l, sql);
|
|
87
|
+
reconcileColumnGrants(table, d, l, sql);
|
|
87
88
|
}
|
|
88
89
|
|
|
89
90
|
return sql;
|
|
@@ -125,3 +126,27 @@ function reconcileGrants(table: string, d: TableContract, l: TableContract | und
|
|
|
125
126
|
if (toGrant.length) out.push(`GRANT ${toGrant.join(', ')} ON ${table} TO ${target};`);
|
|
126
127
|
}
|
|
127
128
|
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Reconcile column-scoped grants — `GRANT SELECT (cols) ON t TO role`. The grantee/privilege
|
|
132
|
+
* grid is reconciled per (grantee, privilege): add the columns declared-but-not-live, revoke
|
|
133
|
+
* those live-but-not-declared. This is how `auth.users` exposes id/email/role (and only those)
|
|
134
|
+
* to `authenticated` without a whole-table grant. Columns are sorted, so a re-pull is a no-op.
|
|
135
|
+
*/
|
|
136
|
+
function reconcileColumnGrants(table: string, d: TableContract, l: TableContract | undefined, out: string[]): void {
|
|
137
|
+
const dcg = d.columnGrants ?? {};
|
|
138
|
+
const lcg = l?.columnGrants ?? {};
|
|
139
|
+
for (const grantee of [...new Set([...Object.keys(dcg), ...Object.keys(lcg)])].sort()) {
|
|
140
|
+
const target = grantee.toUpperCase() === 'PUBLIC' ? 'PUBLIC' : grantee;
|
|
141
|
+
const dPriv = dcg[grantee] ?? {};
|
|
142
|
+
const lPriv = lcg[grantee] ?? {};
|
|
143
|
+
for (const priv of [...new Set([...Object.keys(dPriv), ...Object.keys(lPriv)])].sort()) {
|
|
144
|
+
const declared = new Set(dPriv[priv] ?? []);
|
|
145
|
+
const live = new Set(lPriv[priv] ?? []);
|
|
146
|
+
const toRevoke = [...live].filter((c) => !declared.has(c)).sort();
|
|
147
|
+
const toGrant = [...declared].filter((c) => !live.has(c)).sort();
|
|
148
|
+
if (toRevoke.length) out.push(`REVOKE ${priv} (${toRevoke.join(', ')}) ON ${table} FROM ${target};`);
|
|
149
|
+
if (toGrant.length) out.push(`GRANT ${priv} (${toGrant.join(', ')}) ON ${table} TO ${target};`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -243,14 +243,20 @@ export async function dbAuthzOwnerCommand(flags: Record<string, string>): Promis
|
|
|
243
243
|
const schema = flags.schema || 'public';
|
|
244
244
|
|
|
245
245
|
let probes: OwnerProbe[];
|
|
246
|
+
let publicReadTables = new Set<string>();
|
|
246
247
|
let rows: any[];
|
|
247
248
|
try {
|
|
248
249
|
step(`Loading models from ${modelsPath}...`);
|
|
249
250
|
const models = await loadModels(modelsPath);
|
|
250
|
-
|
|
251
|
-
.map((m) => modelOwner(m, { schema }))
|
|
252
|
-
.filter((
|
|
253
|
-
|
|
251
|
+
const ownerModels = models
|
|
252
|
+
.map((m) => ({ m, owner: modelOwner(m, { schema }) }))
|
|
253
|
+
.filter((x): x is { m: (typeof models)[number]; owner: NonNullable<typeof x.owner> } => x.owner !== null);
|
|
254
|
+
// A table with a public read (`can('read')`, no owner/role/via) is intentionally not
|
|
255
|
+
// reader-isolated — flag it so the probe reports its cross-owner reads as expected, not IDOR.
|
|
256
|
+
const isPublicRead = (m: (typeof models)[number]): boolean =>
|
|
257
|
+
m.abilities.some((a) => a.action === 'read' && !a.condition.role && !a.condition.owner && !a.condition.via);
|
|
258
|
+
publicReadTables = new Set(ownerModels.filter(({ m }) => isPublicRead(m)).map(({ owner }) => owner.table));
|
|
259
|
+
probes = ownerModels.map(({ owner }) => ({ table: owner.table, ownerColumn: owner.ownerColumn, claim: owner.claim }));
|
|
254
260
|
info(`${probes.length} owner-scoped model(s).`);
|
|
255
261
|
if (probes.length === 0) {
|
|
256
262
|
success('db:authz:owner — no owner-scoped models (no `can({ owner })`); nothing to probe.');
|
|
@@ -272,14 +278,16 @@ export async function dbAuthzOwnerCommand(flags: Record<string, string>): Promis
|
|
|
272
278
|
process.exit(1);
|
|
273
279
|
}
|
|
274
280
|
|
|
275
|
-
const findings = evaluateOwnerProbe(rows.map(toOwnerProbeResult));
|
|
281
|
+
const findings = evaluateOwnerProbe(rows.map(toOwnerProbeResult), publicReadTables);
|
|
276
282
|
const leaks = findings.filter((f) => f.severity === 'read-leak' || f.severity === 'write-leak');
|
|
277
283
|
const vacuous = findings.filter((f) => f.severity === 'vacuous');
|
|
278
284
|
const unprobed = findings.filter((f) => f.severity === 'unprobed');
|
|
285
|
+
const publicReads = findings.filter((f) => f.severity === 'public-read');
|
|
279
286
|
|
|
280
287
|
console.log('');
|
|
281
288
|
for (const f of leaks) fail(`[LEAK] ${f.detail}`);
|
|
282
289
|
for (const f of vacuous) fail(`[VACUOUS] ${f.detail}`);
|
|
290
|
+
for (const f of publicReads) info(`[public] ${f.detail}`);
|
|
283
291
|
for (const f of unprobed) info(`[unprobed] ${f.detail}`);
|
|
284
292
|
console.log('');
|
|
285
293
|
|
|
@@ -20,8 +20,10 @@
|
|
|
20
20
|
import fs from 'node:fs/promises';
|
|
21
21
|
import path from 'node:path';
|
|
22
22
|
import { pathToFileURL } from 'node:url';
|
|
23
|
-
import type { ModelDescriptor } from '@everystack/model';
|
|
23
|
+
import type { ModelDescriptor, Module } from '@everystack/model';
|
|
24
24
|
import { introspectSchema } from '../schema-introspect.js';
|
|
25
|
+
import { compileDrizzleSource } from '../schema-source.js';
|
|
26
|
+
import { compileModuleMigration } from '../migration-compile.js';
|
|
25
27
|
import { generateMigrationSql, unmodeledTables, formatMigrationFile, planMigrationFile, HELD_DROP_PREFIX, type Journal } from '../migration-generate.js';
|
|
26
28
|
import { introspectContract, type QueryRunner } from '../authz-contract.js';
|
|
27
29
|
import { FUNCTIONS_SQL, catalogFunctionToDescriptor } from '../security-catalog.js';
|
|
@@ -31,6 +33,7 @@ import { step, success, fail, info, warn } from '../output.js';
|
|
|
31
33
|
|
|
32
34
|
const DEFAULT_MODELS = 'models/index.ts';
|
|
33
35
|
const DEFAULT_MIGRATIONS = 'drizzle';
|
|
36
|
+
const DEFAULT_SCHEMA_OUT = 'db/schema.generated.ts';
|
|
34
37
|
|
|
35
38
|
/** A QueryRunner backed by the ops Lambda `db:query` action (read-only). */
|
|
36
39
|
function lambdaRunner(region: string, fn: string): QueryRunner {
|
|
@@ -61,12 +64,82 @@ async function loadModels(modelsPath: string): Promise<ModelDescriptor[]> {
|
|
|
61
64
|
return models;
|
|
62
65
|
}
|
|
63
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Import the app's barrel and return its `modules` array — the greenfield (`--init`) input. A
|
|
69
|
+
* Module carries the package's extensions + functions on top of its models, so it can emit the
|
|
70
|
+
* COMPLETE migration. Falls back to wrapping a bare `models` array in one module (an app on the
|
|
71
|
+
* older models-only barrel still gets a schema+authz init, just no package functions).
|
|
72
|
+
*/
|
|
73
|
+
async function loadModules(modelsPath: string): Promise<Module[]> {
|
|
74
|
+
const abs = path.resolve(modelsPath);
|
|
75
|
+
let mod: any;
|
|
76
|
+
try {
|
|
77
|
+
mod = await import(pathToFileURL(abs).href);
|
|
78
|
+
} catch (err: any) {
|
|
79
|
+
throw new Error(`Could not load modules from ${modelsPath}: ${err.message}`);
|
|
80
|
+
}
|
|
81
|
+
if (Array.isArray(mod.modules)) return mod.modules;
|
|
82
|
+
const models = mod.models ?? mod.default;
|
|
83
|
+
if (Array.isArray(models)) return [{ models, extensions: [], functions: [] }];
|
|
84
|
+
throw new Error(`${modelsPath} must export a \`modules\` (or \`models\`) array.`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* `db:generate --init` — the greenfield generator. Compiles the app's Modules into ONE
|
|
89
|
+
* complete migration (extensions + schema + authz + package functions/triggers) with NO live
|
|
90
|
+
* database — the model IS the source. Writes it as the next migration in a (typically empty)
|
|
91
|
+
* `drizzle/`. Roles are excluded by design (they are `db:provision`). Use it to (re)initialize
|
|
92
|
+
* a clean migration history: clear `drizzle/`, then `db:generate --init`.
|
|
93
|
+
*/
|
|
94
|
+
async function dbGenerateInit(modelsPath: string, migrationsDir: string, schemaOut: string, name: string): Promise<void> {
|
|
95
|
+
step(`Loading modules from ${modelsPath}...`);
|
|
96
|
+
const modules = await loadModules(modelsPath);
|
|
97
|
+
const models = modules.flatMap((m) => m.models);
|
|
98
|
+
info(`${modules.length} module(s), ${models.length} model(s).`);
|
|
99
|
+
|
|
100
|
+
step(`Writing the drizzle schema → ${path.relative(process.cwd(), schemaOut)}...`);
|
|
101
|
+
await fs.writeFile(schemaOut, compileDrizzleSource(models), 'utf8');
|
|
102
|
+
|
|
103
|
+
const statements = compileModuleMigration(modules);
|
|
104
|
+
|
|
105
|
+
const journalPath = path.join(migrationsDir, 'meta', '_journal.json');
|
|
106
|
+
let journal: Journal;
|
|
107
|
+
try {
|
|
108
|
+
journal = JSON.parse(await fs.readFile(journalPath, 'utf8'));
|
|
109
|
+
} catch {
|
|
110
|
+
journal = { version: '7', dialect: 'postgresql', entries: [] };
|
|
111
|
+
}
|
|
112
|
+
const { filename, journal: nextJournal } = planMigrationFile(journal, name, Date.now());
|
|
113
|
+
const filePath = path.join(migrationsDir, filename);
|
|
114
|
+
await fs.mkdir(path.join(migrationsDir, 'meta'), { recursive: true });
|
|
115
|
+
await fs.writeFile(filePath, formatMigrationFile(statements), 'utf8');
|
|
116
|
+
await fs.writeFile(journalPath, JSON.stringify(nextJournal, null, 2) + '\n', 'utf8');
|
|
117
|
+
|
|
118
|
+
console.log('');
|
|
119
|
+
success(`Wrote ${path.relative(process.cwd(), filePath)} (${statements.length} statement(s)) — the complete greenfield migration.`);
|
|
120
|
+
warn('Roles are NOT in this migration — run `everystack db:provision` (the role chain) before `db:migrate`.');
|
|
121
|
+
process.exit(0);
|
|
122
|
+
}
|
|
123
|
+
|
|
64
124
|
export async function dbGenerateCommand(flags: Record<string, string>): Promise<void> {
|
|
65
125
|
const modelsPath = flags.models || DEFAULT_MODELS;
|
|
66
126
|
const migrationsDir = path.resolve(flags.dir || DEFAULT_MIGRATIONS);
|
|
127
|
+
const schemaOut = path.resolve(flags['schema-out'] || DEFAULT_SCHEMA_OUT);
|
|
67
128
|
const name = flags.name || 'generated';
|
|
68
129
|
const allowDrops = flags['allow-drops'] === 'true';
|
|
69
130
|
|
|
131
|
+
// --init: greenfield, no live DB — the model emits the whole migration (extensions + schema +
|
|
132
|
+
// authz + package functions). The brownfield path below introspects the deployed DB and diffs.
|
|
133
|
+
if (flags.init === 'true') {
|
|
134
|
+
try {
|
|
135
|
+
await dbGenerateInit(modelsPath, migrationsDir, schemaOut, flags.name || 'init');
|
|
136
|
+
} catch (err: any) {
|
|
137
|
+
fail(err.message);
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
70
143
|
let models: ModelDescriptor[];
|
|
71
144
|
let current;
|
|
72
145
|
let liveAuthz;
|
|
@@ -74,6 +147,11 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
|
|
|
74
147
|
step(`Loading models from ${modelsPath}...`);
|
|
75
148
|
models = await loadModels(modelsPath);
|
|
76
149
|
info(`${models.length} model(s).`);
|
|
150
|
+
// The drizzle runtime schema is a derived artifact — refresh it from the models
|
|
151
|
+
// every run (it needs no DB), so a relation-only change with no DDL diff still
|
|
152
|
+
// updates it. The drift-guard test asserts this file matches the models.
|
|
153
|
+
step(`Writing the drizzle schema → ${path.relative(process.cwd(), schemaOut)}...`);
|
|
154
|
+
await fs.writeFile(schemaOut, compileDrizzleSource(models), 'utf8');
|
|
77
155
|
step('Resolving deployed config...');
|
|
78
156
|
const config = await resolveConfig(flags.stage);
|
|
79
157
|
info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
|
|
@@ -19,9 +19,9 @@
|
|
|
19
19
|
* lands with whole-DB introspection. The emission is identical either way.
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
import type { ModelDescriptor } from '@everystack/model';
|
|
22
|
+
import type { ModelDescriptor, Module } from '@everystack/model';
|
|
23
23
|
import type { AuthzContract, TableContract } from './authz-contract.js';
|
|
24
|
-
import { compileCreateTable, compileEnums, foreignKeySql, modelConstraintSpecs, type CompileTableOptions } from './schema-compile.js';
|
|
24
|
+
import { compileCreateTable, compileEnums, foreignKeySql, modelConstraintSpecs, qualifiedTable, type CompileTableOptions } from './schema-compile.js';
|
|
25
25
|
import { compileTableContract } from './authz-compile.js';
|
|
26
26
|
import { emitReconcileSql } from './authz-reconcile.js';
|
|
27
27
|
import { emitSchemaSql, type SchemaChange } from './schema-diff.js';
|
|
@@ -38,8 +38,36 @@ function emptyTable(table: string): TableContract {
|
|
|
38
38
|
*/
|
|
39
39
|
export function compileMigration(models: ModelDescriptor[], opts: CompileTableOptions = {}): string[] {
|
|
40
40
|
const sql: string[] = [];
|
|
41
|
+
const schemaOf = (m: ModelDescriptor): string => m.schema ?? 'public';
|
|
41
42
|
|
|
42
|
-
//
|
|
43
|
+
// 0a. Non-public schemas — `CREATE SCHEMA` for each distinct one a model lives in,
|
|
44
|
+
// before any table is created in it. public is the implicit default, never emitted.
|
|
45
|
+
const schemas = [...new Set(models.map(schemaOf))].filter((s) => s !== 'public').sort();
|
|
46
|
+
sql.push(...schemas.map((s) => `CREATE SCHEMA IF NOT EXISTS "${s}";`));
|
|
47
|
+
|
|
48
|
+
// The authz contracts — computed once, used for the schema USAGE grants here and the
|
|
49
|
+
// table-level reconcile below.
|
|
50
|
+
const contracts = models.map((m) => compileTableContract(m));
|
|
51
|
+
|
|
52
|
+
// 0a-bis. Schema USAGE — a role can't reach a table in a non-public schema without USAGE on
|
|
53
|
+
// it, so a table/column grant there is dead without this. Derived from the grants: every
|
|
54
|
+
// role that holds any privilege on a table in a non-public schema gets USAGE on that schema
|
|
55
|
+
// (public USAGE is granted to PUBLIC by default, so it is never emitted). This replaces the
|
|
56
|
+
// hand-written `GRANT USAGE ON SCHEMA auth/ops/dist …` the old bootstrap carried.
|
|
57
|
+
for (const schema of schemas) {
|
|
58
|
+
const roles = new Set<string>();
|
|
59
|
+
for (const c of contracts) {
|
|
60
|
+
if (!c.table.startsWith(`${schema}.`)) continue;
|
|
61
|
+
for (const r of Object.keys(c.grants)) roles.add(r);
|
|
62
|
+
for (const r of Object.keys(c.columnGrants ?? {})) roles.add(r);
|
|
63
|
+
}
|
|
64
|
+
if (roles.size) {
|
|
65
|
+
const targets = [...roles].sort().map((r) => (r.toUpperCase() === 'PUBLIC' ? 'PUBLIC' : r)).join(', ');
|
|
66
|
+
sql.push(`GRANT USAGE ON SCHEMA "${schema}" TO ${targets};`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// 0b. Enum types — `CREATE TYPE … AS ENUM`, before any table/column references them.
|
|
43
71
|
const enumCreates: SchemaChange[] = compileEnums(models).map((e) => ({ kind: 'createType', name: e.name, values: e.values }));
|
|
44
72
|
sql.push(...emitSchemaSql(enumCreates));
|
|
45
73
|
|
|
@@ -49,11 +77,12 @@ export function compileMigration(models: ModelDescriptor[], opts: CompileTableOp
|
|
|
49
77
|
// 2. Foreign keys — after every table exists.
|
|
50
78
|
for (const model of models) sql.push(...foreignKeySql(model));
|
|
51
79
|
|
|
52
|
-
// 2b. Standalone indexes — after the columns they cover exist.
|
|
80
|
+
// 2b. Standalone indexes — after the columns they cover exist. The ON clause is the
|
|
81
|
+
// schema-qualified table, so a non-public index targets the right table.
|
|
53
82
|
const indexCreates: SchemaChange[] = [];
|
|
54
83
|
for (const model of models) {
|
|
55
84
|
for (const ix of modelConstraintSpecs(model).indexes) {
|
|
56
|
-
indexCreates.push({ kind: 'createIndex', table:
|
|
85
|
+
indexCreates.push({ kind: 'createIndex', table: qualifiedTable(model), name: ix.name, columns: ix.columns, unique: ix.unique, ...(ix.where ? { where: ix.where } : {}) });
|
|
57
86
|
}
|
|
58
87
|
}
|
|
59
88
|
sql.push(...emitSchemaSql(indexCreates));
|
|
@@ -61,9 +90,44 @@ export function compileMigration(models: ModelDescriptor[], opts: CompileTableOp
|
|
|
61
90
|
// 3. Authz — RLS + policies + grants, after the columns they reference. Reuse
|
|
62
91
|
// the reconcile emitter against the empty baseline: every policy/grant is a
|
|
63
92
|
// pure creation, in the same dependency-correct order reconcile already uses.
|
|
64
|
-
|
|
65
|
-
|
|
93
|
+
// The baseline keys on each model's resolved schema, so a non-public table
|
|
94
|
+
// diffs against its own empty state, not a phantom public one.
|
|
95
|
+
const desired: AuthzContract = { tables: contracts, functions: [] };
|
|
96
|
+
const baseline: AuthzContract = { tables: models.map((m) => emptyTable(`${schemaOf(m)}.${m.table}`)), functions: [] };
|
|
66
97
|
sql.push(...emitReconcileSql(desired, baseline));
|
|
67
98
|
|
|
68
99
|
return sql;
|
|
69
100
|
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Compile a set of Modules into one COMPLETE greenfield migration — the whole deploy, no
|
|
104
|
+
* hand-written bootstrap left:
|
|
105
|
+
*
|
|
106
|
+
* 1. CREATE EXTENSION (deduped, sorted) — the bootstrap a package's SQL needs
|
|
107
|
+
* 2. compileMigration(flattened models) — schemas, tables, FKs, indexes, RLS, authz
|
|
108
|
+
* 3. package functions/triggers (each thunk) — imperative package SQL, AFTER the tables
|
|
109
|
+
*
|
|
110
|
+
* Roles are NOT emitted — they are cluster-global (`db:provision`), and the GRANTs in step 2
|
|
111
|
+
* assume they exist (as the dogfood pre-creates them). `compileMigration` is left untouched as
|
|
112
|
+
* the schema+authz core the contract round-trips through; the functions are emitted verbatim
|
|
113
|
+
* here, outside that round-trip (they are imperative, package-owned — not modeled).
|
|
114
|
+
*/
|
|
115
|
+
export function compileModuleMigration(modules: Module[], opts: CompileTableOptions = {}): string[] {
|
|
116
|
+
const sql: string[] = [];
|
|
117
|
+
|
|
118
|
+
// 1. Extensions — deduped + sorted, quoted so a hyphenated name (`uuid-ossp`) is valid.
|
|
119
|
+
const extensions = [...new Set(modules.flatMap((m) => m.extensions))].sort();
|
|
120
|
+
sql.push(...extensions.map((e) => `CREATE EXTENSION IF NOT EXISTS "${e}";`));
|
|
121
|
+
|
|
122
|
+
// 2. Schema + authz for every modeled table.
|
|
123
|
+
sql.push(...compileMigration(modules.flatMap((m) => m.models), opts));
|
|
124
|
+
|
|
125
|
+
// 3. Package SQL — functions + triggers, after the tables they reference. Each thunk's
|
|
126
|
+
// output is a self-contained multi-statement block applied as one unit.
|
|
127
|
+
for (const fn of modules.flatMap((m) => m.functions)) {
|
|
128
|
+
const text = fn().trim();
|
|
129
|
+
if (text) sql.push(text);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return sql;
|
|
133
|
+
}
|
|
@@ -86,6 +86,9 @@ export function unmodeledTables(models: ModelDescriptor[], current: SchemaSnapsh
|
|
|
86
86
|
*/
|
|
87
87
|
export function generateMigrationSql(models: ModelDescriptor[], current: SchemaSnapshot, opts: GenerateOptions = {}): string[] {
|
|
88
88
|
const schema = opts.schema ?? 'public';
|
|
89
|
+
// A table's schema comes from the model first (package tables live in dist/ops/auth),
|
|
90
|
+
// then the call default — so one run spans schemas.
|
|
91
|
+
const schemaOf = (m: ModelDescriptor): string => m.schema ?? schema;
|
|
89
92
|
const desiredEnums = compileEnums(models);
|
|
90
93
|
const desired: SchemaSnapshot = { tables: models.map((m) => compileTableSchema(m, { schema })), enums: desiredEnums };
|
|
91
94
|
const declaredTables = new Set(desired.tables.map((t) => t.table));
|
|
@@ -99,7 +102,7 @@ export function generateMigrationSql(models: ModelDescriptor[], current: SchemaS
|
|
|
99
102
|
const renames = compileRenames(models, { schema });
|
|
100
103
|
const changes = diffSchema(desired, scopedCurrent, renames);
|
|
101
104
|
|
|
102
|
-
const modelByTable = new Map(models.map((m) => [`${
|
|
105
|
+
const modelByTable = new Map(models.map((m) => [`${schemaOf(m)}.${m.table}`, m]));
|
|
103
106
|
const emitted = emitSchemaSql(changes, {
|
|
104
107
|
createTable: (change) => {
|
|
105
108
|
const model = modelByTable.get(change.table);
|
package/src/cli/model-api.ts
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
export * from './schema-compile.js';
|
|
19
19
|
export * from './schema-introspect.js';
|
|
20
20
|
export * from './schema-diff.js';
|
|
21
|
+
export * from './schema-source.js';
|
|
21
22
|
export * from './model-render.js';
|
|
22
23
|
export * from './migration-compile.js';
|
|
23
24
|
export * from './migration-generate.js';
|
|
@@ -40,6 +40,7 @@ export function modelConstraintSpecs(model: ModelDescriptor): { uniques: UniqueC
|
|
|
40
40
|
const checks: CheckConstraint[] = [];
|
|
41
41
|
const indexes: IndexSchema[] = [];
|
|
42
42
|
let checkIndex = 0;
|
|
43
|
+
const usedIndexNames = new Set<string>();
|
|
43
44
|
for (const con of model.constraints) {
|
|
44
45
|
if (con.kind === 'unique') {
|
|
45
46
|
const cols = con.columns.map(toSnakeCase);
|
|
@@ -48,8 +49,16 @@ export function modelConstraintSpecs(model: ModelDescriptor): { uniques: UniqueC
|
|
|
48
49
|
checks.push({ name: `${model.table}_check_${checkIndex++}`, expr: con.predicate });
|
|
49
50
|
} else if (con.kind === 'index') {
|
|
50
51
|
const cols = con.columns.map(toSnakeCase);
|
|
52
|
+
// Disambiguate multiple indexes over the same columns — e.g. a full index plus a
|
|
53
|
+
// partial one (`familyId` and `familyId WHERE status='active'`): the name must be
|
|
54
|
+
// unique within the table for CREATE to succeed. The diff matches indexes by content
|
|
55
|
+
// (columns + uniqueness + predicate), never by name, so the suffix never affects the
|
|
56
|
+
// round-trip. Declaration order is deterministic, so the name is stable across calls.
|
|
57
|
+
let name = `${model.table}_${cols.join('_')}_index`;
|
|
58
|
+
for (let n = 2; usedIndexNames.has(name); n++) name = `${model.table}_${cols.join('_')}_index_${n}`;
|
|
59
|
+
usedIndexNames.add(name);
|
|
51
60
|
indexes.push({
|
|
52
|
-
name
|
|
61
|
+
name,
|
|
53
62
|
columns: cols,
|
|
54
63
|
unique: con.isUnique,
|
|
55
64
|
...(con.predicate ? { where: con.predicate } : {}),
|
|
@@ -77,6 +86,12 @@ const PG_TYPE: Record<string, string> = {
|
|
|
77
86
|
};
|
|
78
87
|
|
|
79
88
|
function pgType(spec: FieldSpec): string {
|
|
89
|
+
// An array is the base type with `[]` appended — `text` → `text[]`, exactly as
|
|
90
|
+
// `format_type` spells it.
|
|
91
|
+
return spec.isArray ? `${scalarPgType(spec)}[]` : scalarPgType(spec);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function scalarPgType(spec: FieldSpec): string {
|
|
80
95
|
// Parameterized types build their own canonical string — exactly as `format_type`
|
|
81
96
|
// spells it (no space after the comma; `varchar` is `character varying`; a
|
|
82
97
|
// precision-only numeric carries scale 0), so an unchanged column round-trips clean.
|
|
@@ -132,7 +147,7 @@ export function modelForeignKeys(model: ModelDescriptor): ForeignKey[] {
|
|
|
132
147
|
out.push({
|
|
133
148
|
name: `${model.table}_${col}_${target.table}_${targetPk}_fk`,
|
|
134
149
|
columns: [col],
|
|
135
|
-
refTable: target
|
|
150
|
+
refTable: refTableName(target),
|
|
136
151
|
refColumns: [targetPk],
|
|
137
152
|
...refActions(field.spec),
|
|
138
153
|
});
|
|
@@ -146,7 +161,7 @@ export function modelForeignKeys(model: ModelDescriptor): ForeignKey[] {
|
|
|
146
161
|
out.push({
|
|
147
162
|
name: `${model.table}_${cols.join('_')}_${target.table}_${refCols.join('_')}_fk`,
|
|
148
163
|
columns: cols,
|
|
149
|
-
refTable: target
|
|
164
|
+
refTable: refTableName(target),
|
|
150
165
|
refColumns: refCols,
|
|
151
166
|
...refActions(con),
|
|
152
167
|
});
|
|
@@ -155,6 +170,22 @@ export function modelForeignKeys(model: ModelDescriptor): ForeignKey[] {
|
|
|
155
170
|
return out;
|
|
156
171
|
}
|
|
157
172
|
|
|
173
|
+
/**
|
|
174
|
+
* The referenced table's name as the FK should carry it — schema-qualified for a non-public
|
|
175
|
+
* target (`auth.users`), bare for a public one (`uploads`). A cross-schema FK (auth → auth, or
|
|
176
|
+
* public → auth) must name the schema, and `pg_get_constraintdef` deparses it qualified when the
|
|
177
|
+
* target schema is off the search_path — so the desired side must match, or an unchanged auth FK
|
|
178
|
+
* would read as drift. public stays bare because it is on the search_path (the proven output).
|
|
179
|
+
*/
|
|
180
|
+
function refTableName(target: ModelDescriptor): string {
|
|
181
|
+
return target.schema && target.schema !== 'public' ? `${target.schema}.${target.table}` : target.table;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Quote each dot-separated part of a (possibly schema-qualified) name: `auth.users` → `"auth"."users"`. */
|
|
185
|
+
function quoteParts(name: string): string {
|
|
186
|
+
return name.split('.').map((p) => `"${p}"`).join('.');
|
|
187
|
+
}
|
|
188
|
+
|
|
158
189
|
/** The ` ON DELETE … ON UPDATE …` clause for a FK, or '' when both are the default. */
|
|
159
190
|
function refActionSql(fk: { onDelete?: string; onUpdate?: string }): string {
|
|
160
191
|
let s = '';
|
|
@@ -168,11 +199,22 @@ function defaultExpr(spec: FieldSpec): string | null {
|
|
|
168
199
|
switch (spec.defaultKind) {
|
|
169
200
|
case 'now': return 'now()';
|
|
170
201
|
case 'random': return 'gen_random_uuid()';
|
|
171
|
-
case 'value':
|
|
202
|
+
case 'value':
|
|
203
|
+
return spec.isArray && Array.isArray(spec.default) ? arrayLiteral(spec.default) : literal(spec.default);
|
|
172
204
|
default: return null;
|
|
173
205
|
}
|
|
174
206
|
}
|
|
175
207
|
|
|
208
|
+
/**
|
|
209
|
+
* A Postgres array literal for a default — `[]` → `'{}'`, `['a','b']` → `'{"a","b"}'`.
|
|
210
|
+
* The introspected form carries a `::type[]` cast (`'{}'::text[]`) that `normalizeDefault`
|
|
211
|
+
* strips, so the bare literal round-trips.
|
|
212
|
+
*/
|
|
213
|
+
function arrayLiteral(arr: unknown[]): string {
|
|
214
|
+
const elems = arr.map((v) => (typeof v === 'string' ? `"${v.replace(/"/g, '\\"')}"` : String(v)));
|
|
215
|
+
return `'{${elems.join(',')}}'`;
|
|
216
|
+
}
|
|
217
|
+
|
|
176
218
|
function literal(value: unknown): string {
|
|
177
219
|
if (typeof value === 'string') return `'${value.replace(/'/g, "''")}'`;
|
|
178
220
|
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
|
@@ -278,6 +320,18 @@ export interface CompileTableOptions {
|
|
|
278
320
|
indent?: string;
|
|
279
321
|
}
|
|
280
322
|
|
|
323
|
+
/**
|
|
324
|
+
* A model's table as a quoted SQL name — schema-qualified only for a non-public
|
|
325
|
+
* schema. Drizzle leaves `public` tables bare (`"posts"`), and the example's
|
|
326
|
+
* brownfield DDL matches that, so a public model stays unqualified; a package
|
|
327
|
+
* table in its own schema becomes `"dist"."blobs"`.
|
|
328
|
+
*/
|
|
329
|
+
export function qualifiedTable(model: ModelDescriptor): string {
|
|
330
|
+
return model.schema && model.schema !== 'public'
|
|
331
|
+
? `"${model.schema}"."${model.table}"`
|
|
332
|
+
: `"${model.table}"`;
|
|
333
|
+
}
|
|
334
|
+
|
|
281
335
|
/**
|
|
282
336
|
* `CREATE TABLE` for a model's fields. Single-column primary keys are inline;
|
|
283
337
|
* composite keys and uniques become table constraints, named the way drizzle
|
|
@@ -322,7 +376,7 @@ export function compileCreateTable(model: ModelDescriptor, opts: CompileTableOpt
|
|
|
322
376
|
lines.push(`${indent}CONSTRAINT "${ck.name}" CHECK (${ck.expr})`);
|
|
323
377
|
}
|
|
324
378
|
|
|
325
|
-
return `CREATE TABLE
|
|
379
|
+
return `CREATE TABLE ${qualifiedTable(model)} (\n${lines.join(',\n')}\n);`;
|
|
326
380
|
}
|
|
327
381
|
|
|
328
382
|
/**
|
|
@@ -337,7 +391,7 @@ export function compileCreateTable(model: ModelDescriptor, opts: CompileTableOpt
|
|
|
337
391
|
* helpers, so they never disagree.)
|
|
338
392
|
*/
|
|
339
393
|
export function compileTableSchema(model: ModelDescriptor, opts: { schema?: string } = {}): TableSchema {
|
|
340
|
-
const schema = opts.schema ?? 'public';
|
|
394
|
+
const schema = model.schema ?? opts.schema ?? 'public';
|
|
341
395
|
const entries = Object.entries(model.fields);
|
|
342
396
|
|
|
343
397
|
const columns = entries.map(([name, field]) => ({
|
|
@@ -410,9 +464,12 @@ export function foreignKeySql(model: ModelDescriptor): string[] {
|
|
|
410
464
|
return modelForeignKeys(model).map((fk) => {
|
|
411
465
|
const cols = fk.columns.map((c) => `"${c}"`).join(', ');
|
|
412
466
|
const refCols = fk.refColumns.map((c) => `"${c}"`).join(', ');
|
|
467
|
+
// Both sides are schema-qualified for a non-public table — `ALTER TABLE "auth"."identities"`
|
|
468
|
+
// and `REFERENCES "auth"."users"` — so a cross-schema FK targets the right table. public
|
|
469
|
+
// stays bare-but-quoted (`"uploads"`), preserving the proven output.
|
|
413
470
|
return (
|
|
414
|
-
`ALTER TABLE
|
|
415
|
-
`FOREIGN KEY (${cols}) REFERENCES
|
|
471
|
+
`ALTER TABLE ${qualifiedTable(model)} ADD CONSTRAINT "${fk.name}" ` +
|
|
472
|
+
`FOREIGN KEY (${cols}) REFERENCES ${quoteParts(fk.refTable)}(${refCols})${refActionSql(fk)};`
|
|
416
473
|
);
|
|
417
474
|
});
|
|
418
475
|
}
|
|
@@ -425,9 +482,9 @@ export function foreignKeySql(model: ModelDescriptor): string[] {
|
|
|
425
482
|
* this map to turn a drop+add into a `RENAME COLUMN`.
|
|
426
483
|
*/
|
|
427
484
|
export function compileRenames(models: ModelDescriptor[], opts: { schema?: string } = {}): RenameMap {
|
|
428
|
-
const schema = opts.schema ?? 'public';
|
|
429
485
|
const map: RenameMap = {};
|
|
430
486
|
for (const model of models) {
|
|
487
|
+
const schema = model.schema ?? opts.schema ?? 'public';
|
|
431
488
|
for (const [name, field] of Object.entries(model.fields)) {
|
|
432
489
|
const from = field.spec.renamedFrom;
|
|
433
490
|
if (!from) continue;
|
package/src/cli/schema-diff.ts
CHANGED
|
@@ -509,7 +509,10 @@ function addConstraintSql(table: string, c: ConstraintSpec): string {
|
|
|
509
509
|
case 'unique': return `${head} UNIQUE (${colList(c.columns)});`;
|
|
510
510
|
case 'check': return `${head} CHECK ${wrapOnce(c.expr)};`;
|
|
511
511
|
case 'foreignKey': {
|
|
512
|
-
|
|
512
|
+
// refTable may be schema-qualified (`auth.users`) for a cross-schema FK — quote each part,
|
|
513
|
+
// not the whole string, or `"auth.users"` reads as one identifier.
|
|
514
|
+
const refTable = c.refTable.split('.').map(quote).join('.');
|
|
515
|
+
let s = `${head} FOREIGN KEY (${colList(c.columns)}) REFERENCES ${refTable}(${colList(c.refColumns)})`;
|
|
513
516
|
if (c.onDelete) s += ` ON DELETE ${c.onDelete.toUpperCase()}`;
|
|
514
517
|
if (c.onUpdate) s += ` ON UPDATE ${c.onUpdate.toUpperCase()}`;
|
|
515
518
|
return `${s};`;
|
|
@@ -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
|
+
}
|