@everystack/cli 0.4.43 → 0.4.44
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 +82 -12
- package/src/cli/authz-derive.ts +156 -9
- package/src/cli/commands/db-pull.ts +32 -3
- package/src/cli/model-render.ts +61 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.44",
|
|
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>",
|
|
@@ -109,7 +109,7 @@
|
|
|
109
109
|
"structured-headers": "1.0.1",
|
|
110
110
|
"tsx": "4.21.0",
|
|
111
111
|
"typescript": "5.9.3",
|
|
112
|
-
"@everystack/model": "0.4.
|
|
112
|
+
"@everystack/model": "0.4.9"
|
|
113
113
|
},
|
|
114
114
|
"peerDependencies": {
|
|
115
115
|
"@everystack/server": ">=0.4.0",
|
package/src/cli/authz-compile.ts
CHANGED
|
@@ -66,7 +66,11 @@ function claimExpr(claim: string): string {
|
|
|
66
66
|
* uuid: (author_id = (((current_setting('request.jwt.claims'::text, true))::jsonb ->> 'sub'::text))::uuid)
|
|
67
67
|
* text: (user_id = ((current_setting('request.jwt.claims'::text, true))::jsonb ->> 'sub'::text))
|
|
68
68
|
*/
|
|
69
|
-
function ownerPredicate(sqlColumn: string, type: string, claim: string): string {
|
|
69
|
+
function ownerPredicate(sqlColumn: string, type: string, claim: string, expr: string | null = null): string {
|
|
70
|
+
// A declared `ownerExpr` is the accessor the database already has (`auth.user_id()`),
|
|
71
|
+
// emitted verbatim and uncast: the function returns the owner column's type, and a cast
|
|
72
|
+
// the deparser would not print is a DROP + CREATE on every owner policy in the schema.
|
|
73
|
+
if (expr) return `(${sqlColumn} = ${expr})`;
|
|
70
74
|
const ce = claimExpr(claim);
|
|
71
75
|
if (type === 'text') return `(${sqlColumn} = ${ce})`;
|
|
72
76
|
return `(${sqlColumn} = (${ce})::${castForType(type)})`;
|
|
@@ -89,7 +93,7 @@ function softDeleteGuard(sqlColumn: string): string {
|
|
|
89
93
|
* WHERE (uploads.user_id = ((current_setting('request.jwt.claims'::text, true))::jsonb ->> 'sub'::text))))
|
|
90
94
|
*/
|
|
91
95
|
function viaPredicate(v: ViaRef): string {
|
|
92
|
-
const parentOwner = ownerPredicate(`${v.parentTable}.${v.parentOwner.sqlColumn}`, v.parentOwner.type, v.parentOwner.claim);
|
|
96
|
+
const parentOwner = ownerPredicate(`${v.parentTable}.${v.parentOwner.sqlColumn}`, v.parentOwner.type, v.parentOwner.claim, v.parentOwner.expr);
|
|
93
97
|
return `(${v.fkColumn} IN ( SELECT ${v.parentTable}.${v.parentPk}\n FROM ${v.parentTable}\n WHERE ${parentOwner}))`;
|
|
94
98
|
}
|
|
95
99
|
|
|
@@ -103,6 +107,8 @@ interface OwnerRef {
|
|
|
103
107
|
type: string;
|
|
104
108
|
/** JWT claim holding the owner id. */
|
|
105
109
|
claim: string;
|
|
110
|
+
/** A declared accessor for the caller's id (`auth.user_id()`), or null for the inline claim. */
|
|
111
|
+
expr: string | null;
|
|
106
112
|
}
|
|
107
113
|
|
|
108
114
|
/**
|
|
@@ -135,6 +141,7 @@ function resolveOwner(model: ModelDescriptor): OwnerRef | null {
|
|
|
135
141
|
sqlColumn: toSnakeCase(fieldKey),
|
|
136
142
|
type: model.fields[fieldKey]?.spec.type ?? 'uuid',
|
|
137
143
|
claim: ownerAbility.condition.userField ?? 'sub',
|
|
144
|
+
expr: rawExpr(ownerAbility.condition.ownerExpr, `${model.table}: can({ ownerExpr })`),
|
|
138
145
|
};
|
|
139
146
|
}
|
|
140
147
|
|
|
@@ -244,6 +251,23 @@ function rawPredicate(value: unknown, where: string): string | null {
|
|
|
244
251
|
return parenthesizeOnce(text.trim());
|
|
245
252
|
}
|
|
246
253
|
|
|
254
|
+
/**
|
|
255
|
+
* Unwrap a sql fragment to its text, UNparenthesized — for an expression that is not a
|
|
256
|
+
* predicate. `ownerExpr` is the right-hand side of `<col> = …`; parenthesizing it would
|
|
257
|
+
* emit `(user_id = (auth.user_id()))`, which is not what the deparser prints, and the
|
|
258
|
+
* whole point of declaring it is to match the live text exactly.
|
|
259
|
+
*/
|
|
260
|
+
function rawExpr(value: unknown, where: string): string | null {
|
|
261
|
+
if (value == null) return null;
|
|
262
|
+
const text = (value as { sql?: unknown }).sql;
|
|
263
|
+
if (typeof text !== 'string' || !text.trim()) {
|
|
264
|
+
throw new Error(
|
|
265
|
+
`${where}: expected a sql\`…\` fragment (import { sql } from '@everystack/model'), got ${typeof value}. A bare string is rejected on purpose — the expression that decides who owns a row is authored, never stringly assembled.`,
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
return text.trim();
|
|
269
|
+
}
|
|
270
|
+
|
|
247
271
|
/** AND-compose predicates, dropping the vacuous ones. A condition only ever NARROWS. */
|
|
248
272
|
function andPredicates(...parts: (string | null)[]): string | null {
|
|
249
273
|
const real = parts.filter((p): p is string => Boolean(p) && p !== '(true)' && p !== 'true');
|
|
@@ -270,7 +294,7 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
270
294
|
// parent subquery. Both drive the same owner-gated policies (select/insert/
|
|
271
295
|
// update/delete_own); only the predicate text differs.
|
|
272
296
|
const rowPred = owner
|
|
273
|
-
? ownerPredicate(owner.sqlColumn, owner.type, owner.claim)
|
|
297
|
+
? ownerPredicate(owner.sqlColumn, owner.type, owner.claim, owner.expr)
|
|
274
298
|
: via
|
|
275
299
|
? viaPredicate(via)
|
|
276
300
|
: null;
|
|
@@ -298,6 +322,33 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
298
322
|
}
|
|
299
323
|
return null;
|
|
300
324
|
};
|
|
325
|
+
/**
|
|
326
|
+
* The predicate on the PUBLIC read — a read ability with neither a role nor a row scope.
|
|
327
|
+
*
|
|
328
|
+
* Read separately from {@link ownerReadPred} because the two are different BRANCHES of
|
|
329
|
+
* the same authenticated policy. Taking both from one ordered lookup made the anon
|
|
330
|
+
* policy depend on the order the abilities happened to be declared in: put the owner
|
|
331
|
+
* read first and `anon` inherited the owner branch's guard as its whole public rule.
|
|
332
|
+
*/
|
|
333
|
+
const publicReadPred = (): string | null => {
|
|
334
|
+
for (const a of model.abilities) {
|
|
335
|
+
if (a.action !== 'read' || a.condition.role || rowScoped(a)) continue;
|
|
336
|
+
const p = rawPredicate(a.condition.sql ?? a.condition.where, `${table}: can('read')`);
|
|
337
|
+
if (p) return p;
|
|
338
|
+
}
|
|
339
|
+
return null;
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
/** The predicate the OWNER read narrows ITSELF by — `can('read', { owner, sql })`. */
|
|
343
|
+
const ownerReadPred = (): string | null => {
|
|
344
|
+
for (const a of model.abilities) {
|
|
345
|
+
if (a.action !== 'read' || !rowScoped(a) || isColumnRead(a)) continue;
|
|
346
|
+
const p = rawPredicate(a.condition.sql ?? a.condition.where, `${table}: can('read', { owner })`);
|
|
347
|
+
if (p) return p;
|
|
348
|
+
}
|
|
349
|
+
return null;
|
|
350
|
+
};
|
|
351
|
+
|
|
301
352
|
/** The WRITE half, when the author declared one distinct from the read half. */
|
|
302
353
|
const checkFor = (action: Ability['action']): string | null => {
|
|
303
354
|
for (const a of model.abilities) {
|
|
@@ -334,18 +385,37 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
334
385
|
if (hasPublicRead) {
|
|
335
386
|
// public read on a soft-delete table — split per role; authenticated owners
|
|
336
387
|
// also see their own (soft-deleted) rows, hence the OR'd owner check.
|
|
337
|
-
const readPred =
|
|
388
|
+
const readPred = publicReadPred();
|
|
338
389
|
const anonUsing = andPredicates(sdGuard, readPred) ?? 'true';
|
|
339
390
|
policy(`${t}_select_anon`, 'SELECT', ['anon'], anonUsing, null);
|
|
340
391
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
392
|
+
let authedUsing: string;
|
|
393
|
+
if (hasOwnerRead && rowPred && anonUsing !== 'true') {
|
|
394
|
+
// PUBLIC **OR** MINE. Two read abilities on one model — a public read and an
|
|
395
|
+
// owner read — are two branches of one authenticated policy, not a contest the
|
|
396
|
+
// public one wins. They used to be exactly that contest: `hasPublicRead` took
|
|
397
|
+
// the branch and the owner read was computed and discarded, so a signed-in
|
|
398
|
+
// author could not see their own non-public rows. On a real brownfield schema
|
|
399
|
+
// that hid ~13k legacy `pending` rows from the people who wrote them.
|
|
400
|
+
//
|
|
401
|
+
// The owner branch escapes the public predicate (that is the whole point) and
|
|
402
|
+
// carries its OWN guard — `can('read', { owner: 'userId', sql: … })` — so the
|
|
403
|
+
// author says what "mine" means without loosening what "public" means.
|
|
404
|
+
const ownBranch = andPredicates(rowPred, ownerReadPred())!;
|
|
405
|
+
authedUsing = `(${anonUsing} OR ${ownBranch})`;
|
|
406
|
+
} else {
|
|
407
|
+
// The owner predicate may only ever WIDEN a public read (the soft-delete OR:
|
|
408
|
+
// owners also see their own deleted rows). It must never REPLACE it — an owner
|
|
409
|
+
// condition on a WRITE ability used to narrow the authenticated SELECT to
|
|
410
|
+
// own-rows-only, so anon saw the whole table and a signed-in user lost it.
|
|
411
|
+
// Without a soft-delete guard, `true OR owner` is just `true` — and an
|
|
412
|
+
// unfiltered public read already contains every row an owner could add, which
|
|
413
|
+
// is why `anonUsing === 'true'` skips the disjunction rather than emitting a
|
|
414
|
+
// branch that can never change the answer.
|
|
415
|
+
authedUsing = sdGuard && rowPred
|
|
416
|
+
? andPredicates(`(${sdGuard} OR ${rowPred})`, readPred)!
|
|
417
|
+
: anonUsing;
|
|
418
|
+
}
|
|
349
419
|
policy(`${t}_select_authenticated`, 'SELECT', ['authenticated'], authedUsing, null);
|
|
350
420
|
} else if (hasOwnerRead && rowPred) {
|
|
351
421
|
// owner-scoped read — no anon visibility; you see only the rows you own
|
package/src/cli/authz-derive.ts
CHANGED
|
@@ -65,11 +65,120 @@ const KNOWN_ROLES = new Set(['anon', 'authenticated', 'admin']);
|
|
|
65
65
|
const OWNER_RE =
|
|
66
66
|
/^\(?([a-z_][a-z0-9_]*)\s*=\s*\(*\(current_setting\('request\.jwt\.claims'::text,\s*true\)\)::jsonb\s*->>\s*'([a-z_]+)'::text\)*(?:::[a-z]+)?\)?$/i;
|
|
67
67
|
|
|
68
|
-
/**
|
|
69
|
-
|
|
68
|
+
/**
|
|
69
|
+
* The OTHER owner shape: the database's own accessor, `(<col> = auth.user_id())`.
|
|
70
|
+
*
|
|
71
|
+
* A brownfield schema almost always has one — a STABLE SECURITY DEFINER function wrapping
|
|
72
|
+
* the same claim read — and every owner policy in it is written against that function, not
|
|
73
|
+
* against the claim inline. Rendering those as the inline claim would be a semantically
|
|
74
|
+
* similar but TEXTUALLY different predicate, and predicates are diffed as text: it would
|
|
75
|
+
* plan a DROP + CREATE on every owner policy in the schema.
|
|
76
|
+
*
|
|
77
|
+
* Deliberately narrow: the right side must be a zero-argument function call. That is what
|
|
78
|
+
* an "who am I" accessor looks like, and it is what a column-to-column comparison
|
|
79
|
+
* (`(user_id = reviewer_id)`) is not — so the matcher cannot mistake a join predicate for
|
|
80
|
+
* a statement of ownership.
|
|
81
|
+
*/
|
|
82
|
+
const OWNER_FN_RE = /^\(?([a-z_][a-z0-9_]*)\s*=\s*((?:[a-z_][a-z0-9_]*\.)?[a-z_][a-z0-9_]*\(\))\)?$/i;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Does this predicate say "the row is mine"? Returns the column, the claim, and the
|
|
86
|
+
* accessor expression when the schema uses one (null = the claim read inline).
|
|
87
|
+
*/
|
|
88
|
+
export function parseOwnerPredicate(
|
|
89
|
+
pred: string | null,
|
|
90
|
+
): { column: string; claim: string; expr: string | null } | null {
|
|
70
91
|
if (!pred) return null;
|
|
71
|
-
const
|
|
72
|
-
|
|
92
|
+
const t = pred.trim();
|
|
93
|
+
const m = OWNER_RE.exec(t);
|
|
94
|
+
if (m) return { column: m[1], claim: m[2], expr: null };
|
|
95
|
+
const f = OWNER_FN_RE.exec(t);
|
|
96
|
+
return f ? { column: f[1], claim: 'sub', expr: f[2] } : null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Split a predicate on a top-level operator, respecting parens and string literals.
|
|
101
|
+
*
|
|
102
|
+
* Needed because the shapes worth recognising are compositional — `(public) OR (mine AND
|
|
103
|
+
* guard)` — and a naive `.split(' OR ')` cuts inside a nested disjunction, which is how a
|
|
104
|
+
* template matcher silently mis-reads `((state <> 'deleted') AND ((user_id = …) OR (…)))`
|
|
105
|
+
* as a top-level OR it is not.
|
|
106
|
+
*/
|
|
107
|
+
function splitTopLevel(pred: string, op: 'OR' | 'AND'): string[] {
|
|
108
|
+
const needle = ` ${op} `;
|
|
109
|
+
const parts: string[] = [];
|
|
110
|
+
let depth = 0;
|
|
111
|
+
let quoted = false;
|
|
112
|
+
let start = 0;
|
|
113
|
+
// Strip one enclosing paren pair so the operator we want is genuinely at depth 0.
|
|
114
|
+
const s = unwrapOnce(pred.trim());
|
|
115
|
+
for (let i = 0; i < s.length; i++) {
|
|
116
|
+
const c = s[i];
|
|
117
|
+
if (c === "'") { quoted = !quoted; continue; }
|
|
118
|
+
if (quoted) continue;
|
|
119
|
+
if (c === '(') depth++;
|
|
120
|
+
else if (c === ')') depth--;
|
|
121
|
+
else if (depth === 0 && s.startsWith(needle, i)) {
|
|
122
|
+
parts.push(s.slice(start, i));
|
|
123
|
+
i += needle.length - 1;
|
|
124
|
+
start = i + 1;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
parts.push(s.slice(start));
|
|
128
|
+
return parts.map((p) => p.trim()).filter(Boolean);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Drop one enclosing paren pair, but only when it spans the WHOLE expression. */
|
|
132
|
+
function unwrapOnce(s: string): string {
|
|
133
|
+
if (!s.startsWith('(') || !s.endsWith(')')) return s;
|
|
134
|
+
let depth = 0;
|
|
135
|
+
let quoted = false;
|
|
136
|
+
for (let i = 0; i < s.length; i++) {
|
|
137
|
+
const c = s[i];
|
|
138
|
+
if (c === "'") { quoted = !quoted; continue; }
|
|
139
|
+
if (quoted) continue;
|
|
140
|
+
if (c === '(') depth++;
|
|
141
|
+
else if (c === ')') {
|
|
142
|
+
depth--;
|
|
143
|
+
if (depth === 0 && i < s.length - 1) return s; // the pair closed early — not enclosing
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return depth === 0 ? s.slice(1, -1).trim() : s;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Decompose an authenticated read of the form `(public) OR (mine [AND guard])`, given the
|
|
151
|
+
* public predicate the anon policy already states.
|
|
152
|
+
*
|
|
153
|
+
* Keyed on the ANON PREDICATE APPEARING VERBATIM as one of two top-level disjuncts, and an
|
|
154
|
+
* owner predicate appearing in the other — not on a shape template. The alternative,
|
|
155
|
+
* matching a `X OR (Y AND Z)` template, both over- and under-fires: it claims predicates it
|
|
156
|
+
* has not proven are a public branch, and it misses the schema whose OR is nested inside an
|
|
157
|
+
* AND. Anything that does not decompose EXACTLY returns null, and the caller says so.
|
|
158
|
+
*/
|
|
159
|
+
export function parsePublicOrOwn(
|
|
160
|
+
authedPred: string | null,
|
|
161
|
+
anonPred: string | null,
|
|
162
|
+
): { owner: { column: string; claim: string; expr: string | null }; ownerSql: string | null } | null {
|
|
163
|
+
if (!authedPred || !anonPred) return null;
|
|
164
|
+
const branches = splitTopLevel(authedPred, 'OR');
|
|
165
|
+
if (branches.length !== 2) return null;
|
|
166
|
+
|
|
167
|
+
const anonNorm = unwrapOnce(anonPred.trim());
|
|
168
|
+
const publicIdx = branches.findIndex((b) => unwrapOnce(b) === anonNorm);
|
|
169
|
+
if (publicIdx === -1) return null;
|
|
170
|
+
|
|
171
|
+
const ownBranch = branches[1 - publicIdx];
|
|
172
|
+
const conjuncts = splitTopLevel(ownBranch, 'AND');
|
|
173
|
+
const ownerIdx = conjuncts.findIndex((c) => parseOwnerPredicate(c));
|
|
174
|
+
if (ownerIdx === -1) return null;
|
|
175
|
+
|
|
176
|
+
const owner = parseOwnerPredicate(conjuncts[ownerIdx])!;
|
|
177
|
+
const rest = conjuncts.filter((_, i) => i !== ownerIdx);
|
|
178
|
+
// More than one leftover conjunct would have to be re-ANDed, and the text the compiler
|
|
179
|
+
// then emits is not guaranteed to be the text that was read. Refuse rather than guess.
|
|
180
|
+
if (rest.length > 1) return null;
|
|
181
|
+
return { owner, ownerSql: rest[0] ?? null };
|
|
73
182
|
}
|
|
74
183
|
|
|
75
184
|
/** Is a privilege EFFECTIVE for a role — i.e. actually granted, not merely policed? */
|
|
@@ -135,15 +244,48 @@ export function deriveAbilities(contract: TableContract): DerivedAbilities {
|
|
|
135
244
|
if (anonRead) {
|
|
136
245
|
// Public read. If the live policy narrows it (a soft-delete guard, a published flag),
|
|
137
246
|
// carry that predicate — a public read with a filter is still a public read.
|
|
138
|
-
const
|
|
139
|
-
const
|
|
247
|
+
const anonPol = policiesFor(contract, 'anon', 'SELECT')[0];
|
|
248
|
+
const anonPred = anonPol?.using ?? null;
|
|
249
|
+
const frag = sqlFragment(anonPred);
|
|
140
250
|
abilities.push(frag ? `can('read', { sql: ${frag} })` : `can('read')`);
|
|
251
|
+
|
|
252
|
+
// The authenticated SELECT is a SEPARATE policy and routinely says MORE than the anon
|
|
253
|
+
// one — `(public) OR (mine)`, so an author sees their own not-yet-public rows. Reading
|
|
254
|
+
// only the anon policy dropped that branch and rendered a model that meant strictly
|
|
255
|
+
// less than the database did, with nothing said. On a real schema it hid ~13,184
|
|
256
|
+
// legacy rows from the people who wrote them.
|
|
257
|
+
const authedPol = authedRead
|
|
258
|
+
? policiesFor(contract, 'authenticated', 'SELECT').find((p) => p.name !== anonPol?.name)
|
|
259
|
+
: undefined;
|
|
260
|
+
const authedPred = authedPol?.using ?? null;
|
|
261
|
+
if (authedPol && (authedPred ?? 'true') !== (anonPred ?? 'true')) {
|
|
262
|
+
const own = parsePublicOrOwn(authedPred, anonPred);
|
|
263
|
+
if (own) {
|
|
264
|
+
const parts = [`owner: '${own.owner.column}'`];
|
|
265
|
+
if (own.owner.claim !== 'sub') parts.push(`userField: '${own.owner.claim}'`);
|
|
266
|
+
if (own.owner.expr) parts.push(`ownerExpr: sql\`${own.owner.expr}\``);
|
|
267
|
+
const guard = sqlFragment(own.ownerSql);
|
|
268
|
+
if (guard) parts.push(`sql: ${guard}`);
|
|
269
|
+
abilities.push(`can('read', { ${parts.join(', ')} })`);
|
|
270
|
+
} else {
|
|
271
|
+
// P6: it must SAY SO. An unexpressible predicate becomes a comment naming the
|
|
272
|
+
// policy and its text, never a model that quietly means less.
|
|
273
|
+
notes.push(
|
|
274
|
+
`policy "${authedPol.name}" (SELECT) grants authenticated MORE than the anon read,`,
|
|
275
|
+
);
|
|
276
|
+
notes.push(` and its shape is not expressible as abilities. Live USING:`);
|
|
277
|
+
notes.push(` ${authedPred}`);
|
|
278
|
+
notes.push(` Declare it by hand, or leave the table adopted. NOT rendered above.`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
141
281
|
} else if (authedRead) {
|
|
142
282
|
const pol = policiesFor(contract, 'authenticated', 'SELECT')[0];
|
|
143
283
|
const owner = parseOwnerPredicate(pol?.using ?? null);
|
|
144
284
|
if (owner) {
|
|
145
|
-
const
|
|
146
|
-
|
|
285
|
+
const parts = [`owner: '${owner.column}'`];
|
|
286
|
+
if (owner.claim !== 'sub') parts.push(`userField: '${owner.claim}'`);
|
|
287
|
+
if (owner.expr) parts.push(`ownerExpr: sql\`${owner.expr}\``);
|
|
288
|
+
abilities.push(`can('read', { ${parts.join(', ')} })`);
|
|
147
289
|
} else {
|
|
148
290
|
const frag = sqlFragment(pol?.using ?? null);
|
|
149
291
|
abilities.push(frag ? `can('read', { role: 'authenticated', sql: ${frag} })` : `can('read', { role: 'authenticated' })`);
|
|
@@ -165,6 +307,7 @@ export function deriveAbilities(contract: TableContract): DerivedAbilities {
|
|
|
165
307
|
if (owner) {
|
|
166
308
|
parts.push(`owner: '${owner.column}'`);
|
|
167
309
|
if (owner.claim !== 'sub') parts.push(`userField: '${owner.claim}'`);
|
|
310
|
+
if (owner.expr) parts.push(`ownerExpr: sql\`${owner.expr}\``);
|
|
168
311
|
}
|
|
169
312
|
// A predicate beyond the owner gate (a state guard, a tenant filter) rides as `sql`.
|
|
170
313
|
const usingFrag = owner && parseOwnerPredicate(using) ? null : sqlFragment(using);
|
|
@@ -200,7 +343,11 @@ export function deriveAbilities(contract: TableContract): DerivedAbilities {
|
|
|
200
343
|
/** Render one table's derived stanza as the lines that sit inside a model literal. */
|
|
201
344
|
export function renderDerivedAbilities(d: DerivedAbilities): string {
|
|
202
345
|
const out: string[] = [];
|
|
203
|
-
|
|
346
|
+
// Split on newlines: a note may carry a live predicate verbatim, and `pg_get_expr`
|
|
347
|
+
// deparses a subquery across several lines. One `//` for the first line left the rest
|
|
348
|
+
// as bare SQL in the middle of a TypeScript object — a generated file that would not
|
|
349
|
+
// parse. Commenting per line here means no note, present or future, can do that.
|
|
350
|
+
for (const n of d.notes) for (const line of String(n).split('\n')) out.push(` // ${line}`);
|
|
204
351
|
if (d.abilities.length) {
|
|
205
352
|
out.push(` abilities: [${d.abilities.join(', ')}],`);
|
|
206
353
|
} else {
|
|
@@ -83,6 +83,24 @@ export function keyCandidates(row: Record<string, unknown>, cols: string[]): str
|
|
|
83
83
|
return cols.filter((_, i) => Number(row[`c${i}`]) === n && Number(row[`d${i}`]) === n);
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* The specifier the generated `index.ts` uses to import the `--derived-out` file.
|
|
88
|
+
*
|
|
89
|
+
* Both paths are resolved against CWD first, so this works whichever way either was written
|
|
90
|
+
* (`db/models` + `db/models/derived.ts`, or absolute, or `./db/models/`). Extension stripped
|
|
91
|
+
* (the barrel's own imports are extensionless), separators normalized for Windows, and a
|
|
92
|
+
* same-directory result gets the explicit `./` a bare `derived` would lack.
|
|
93
|
+
*
|
|
94
|
+
* `--out` may also name a single `.ts` file, in which case the barrel IS that file and the
|
|
95
|
+
* specifier is relative to its directory.
|
|
96
|
+
*/
|
|
97
|
+
export function derivedImportSpecifier(out: string, derivedOut: string): string {
|
|
98
|
+
const barrelDir = out.endsWith('.ts') ? path.dirname(path.resolve(out)) : path.resolve(out);
|
|
99
|
+
const target = path.resolve(derivedOut).replace(/\.ts$/, '');
|
|
100
|
+
const rel = path.relative(barrelDir, target).split(path.sep).join('/');
|
|
101
|
+
return rel.startsWith('.') ? rel : `./${rel}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
86
104
|
/** A QueryRunner backed by the ops Lambda `db:query` action (read-only). */
|
|
87
105
|
function lambdaRunner(region: string, fn: string): QueryRunner {
|
|
88
106
|
return async (sql: string) => {
|
|
@@ -269,15 +287,26 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
269
287
|
process.exit(0);
|
|
270
288
|
}
|
|
271
289
|
}
|
|
272
|
-
// With --derived-out, the embedded copy would duplicate every descriptor — the
|
|
273
|
-
//
|
|
290
|
+
// With --derived-out, the embedded copy would duplicate every descriptor — the models
|
|
291
|
+
// output carries models only, and the barrel IMPORTS the layer from its own file. It used
|
|
292
|
+
// to carry neither, so the same command that wrote 120 descriptors emitted a
|
|
293
|
+
// `defineModule({ models })` that excluded every one of them, and db:plan then compared
|
|
294
|
+
// against a fraction of the database while printing a confident statement count.
|
|
274
295
|
const embeddedDerived = derivedOut ? undefined : derived;
|
|
296
|
+
const externalDerived = derivedOut && flags.out
|
|
297
|
+
? {
|
|
298
|
+
specifier: derivedImportSpecifier(flags.out, derivedOut),
|
|
299
|
+
sequences: derived.sequenceNames.length > 0,
|
|
300
|
+
materializedTables: derived.materializedTableNames.length > 0,
|
|
301
|
+
derived: derived.names.length > 0,
|
|
302
|
+
}
|
|
303
|
+
: undefined;
|
|
275
304
|
|
|
276
305
|
let source: string;
|
|
277
306
|
if (flags.out && !flags.out.endsWith('.ts')) {
|
|
278
307
|
// A directory: one file per model + index.ts — the default shape for a real app.
|
|
279
308
|
const dir = path.resolve(flags.out);
|
|
280
|
-
const files = renderModelFiles(current, { schema, abilities, liveAuthz, derived: embeddedDerived });
|
|
309
|
+
const files = renderModelFiles(current, { schema, abilities, liveAuthz, derived: embeddedDerived, externalDerived });
|
|
281
310
|
try {
|
|
282
311
|
await fs.mkdir(dir, { recursive: true });
|
|
283
312
|
const written = new Set(files.map((f) => f.file));
|
package/src/cli/model-render.ts
CHANGED
|
@@ -81,6 +81,10 @@ export interface RenderOptions {
|
|
|
81
81
|
/** The rendered derived layer (B5) — rides the barrel: block after the models,
|
|
82
82
|
* sequences/derived arrays on the module wrapper, symbols on the import header. */
|
|
83
83
|
derived?: DerivedRenderResult;
|
|
84
|
+
/** The `--derived-out` case: the layer lives in its own file, so the barrel imports and
|
|
85
|
+
* wires it instead of embedding it. Mutually exclusive with `derived` in practice —
|
|
86
|
+
* passing neither is what silently produced `defineModule({ models })`. */
|
|
87
|
+
externalDerived?: ExternalDerived;
|
|
84
88
|
}
|
|
85
89
|
|
|
86
90
|
/**
|
|
@@ -505,22 +509,42 @@ function importHeader(body: string, extra: string[] = []): string {
|
|
|
505
509
|
return imports.join('\n');
|
|
506
510
|
}
|
|
507
511
|
|
|
508
|
-
/**
|
|
509
|
-
|
|
512
|
+
/**
|
|
513
|
+
* The barrel's module wrapper: models always; sequences/derived when the pull found any (B5).
|
|
514
|
+
*
|
|
515
|
+
* `external` is the `--derived-out` case — the descriptors live in their own file, so the
|
|
516
|
+
* barrel IMPORTS the arrays rather than re-declaring them. Before this the barrel simply
|
|
517
|
+
* omitted them, which meant the same pull that wrote 120 descriptors emitted a
|
|
518
|
+
* `defineModule({ models })` that excluded every one, and `db:plan` then compared against a
|
|
519
|
+
* fraction of the database while reporting a confident statement count. A plan scoped to
|
|
520
|
+
* something narrower than the reader assumes is the same false-completeness failure as a
|
|
521
|
+
* classification that defaults to safe.
|
|
522
|
+
*/
|
|
523
|
+
function moduleFooter(
|
|
524
|
+
modelNames: string[],
|
|
525
|
+
derived?: DerivedRenderResult,
|
|
526
|
+
multiline = false,
|
|
527
|
+
external?: ExternalDerived,
|
|
528
|
+
): string {
|
|
510
529
|
// A materialized table (--matviews-as-tables) is a MODEL — its block rides the derived
|
|
511
530
|
// render (topo-ordered with the objects around it) but its name belongs in `models`.
|
|
512
|
-
const
|
|
513
|
-
const
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
531
|
+
const hasMaterialized = external ? external.materializedTables : Boolean(derived?.materializedTableNames.length);
|
|
532
|
+
const inlineNames = external ? [] : (derived?.materializedTableNames ?? []);
|
|
533
|
+
const allModels = [...modelNames, ...inlineNames];
|
|
534
|
+
const rendered = multiline
|
|
535
|
+
? `export const models = [\n${allModels.map((n) => ` ${n},`).join('\n')}\n${external && hasMaterialized ? ' ...materializedTables,\n' : ''}];`
|
|
536
|
+
: `export const models = [${[...allModels, ...(external && hasMaterialized ? ['...materializedTables'] : [])].join(', ')}];`;
|
|
537
|
+
const parts = [rendered];
|
|
517
538
|
const keys = ['models'];
|
|
518
|
-
|
|
519
|
-
|
|
539
|
+
|
|
540
|
+
const hasSequences = external ? external.sequences : Boolean(derived?.sequenceNames.length);
|
|
541
|
+
const hasDerived = external ? external.derived : Boolean(derived?.names.length);
|
|
542
|
+
if (hasSequences) {
|
|
543
|
+
if (!external) parts.push(`export const sequences = [${derived!.sequenceNames.join(', ')}];`);
|
|
520
544
|
keys.push('sequences');
|
|
521
545
|
}
|
|
522
|
-
if (
|
|
523
|
-
parts.push(`export const derived = [${derived
|
|
546
|
+
if (hasDerived) {
|
|
547
|
+
if (!external) parts.push(`export const derived = [${derived!.names.join(', ')}];`);
|
|
524
548
|
keys.push('derived');
|
|
525
549
|
}
|
|
526
550
|
parts.push(`export const appModule = defineModule({ ${keys.join(', ')} });`);
|
|
@@ -528,6 +552,24 @@ function moduleFooter(modelNames: string[], derived?: DerivedRenderResult, multi
|
|
|
528
552
|
return parts.join('\n\n');
|
|
529
553
|
}
|
|
530
554
|
|
|
555
|
+
/** What an external (`--derived-out`) derived file exports, so the barrel can wire it. */
|
|
556
|
+
export interface ExternalDerived {
|
|
557
|
+
/** Import specifier as written in the barrel, e.g. `'./derived'`. */
|
|
558
|
+
specifier: string;
|
|
559
|
+
sequences: boolean;
|
|
560
|
+
materializedTables: boolean;
|
|
561
|
+
derived: boolean;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** The named exports to pull out of an external derived file, in a stable order. */
|
|
565
|
+
function externalDerivedNames(e: ExternalDerived): string[] {
|
|
566
|
+
const names: string[] = [];
|
|
567
|
+
if (e.derived) names.push('derived');
|
|
568
|
+
if (e.materializedTables) names.push('materializedTables');
|
|
569
|
+
if (e.sequences) names.push('sequences');
|
|
570
|
+
return names;
|
|
571
|
+
}
|
|
572
|
+
|
|
531
573
|
/**
|
|
532
574
|
* Render a whole snapshot as a single Models module: the import, one block per table (scoped
|
|
533
575
|
* to `opts.schema`), and the `models` array the rest of the framework consumes. The tables
|
|
@@ -610,14 +652,21 @@ export function renderModelFiles(snapshot: SchemaSnapshot, opts: RenderOptions =
|
|
|
610
652
|
// their own model.
|
|
611
653
|
const names = tables.map((t) => modelVarName(t.table));
|
|
612
654
|
const modelSymbols = ['defineModule', ...(opts.derived?.imports ?? [])];
|
|
655
|
+
const ext = opts.externalDerived;
|
|
656
|
+
const extNames = ext ? externalDerivedNames(ext) : [];
|
|
613
657
|
const index = [
|
|
614
658
|
[
|
|
615
659
|
`import { ${modelSymbols.join(', ')} } from '@everystack/model';`,
|
|
660
|
+
// The --derived-out layer, imported rather than re-declared — the barrel must WIRE it
|
|
661
|
+
// or db:plan silently compares against models only.
|
|
662
|
+
...(extNames.length ? [`import { ${extNames.join(', ')} } from '${ext!.specifier}';`] : []),
|
|
616
663
|
...names.map((n, i) => `import { ${n} } from './${bareName(tables[i].table).replace(/_/g, '-')}';`),
|
|
617
664
|
].join('\n'),
|
|
618
665
|
`export {\n${names.map((n) => ` ${n},`).join('\n')}\n};`,
|
|
666
|
+
// Re-exported so the barrel remains the one place that describes the database.
|
|
667
|
+
...(extNames.length ? [`export { ${extNames.join(', ')} };`] : []),
|
|
619
668
|
...(opts.derived?.block ? [opts.derived.block] : []),
|
|
620
|
-
moduleFooter(names, opts.derived, true),
|
|
669
|
+
moduleFooter(names, opts.derived, true, ext),
|
|
621
670
|
].join('\n\n');
|
|
622
671
|
files.push({ file: 'index.ts', source: index + '\n' });
|
|
623
672
|
|