@everystack/cli 0.4.55 → 0.4.56
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-contract.ts +155 -3
- package/src/cli/authz-reconcile.ts +146 -1
- package/src/cli/authz-render.ts +26 -4
- package/src/cli/commands/db-check.ts +4 -1
- package/src/cli/commands/db-fingerprint.ts +16 -2
- package/src/cli/commands/db-pull.ts +6 -1
- package/src/cli/commands/db-reconcile.ts +41 -2
- package/src/cli/commands/security.ts +24 -3
- package/src/cli/db-build.ts +36 -1
- package/src/cli/derived-apply.ts +249 -18
- package/src/cli/derived-compile.ts +25 -7
- package/src/cli/derived-grants.ts +96 -3
- package/src/cli/derived-introspect.ts +98 -1
- package/src/cli/derived-lint.ts +56 -0
- package/src/cli/derived-plan.ts +48 -1
- package/src/cli/derived-render.ts +153 -3
- package/src/cli/derived-source.ts +32 -2
- package/src/cli/migration-generate.ts +3 -0
- package/src/cli/model-render.ts +28 -3
- package/src/cli/schema-compile.ts +66 -9
- package/src/cli/schema-diff.ts +133 -14
- package/src/cli/schema-fingerprint.ts +58 -5
- package/src/cli/schema-introspect.ts +40 -0
- package/src/cli/schema-source.ts +19 -1
- package/src/cli/security-audit.ts +131 -2
- package/src/cli/security-catalog.ts +18 -3
- package/src/cli/state-apply.ts +7 -1
|
@@ -37,6 +37,33 @@ export interface LiveObject {
|
|
|
37
37
|
comment?: string;
|
|
38
38
|
/** sha256 over normalized definition + indexes + comment. */
|
|
39
39
|
defHash: string;
|
|
40
|
+
/**
|
|
41
|
+
* Functions, views and matviews: the owning role.
|
|
42
|
+
*
|
|
43
|
+
* Read because the grant differ CANNOT be correct without it. Owner-implicit privileges are
|
|
44
|
+
* excluded from the ACL read (`a.grantee <> p.proowner` / `<> c.relowner`), which is right —
|
|
45
|
+
* but it means the SET of non-owner grants shifts when the owner changes, so the same object
|
|
46
|
+
* under two owners yields two different privilege sets and the differ mints statements to
|
|
47
|
+
* reconcile a difference that is purely ownership.
|
|
48
|
+
*
|
|
49
|
+
* A15 — and for views it is a privilege boundary in its own right. A `security_invoker =
|
|
50
|
+
* false` view executes with its OWNER's rights, exactly as a SECURITY DEFINER function does.
|
|
51
|
+
* Undeclared, the owner is whoever ran the build: an `admin`-owned sealed-slice view rebuilds
|
|
52
|
+
* as builder-owned, `admin` can no longer read through it, and every gate stays green.
|
|
53
|
+
*/
|
|
54
|
+
owner?: string;
|
|
55
|
+
/**
|
|
56
|
+
* Is `owner` a SUPERUSER (or an rds_superuser member — RDS has no true superuser)? Half of
|
|
57
|
+
* the discriminator db:pull uses to decide whether the observed owner is a CHOSEN principal
|
|
58
|
+
* or the ambient build credential. Absent means not measured.
|
|
59
|
+
*/
|
|
60
|
+
ownerIsSuperuser?: boolean;
|
|
61
|
+
/**
|
|
62
|
+
* Is `owner` the role this introspection is CONNECTED AS? The other half of the
|
|
63
|
+
* discriminator. A normalized non-superuser migrator owns everything it creates by default,
|
|
64
|
+
* so "superuser" alone would miss it. Absent means not measured.
|
|
65
|
+
*/
|
|
66
|
+
ownerIsConnectedRole?: boolean;
|
|
40
67
|
/** Matview only: whether it holds data. */
|
|
41
68
|
populated?: boolean;
|
|
42
69
|
/** Matview only: pg_total_relation_size — the rebuild-cost signal. */
|
|
@@ -128,7 +155,22 @@ SELECT
|
|
|
128
155
|
EXISTS (
|
|
129
156
|
SELECT 1 FROM unnest(COALESCE(c.reloptions, '{}'::text[])) o
|
|
130
157
|
WHERE o IN ('security_invoker=true', 'security_invoker=on')
|
|
131
|
-
) AS security_invoker
|
|
158
|
+
) AS security_invoker,
|
|
159
|
+
-- A15. The same three fields the function read carries, for the same reason: a
|
|
160
|
+
-- security_invoker = false view executes with its OWNER's rights, so its owner is a
|
|
161
|
+
-- privilege boundary exactly as a SECURITY DEFINER function's is. The two discriminators
|
|
162
|
+
-- let db:pull tell a CHOSEN owner from the ambient build credential.
|
|
163
|
+
pg_get_userbyid(c.relowner) AS owner,
|
|
164
|
+
COALESCE(
|
|
165
|
+
(SELECT r.rolsuper
|
|
166
|
+
OR EXISTS (
|
|
167
|
+
SELECT 1 FROM pg_auth_members m JOIN pg_roles g ON g.oid = m.roleid
|
|
168
|
+
WHERE m.member = c.relowner AND g.rolname = 'rds_superuser'
|
|
169
|
+
)
|
|
170
|
+
FROM pg_roles r WHERE r.oid = c.relowner),
|
|
171
|
+
false
|
|
172
|
+
) AS owner_is_superuser,
|
|
173
|
+
(pg_get_userbyid(c.relowner) IN (CURRENT_USER, SESSION_USER)) AS owner_is_connected_role
|
|
132
174
|
FROM pg_class c
|
|
133
175
|
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
134
176
|
WHERE c.relkind IN ('v', 'm')
|
|
@@ -171,6 +213,25 @@ SELECT
|
|
|
171
213
|
l.lanname AS language,
|
|
172
214
|
p.prosecdef AS secdef,
|
|
173
215
|
p.provolatile AS volatility,
|
|
216
|
+
pg_get_userbyid(p.proowner) AS owner,
|
|
217
|
+
-- The AMBIENT-BUILD-CREDENTIAL discriminator, two clauses and no more. db:pull renders
|
|
218
|
+
-- the owner only when the observed owner is neither of these — rendering the ambient
|
|
219
|
+
-- credential would BLESS an accident, turning "whoever happened to run the migration" into
|
|
220
|
+
-- fingerprinted, reproducible design. When it IS ambient the renderer emits a FIXME instead:
|
|
221
|
+
-- the defect stays visible without being ratified.
|
|
222
|
+
--
|
|
223
|
+
-- Superuser, matching security-catalog's rule (RDS has no true superuser, so rds_superuser
|
|
224
|
+
-- membership counts).
|
|
225
|
+
(
|
|
226
|
+
SELECT r.rolsuper OR EXISTS (
|
|
227
|
+
SELECT 1 FROM pg_auth_members m JOIN pg_roles g ON g.oid = m.roleid
|
|
228
|
+
WHERE m.member = p.proowner AND g.rolname = 'rds_superuser'
|
|
229
|
+
)
|
|
230
|
+
FROM pg_roles r WHERE r.oid = p.proowner
|
|
231
|
+
) AS owner_is_superuser,
|
|
232
|
+
-- ...or the role we are connected as. A normalized, deliberately non-superuser migrator owns
|
|
233
|
+
-- every object it creates, so the superuser clause alone would miss the commonest case.
|
|
234
|
+
(pg_get_userbyid(p.proowner) IN (CURRENT_USER, SESSION_USER)) AS owner_is_connected_role,
|
|
174
235
|
(SELECT split_part(cfg, '=', 2) FROM unnest(COALESCE(p.proconfig, '{}'::text[])) cfg
|
|
175
236
|
WHERE cfg LIKE 'search_path=%' LIMIT 1) AS search_path,
|
|
176
237
|
p.prosrc AS src
|
|
@@ -458,6 +519,12 @@ export interface RelationRow {
|
|
|
458
519
|
rows: unknown;
|
|
459
520
|
/** security_invoker reloption (views). Optional — pre-B5 fixtures omit it. */
|
|
460
521
|
security_invoker?: unknown;
|
|
522
|
+
/** `pg_get_userbyid(relowner)` — see LiveObject.owner. Optional: pre-A15 fixtures omit it. */
|
|
523
|
+
owner?: unknown;
|
|
524
|
+
/** See LiveObject.ownerIsSuperuser. Optional: absent means not measured. */
|
|
525
|
+
owner_is_superuser?: unknown;
|
|
526
|
+
/** See LiveObject.ownerIsConnectedRole. Optional: absent means not measured. */
|
|
527
|
+
owner_is_connected_role?: unknown;
|
|
461
528
|
}
|
|
462
529
|
|
|
463
530
|
export interface FunctionRow {
|
|
@@ -475,6 +542,12 @@ export interface FunctionRow {
|
|
|
475
542
|
secdef?: unknown;
|
|
476
543
|
volatility?: unknown;
|
|
477
544
|
search_path?: unknown;
|
|
545
|
+
/** `pg_get_userbyid(proowner)` — see LiveObject.owner. Optional: pre-owner fixtures omit it. */
|
|
546
|
+
owner?: unknown;
|
|
547
|
+
/** See LiveObject.ownerIsSuperuser. Optional: absent means not measured. */
|
|
548
|
+
owner_is_superuser?: unknown;
|
|
549
|
+
/** See LiveObject.ownerIsConnectedRole. Optional: absent means not measured. */
|
|
550
|
+
owner_is_connected_role?: unknown;
|
|
478
551
|
src?: unknown;
|
|
479
552
|
}
|
|
480
553
|
|
|
@@ -544,6 +617,19 @@ export interface DerivedRows {
|
|
|
544
617
|
grants?: GrantAclRow[];
|
|
545
618
|
}
|
|
546
619
|
|
|
620
|
+
/** Coerce a Postgres boolean as any driver spells it. `undefined` means NOT MEASURED —
|
|
621
|
+
* never conflated with a measured `false`. NULL is not-measured too: the only way these
|
|
622
|
+
* sub-selects go NULL is the role vanishing between reads, and guessing there would be
|
|
623
|
+
* exactly the fabrication this feature exists to stop. */
|
|
624
|
+
function pgBool(v: unknown): boolean | undefined {
|
|
625
|
+
if (v === undefined || v === null) return undefined;
|
|
626
|
+
if (typeof v === 'boolean') return v;
|
|
627
|
+
const s = String(v).toLowerCase();
|
|
628
|
+
if (s === 't' || s === 'true') return true;
|
|
629
|
+
if (s === 'f' || s === 'false') return false;
|
|
630
|
+
return undefined;
|
|
631
|
+
}
|
|
632
|
+
|
|
547
633
|
/** Coerce a Postgres text[] (a JS array, or the `{a,b}` text form some drivers return) to string[]. */
|
|
548
634
|
function pgTextArray(v: unknown): string[] {
|
|
549
635
|
if (Array.isArray(v)) return v.map(String);
|
|
@@ -597,6 +683,12 @@ export function assembleDerivedCatalog(rows: DerivedRows): DerivedCatalog {
|
|
|
597
683
|
definition, indexes,
|
|
598
684
|
...(comment !== undefined ? { comment } : {}),
|
|
599
685
|
defHash: computeDefHash(definition, indexes, comment),
|
|
686
|
+
// A15 — same three fields, same meaning, as the function branch below. Absent (not
|
|
687
|
+
// measured) and false (measured, not ambient) stay DIFFERENT, so a pre-A15 fixture
|
|
688
|
+
// keeps behaving exactly as it did.
|
|
689
|
+
...(row.owner != null ? { owner: String(row.owner) } : {}),
|
|
690
|
+
...(pgBool(row.owner_is_superuser) !== undefined ? { ownerIsSuperuser: pgBool(row.owner_is_superuser) } : {}),
|
|
691
|
+
...(pgBool(row.owner_is_connected_role) !== undefined ? { ownerIsConnectedRole: pgBool(row.owner_is_connected_role) } : {}),
|
|
600
692
|
...grantsFor('r', identity),
|
|
601
693
|
};
|
|
602
694
|
if (kind === 'materialized view') {
|
|
@@ -625,6 +717,11 @@ export function assembleDerivedCatalog(rows: DerivedRows): DerivedCatalog {
|
|
|
625
717
|
definition, indexes: [],
|
|
626
718
|
...(comment !== undefined ? { comment } : {}),
|
|
627
719
|
defHash: computeDefHash(definition, [], comment),
|
|
720
|
+
...(row.owner != null ? { owner: String(row.owner) } : {}),
|
|
721
|
+
// Absent (not measured) and false (measured, not ambient) are DIFFERENT — a pre-owner
|
|
722
|
+
// fixture must keep behaving exactly as it did, so undefined never becomes false here.
|
|
723
|
+
...(pgBool(row.owner_is_superuser) !== undefined ? { ownerIsSuperuser: pgBool(row.owner_is_superuser) } : {}),
|
|
724
|
+
...(pgBool(row.owner_is_connected_role) !== undefined ? { ownerIsConnectedRole: pgBool(row.owner_is_connected_role) } : {}),
|
|
628
725
|
...grantsFor('f', identity),
|
|
629
726
|
...(row.src !== undefined ? {
|
|
630
727
|
fn: {
|
package/src/cli/derived-lint.ts
CHANGED
|
@@ -233,6 +233,62 @@ export function findPublicExecutableSecdef(derived: readonly DerivedDescriptor[]
|
|
|
233
233
|
return warnings;
|
|
234
234
|
}
|
|
235
235
|
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
// Advisory: SECURITY DEFINER with no pin (db:check warn) — A3's linter half
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
export interface UnpinnedDefinerFinding extends DerivedAuthzGap {
|
|
241
|
+
/** The schemas the DECLARATION can justify, `pg_catalog` first. A floor to review — never applied. */
|
|
242
|
+
suggested: string[];
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Every SECURITY DEFINER function that declares `searchPath: 'unpinned'`, with a SUGGESTED pin.
|
|
247
|
+
*
|
|
248
|
+
* This is the half of A3 that keeps the honesty from becoming permission. `db:pull` no longer
|
|
249
|
+
* invents `['pg_catalog']` for a function whose live `proconfig` is empty — inventing it was an
|
|
250
|
+
* outage that passed every gate, and fabrication corrodes fingerprint, plan and apply, all of
|
|
251
|
+
* which rest on the model telling the truth. So the parser records what is there and the
|
|
252
|
+
* OPINION lives here: an unpinned definer function is a real latent vulnerability, and it gets
|
|
253
|
+
* named on every run until someone decides about it.
|
|
254
|
+
*
|
|
255
|
+
* SUGGEST, never apply. The suggestion is derived from the DECLARED structure — the function's
|
|
256
|
+
* own schema plus the schemas of its `dependsOn` — and not from reading the body. Body-derived
|
|
257
|
+
* pins are inventing with extra steps: dynamic SQL, operators and casts defeat the parse, and a
|
|
258
|
+
* pin that is wrong in the tool's favour is the same failure mode with better intentions. The
|
|
259
|
+
* suggestion is a floor. The author reviews it and lands it themselves.
|
|
260
|
+
*
|
|
261
|
+
* WARN, not fail. On an adopted database this is a faithful report of what is already true, and
|
|
262
|
+
* failing CI on a faithful pull would make `db:pull` unusable on exactly the schemas it exists
|
|
263
|
+
* for. Same call, and the same reasoning, as findPublicExecutableSecdef above.
|
|
264
|
+
*/
|
|
265
|
+
export function findUnpinnedDefiners(derived: readonly DerivedDescriptor[]): UnpinnedDefinerFinding[] {
|
|
266
|
+
const findings: UnpinnedDefinerFinding[] = [];
|
|
267
|
+
for (const d of derived) {
|
|
268
|
+
if (d.kind !== 'function' || d.security !== 'definer' || d.searchPath !== 'unpinned') continue;
|
|
269
|
+
const identity = identityOf(d);
|
|
270
|
+
const schemas = new Set<string>([parseQualified(d.name).schema]);
|
|
271
|
+
for (const ref of d.dependsOn ?? []) {
|
|
272
|
+
schemas.add(isModelRef(ref) ? (ref.schema ?? 'public') : parseQualified(ref.name).schema);
|
|
273
|
+
}
|
|
274
|
+
// pg_catalog FIRST and always: it is what stops a caller shadowing a built-in the body
|
|
275
|
+
// calls unqualified, which is the whole attack this pin defends against.
|
|
276
|
+
const suggested = ['pg_catalog', ...[...schemas].filter((s) => s !== 'pg_catalog').sort()];
|
|
277
|
+
findings.push({
|
|
278
|
+
identity,
|
|
279
|
+
suggested,
|
|
280
|
+
message:
|
|
281
|
+
`${identity} is SECURITY DEFINER with NO search_path — declared 'unpinned', which is the truth about the `
|
|
282
|
+
+ `database, not an approval. It runs with its owner's rights, and a caller controls the search_path, so an `
|
|
283
|
+
+ `unqualified name in the body can be resolved to an object the caller planted. SUGGESTED pin from what the `
|
|
284
|
+
+ `declaration references: searchPath: [${suggested.map((s) => `'${s}'`).join(', ')}]. That is a FLOOR derived `
|
|
285
|
+
+ `from dependsOn, not from reading the body — dynamic SQL, operators and casts are invisible to it, so review `
|
|
286
|
+
+ `it, extend it, and land it yourself. Nothing here is ever applied for you.`,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
return findings;
|
|
290
|
+
}
|
|
291
|
+
|
|
236
292
|
// ---------------------------------------------------------------------------
|
|
237
293
|
// Gate: matview snapshot over row-scoped sources (db:check warn)
|
|
238
294
|
// ---------------------------------------------------------------------------
|
package/src/cli/derived-plan.ts
CHANGED
|
@@ -233,6 +233,30 @@ export function planReconcile(
|
|
|
233
233
|
const sqlDrops = new Map<string, { dropSql: string; reason: string }>();
|
|
234
234
|
const extraWarnings: string[] = [];
|
|
235
235
|
|
|
236
|
+
/**
|
|
237
|
+
* B2 — the declared `SET search_path` compared against the LIVE one, structurally.
|
|
238
|
+
*
|
|
239
|
+
* Every other comparison in this table is a hash against a RECORDED hash, which is why the
|
|
240
|
+
* pin could differ in silence: `--baseline` records the declared source hash beside the live
|
|
241
|
+
* def hash without ever comparing the two, so a model claiming `search_path=pg_catalog` about
|
|
242
|
+
* a database with no pin reads as up to date forever. Measured — `db:reconcile --check`
|
|
243
|
+
* returned zero actions and zero drift on exactly that state.
|
|
244
|
+
*
|
|
245
|
+
* This is the one comparison that reads both sides at once. Null when they agree, or when
|
|
246
|
+
* either side was not measured (a parser-era object with no declared pin, a pre-B5 catalog
|
|
247
|
+
* read with no structured fields) — absent evidence is not evidence.
|
|
248
|
+
*/
|
|
249
|
+
const pinMismatch = (src: SourceObject, liveObj: LiveObject): string | null => {
|
|
250
|
+
if (src.kind !== 'function' || src.searchPath === undefined || !liveObj.fn) return null;
|
|
251
|
+
const tokens = (v: string | undefined): string[] =>
|
|
252
|
+
(v ?? '').split(',').map((s) => s.trim().replace(/^"|"$/g, '')).filter(Boolean);
|
|
253
|
+
const declared = src.searchPath === 'unpinned' ? [] : src.searchPath.map((s) => s.trim());
|
|
254
|
+
const livePin = tokens(liveObj.fn.searchPath);
|
|
255
|
+
if (declared.length === livePin.length && declared.every((s, i) => s === livePin[i])) return null;
|
|
256
|
+
const say = (p: string[]): string => (p.length ? `search_path = ${p.join(', ')}` : 'no search_path');
|
|
257
|
+
return `declared ${say(declared)}, live has ${say(livePin)} — replaced from source`;
|
|
258
|
+
};
|
|
259
|
+
|
|
236
260
|
for (const src of source.objects) {
|
|
237
261
|
// --only restricts the primary decisions to the named identities; the rest are left
|
|
238
262
|
// untouched (they are not skips — they were never in scope, so they get no plan entry).
|
|
@@ -271,9 +295,16 @@ export function planReconcile(
|
|
|
271
295
|
// (live deparse), so the reconciler can't verify live == source. --baseline trusts it,
|
|
272
296
|
// --rebuild guarantees it by rebuilding from source (relations through the dependency graph
|
|
273
297
|
// below; functions replace in place), and the default flags it for a decision.
|
|
298
|
+
const pin = pinMismatch(src, liveObj);
|
|
274
299
|
if (options.rebuild) {
|
|
275
300
|
if (isRelation(src.kind)) rebuild.set(src.identity, 'rebuilt from source (first contact, --rebuild)');
|
|
276
301
|
else fnReplace.set(src.identity, 'replaced from source (first contact, --rebuild)');
|
|
302
|
+
} else if (pin) {
|
|
303
|
+
// --baseline asserts "live IS the source, trust me". Here we can PROVE it is not, so
|
|
304
|
+
// recording the assertion would bless the difference permanently — nothing downstream
|
|
305
|
+
// ever compares these two again. Converge instead, on every path: an object whose pin
|
|
306
|
+
// is measurably wrong is not unverifiable, it is verifiably different.
|
|
307
|
+
fnReplace.set(src.identity, `search_path differs from live: ${pin}`);
|
|
277
308
|
} else if (options.baseline) {
|
|
278
309
|
baseline.push(src.identity);
|
|
279
310
|
} else {
|
|
@@ -341,6 +372,15 @@ export function planReconcile(
|
|
|
341
372
|
else fnReplace.set(src.identity, 'source changed');
|
|
342
373
|
continue;
|
|
343
374
|
}
|
|
375
|
+
// Self-consistent provenance is not proof. A --baseline taken before B2, or a mistaken
|
|
376
|
+
// --rebaseline, records the declared source hash beside the live def hash without ever
|
|
377
|
+
// comparing them — so a declared pin the database does not have reads as up to date on
|
|
378
|
+
// every run. The structural read is what breaks that loop; it self-heals in one apply.
|
|
379
|
+
const pin = pinMismatch(src, liveObj);
|
|
380
|
+
if (pin) {
|
|
381
|
+
fnReplace.set(src.identity, `search_path differs from live: ${pin}`);
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
344
384
|
// Up-to-date (src + live both match provenance). If provenance predates body_hash, record it
|
|
345
385
|
// once — a record-only backfill that ARMS the authz-only fast path for a future grant change.
|
|
346
386
|
// Converges: after this run the row has body_hash, so it skips cleanly next time.
|
|
@@ -430,8 +470,15 @@ export function planReconcile(
|
|
|
430
470
|
);
|
|
431
471
|
} else {
|
|
432
472
|
const target = parsed.target ?? (src.kind === 'function' ? `FUNCTION ${src.identity}` : src.identity);
|
|
433
|
-
|
|
473
|
+
// Functions carry ownership context; relations do not (relacl's NULL means owner-only
|
|
474
|
+
// and aclexplode(NULL) is empty, so a relation's grant set does not shift with its owner).
|
|
475
|
+
const owners = src.kind === 'function'
|
|
476
|
+
? { ...(src.owner !== undefined ? { declared: src.owner } : {}), ...(liveObj.owner !== undefined ? { live: liveObj.owner } : {}) }
|
|
477
|
+
: undefined;
|
|
478
|
+
const { statements, suppressed } = diffObjectGrants(target, parsed.grants, liveObj.grants, owners);
|
|
434
479
|
if (statements.length > 0) regrants.push({ identity: src.identity, statements });
|
|
480
|
+
// Never silent: a withheld revoke is reported every run until the owner is declared.
|
|
481
|
+
for (const note of suppressed) extraWarnings.push(note);
|
|
435
482
|
}
|
|
436
483
|
}
|
|
437
484
|
|
|
@@ -208,6 +208,110 @@ function parseArgs(args: string): ParsedArg[] | null {
|
|
|
208
208
|
return out;
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
+
/**
|
|
212
|
+
* The role-name shape `defineFunction` accepts (packages/model derived.ts OWNER_ROLE_NAME).
|
|
213
|
+
* The owner is interpolated into `SET ROLE` / `ALTER … OWNER TO`, so anything else is refused
|
|
214
|
+
* at declaration — which means rendering it here would emit source that throws on import.
|
|
215
|
+
*/
|
|
216
|
+
const RENDERABLE_ROLE = /^[a-z_][a-z0-9_]*$/;
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* An object that RUNS AS ITS OWNER, and the sentence that says so.
|
|
220
|
+
*
|
|
221
|
+
* Two shapes qualify, and they are the same hazard one object apart: a `SECURITY DEFINER`
|
|
222
|
+
* function, and a view with `security_invoker = false`. For both, an owner nobody chose is a
|
|
223
|
+
* live privilege surface, so the FIXME is loud and the warning reaches stderr.
|
|
224
|
+
*/
|
|
225
|
+
export interface ElevatedRights {
|
|
226
|
+
/** How the object declares it — `'SECURITY DEFINER'`, `'not securityInvoker'`. */
|
|
227
|
+
posture: string;
|
|
228
|
+
/** What runs as the owner — `'Every call'`, `'Every read through it'`. */
|
|
229
|
+
what: string;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export const SECURITY_DEFINER_RIGHTS: ElevatedRights = { posture: 'SECURITY DEFINER', what: 'Every call' };
|
|
233
|
+
export const DEFINER_VIEW_RIGHTS: ElevatedRights = { posture: 'not securityInvoker', what: 'Every read through it' };
|
|
234
|
+
export const MATVIEW_RIGHTS: ElevatedRights = { posture: 'a materialized view', what: 'Every REFRESH' };
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* What db:pull says about an object's OBSERVED owner: render it, or flag it.
|
|
238
|
+
*
|
|
239
|
+
* Ownership is a privilege boundary, so a pulled model that stays silent about it reproduces
|
|
240
|
+
* the wrong database. But rendering EVERY observed owner is worse than saying nothing: on a
|
|
241
|
+
* brownfield database most objects are owned by whoever ran the migration, and writing
|
|
242
|
+
* `owner: 'postgres'` into the models would BLESS that accident — converting an unreviewed
|
|
243
|
+
* superuser-owned SECURITY DEFINER function into fingerprinted, reproducible design.
|
|
244
|
+
*
|
|
245
|
+
* So the discriminator is the AMBIENT BUILD CREDENTIAL, two clauses and no more: the owner is
|
|
246
|
+
* a superuser, or it is the role this pull connected as. Neither → a deliberately chosen
|
|
247
|
+
* principal → render it. Either → a FIXME naming the observed owner, loud when the object runs
|
|
248
|
+
* as its owner. The defect stays visible without being ratified.
|
|
249
|
+
*
|
|
250
|
+
* This also settles the portability objection: the roles whose names legitimately differ
|
|
251
|
+
* between a dev machine, a deployed master and a normalized operator ARE the operator tier —
|
|
252
|
+
* exactly the tier this declines to render.
|
|
253
|
+
*
|
|
254
|
+
* A15 — ONE discriminator for functions AND relations. The rule is identical and the reason is
|
|
255
|
+
* identical; two copies of it is how the view half ends up subtly different from the function
|
|
256
|
+
* half, which is the defect this whole plan keeps finding.
|
|
257
|
+
*/
|
|
258
|
+
export function renderedOwner(
|
|
259
|
+
o: Pick<LiveObject, 'identity' | 'owner' | 'ownerIsSuperuser' | 'ownerIsConnectedRole'>,
|
|
260
|
+
elevated: ElevatedRights | null,
|
|
261
|
+
): { prop?: string; fixme?: string; warning?: string } {
|
|
262
|
+
const owner = o.owner;
|
|
263
|
+
// Not measured. A pre-owner catalog read carries no owner at all, and absent evidence is
|
|
264
|
+
// not evidence of a problem — say nothing, exactly as before.
|
|
265
|
+
if (owner === undefined) return {};
|
|
266
|
+
|
|
267
|
+
const superuser = o.ownerIsSuperuser;
|
|
268
|
+
const connected = o.ownerIsConnectedRole;
|
|
269
|
+
const ambient: string[] = [];
|
|
270
|
+
if (superuser === true) ambient.push('a superuser');
|
|
271
|
+
if (connected === true) ambient.push('the role db:pull connected as');
|
|
272
|
+
|
|
273
|
+
if (!ambient.length && superuser !== undefined && connected !== undefined) {
|
|
274
|
+
// A chosen principal. Render it — unless PostgreSQL's name is one defineFunction refuses,
|
|
275
|
+
// in which case rendering would emit source that throws on import.
|
|
276
|
+
if (!RENDERABLE_ROLE.test(owner)) {
|
|
277
|
+
const why = `${o.identity}: owner '${owner}' is not a bare lowercase role name, which is all the model's owner property accepts (it is interpolated into SET ROLE) — ownership NOT declared. Rename the role, or declare the owner by hand.`;
|
|
278
|
+
return { fixme: `// FIXME: ${why}`, warning: why };
|
|
279
|
+
}
|
|
280
|
+
return { prop: `owner: ${tsString(owner)},` };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (!ambient.length) {
|
|
284
|
+
// Owner known, discriminator not measured. Refuse to classify rather than guess.
|
|
285
|
+
return {
|
|
286
|
+
fixme: `// FIXME: ${o.identity} is owned by '${owner}', but this pull could not tell whether that is a chosen principal or the build credential — owner is NOT declared. Add owner: '${owner}' if that role is the intended owner.`,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const because = ambient.join(' and ');
|
|
291
|
+
// The runs-as-owner case is the one that reaches stderr as well as the file: it is a live
|
|
292
|
+
// privilege-escalation surface, not a style note. The plain case stays an inline FIXME —
|
|
293
|
+
// on a brownfield database it is the common shape, and echoing all of it would bury the rest.
|
|
294
|
+
return elevated
|
|
295
|
+
? {
|
|
296
|
+
fixme: `// FIXME SECURITY: ${o.identity} is ${elevated.posture} and owned by '${owner}' — ${because}. ${elevated.what} runs with that role's privileges. The observed owner is NOT declared, because declaring the build credential would make the accident reproducible; declare the intended owner with owner: '<role>'.`,
|
|
297
|
+
warning: `${o.identity}: ${elevated.posture}, owned by '${owner}' (${because}) — ${elevated.what.toLowerCase()} runs with that role's privileges. Ownership was NOT declared; declare the intended owner with owner: '<role>'.`,
|
|
298
|
+
}
|
|
299
|
+
: {
|
|
300
|
+
fixme: `// FIXME: ${o.identity} is owned by '${owner}' — ${because}, not a chosen principal, so ownership is NOT declared. Add owner: '<role>' if a specific role should own it.`,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* The A4 spelling, kept so existing callers and tests read unchanged. `renderedOwner` is the
|
|
306
|
+
* implementation; this is the function-shaped door onto it.
|
|
307
|
+
*/
|
|
308
|
+
export function renderedFunctionOwner(
|
|
309
|
+
o: Pick<LiveObject, 'identity' | 'owner' | 'ownerIsSuperuser' | 'ownerIsConnectedRole'>,
|
|
310
|
+
secdef: boolean,
|
|
311
|
+
): { prop?: string; fixme?: string; warning?: string } {
|
|
312
|
+
return renderedOwner(o, secdef ? SECURITY_DEFINER_RIGHTS : null);
|
|
313
|
+
}
|
|
314
|
+
|
|
211
315
|
/** One matview index attachment → the Brick E builder chain, or null (FIXME). */
|
|
212
316
|
function renderIndexBuilder(indexdef: string): string | null {
|
|
213
317
|
const parsed = parseIndexDefinition(indexdef);
|
|
@@ -469,6 +573,24 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
|
|
|
469
573
|
const props: string[] = [];
|
|
470
574
|
const hasPrivileges = Object.keys(privileges).length > 0;
|
|
471
575
|
if (o.kind === 'view') props.push(`securityInvoker: ${o.securityInvoker === true},`);
|
|
576
|
+
// A15 — the owner, through the SAME discriminator functions use. Elevated exactly when
|
|
577
|
+
// the relation runs as its owner: a view that is not securityInvoker, and a matview
|
|
578
|
+
// always (its rows are computed at REFRESH time with the owner's rights, no exceptions).
|
|
579
|
+
const relationOwnership = renderedOwner(
|
|
580
|
+
o,
|
|
581
|
+
o.kind === 'materialized view' ? MATVIEW_RIGHTS
|
|
582
|
+
: o.securityInvoker === false ? DEFINER_VIEW_RIGHTS
|
|
583
|
+
: null,
|
|
584
|
+
);
|
|
585
|
+
if (relationOwnership.prop) props.push(relationOwnership.prop);
|
|
586
|
+
if (relationOwnership.fixme) fixmes.push(relationOwnership.fixme);
|
|
587
|
+
// The FIXME lands in the file, at the declaration, which is where the operator acts on
|
|
588
|
+
// it. The stderr WARNING is deliberately NOT raised for relations: `SECURITY DEFINER` is
|
|
589
|
+
// opt-in and rare, so echoing those is signal, but `security_invoker = false` is
|
|
590
|
+
// PostgreSQL's DEFAULT for views — on any real database nearly every view and every
|
|
591
|
+
// matview qualifies, and promoting all of them would bury the function findings the
|
|
592
|
+
// channel exists for. Same judgement the function branch already makes for its plain
|
|
593
|
+
// case, applied where the base rate is inverted.
|
|
472
594
|
// `private: true` means "declared dark, no grants" — it contradicts a recorded
|
|
473
595
|
// grant, and the model refuses the pair. A relation whose only reach is recorded
|
|
474
596
|
// (PUBLIC SELECT, or a write grant) is not dark; it just has no CHOSEN audience.
|
|
@@ -516,6 +638,7 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
|
|
|
516
638
|
const roleGrants = Object.keys(grants).filter((r) => r.toUpperCase() !== 'PUBLIC').sort();
|
|
517
639
|
const publicExec = Object.keys(grants).some((r) => r.toUpperCase() === 'PUBLIC');
|
|
518
640
|
const args = f && (f.language === 'sql' || f.language === 'plpgsql') ? parseArgs(f.args) : null;
|
|
641
|
+
const ownership = renderedFunctionOwner(o, f?.secdef === true);
|
|
519
642
|
|
|
520
643
|
if (!f || args === null || (f.language !== 'sql' && f.language !== 'plpgsql')) {
|
|
521
644
|
warnings.push(`${o.identity}: signature beyond the v1 vocabulary (${f ? `language ${f.language}, args '${f.args}'` : 'no structured fields'}) — rendered as defineSql.`);
|
|
@@ -523,6 +646,14 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
|
|
|
523
646
|
// collapse to one provenance row here too — the declared name carries the signature
|
|
524
647
|
// (the drop is explicit, so the name is a label, and a unique one is what it needs).
|
|
525
648
|
const sqlName = declaredName(o) + (f?.identityArgs !== undefined ? `(${f.identityArgs})` : '');
|
|
649
|
+
// A defineSql object cannot carry an owner. Where the pull observed a CHOSEN owner that
|
|
650
|
+
// is real fidelity loss, so it is said out loud on both surfaces — never dropped. An
|
|
651
|
+
// ambient owner needs no note here: there is nothing to declare and nothing was lost.
|
|
652
|
+
if (ownership.prop) {
|
|
653
|
+
const why = `${o.identity}: owned by '${o.owner}', but defineSql cannot declare an owner — the build will create it as the connecting role. Move it into defineFunction's vocabulary, or set the owner by hand after the build.`;
|
|
654
|
+
warnings.push(why);
|
|
655
|
+
lines.push(`// FIXME: ${why}`);
|
|
656
|
+
}
|
|
526
657
|
lines.push(
|
|
527
658
|
`// FIXME: ${o.identity} — signature beyond defineFunction's v1 vocabulary; kept verbatim as defineSql.`,
|
|
528
659
|
`export const ${varName} = defineSql(${tsString(sqlName)}, {`,
|
|
@@ -548,11 +679,30 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
|
|
|
548
679
|
props.push(`returns: ${tsString(f.returns)},`);
|
|
549
680
|
props.push(`language: ${tsString(f.language)},`);
|
|
550
681
|
if (VOLATILITY[f.volatility]) props.push(`volatility: ${tsString(VOLATILITY[f.volatility])},`);
|
|
551
|
-
if (f.secdef)
|
|
552
|
-
|
|
553
|
-
|
|
682
|
+
if (f.secdef) props.push(`security: 'definer',`);
|
|
683
|
+
// THE PIN IS READ, NEVER INVENTED.
|
|
684
|
+
//
|
|
685
|
+
// This used to render `searchPath: ['pg_catalog']` for any definer function whose live
|
|
686
|
+
// proconfig was empty. A build from those models then applied the invented pin for real, so
|
|
687
|
+
// functions that had always resolved `public` stopped resolving it — sign-up and password
|
|
688
|
+
// change died on a database built from the consumer's own models, with every gate green.
|
|
689
|
+
// 34 of their 34 pinned-in-the-model functions were unpinned in the database.
|
|
690
|
+
//
|
|
691
|
+
// A live pin renders as the array. No live pin on a definer function renders the explicit
|
|
692
|
+
// `'unpinned'` sentinel — an ANSWER, not silence, so `defineFunction` still throws for the
|
|
693
|
+
// author who simply forgot. No live pin on an invoker function renders nothing, which
|
|
694
|
+
// already means exactly that.
|
|
695
|
+
if (f.searchPath) {
|
|
696
|
+
const path = f.searchPath.split(',').map((s) => s.trim().replace(/^"|"$/g, '')).filter(Boolean);
|
|
554
697
|
props.push(`searchPath: [${path.map(tsString).join(', ')}],`);
|
|
698
|
+
} else if (f.secdef) {
|
|
699
|
+
props.push(`searchPath: 'unpinned',`);
|
|
555
700
|
}
|
|
701
|
+
// Ownership sits beside security because it IS the other half of it: `security: 'definer'`
|
|
702
|
+
// says the function runs as its owner, and this says who that is.
|
|
703
|
+
if (ownership.prop) props.push(ownership.prop);
|
|
704
|
+
if (ownership.fixme) fixmes.push(ownership.fixme);
|
|
705
|
+
if (ownership.warning) warnings.push(ownership.warning);
|
|
556
706
|
if (roleGrants.length) {
|
|
557
707
|
props.push(`abilities: [${roleGrants.map((r) => `can('execute', { role: ${tsString(r)} })`).join(', ')}],`);
|
|
558
708
|
need('can');
|
|
@@ -60,6 +60,30 @@ export interface SourceObject {
|
|
|
60
60
|
file: string;
|
|
61
61
|
/** Position in the concatenated source — a valid dependency order by convention. */
|
|
62
62
|
seq: number;
|
|
63
|
+
/**
|
|
64
|
+
* kind 'function' only: the declared owning role.
|
|
65
|
+
*
|
|
66
|
+
* A SECURITY DEFINER function executes with its OWNER's rights, so this is a privilege
|
|
67
|
+
* boundary. The applier enacts it by running the CREATE under `SET ROLE <owner>` rather
|
|
68
|
+
* than following it with `ALTER … OWNER TO`: measured on PG16, once ownership has moved the
|
|
69
|
+
* builder gets "must be owner of function" on the next `CREATE OR REPLACE`, so the
|
|
70
|
+
* alter-after form works exactly once and breaks every re-run.
|
|
71
|
+
*/
|
|
72
|
+
owner?: string;
|
|
73
|
+
/**
|
|
74
|
+
* kind 'function' only: the declared `SET search_path`, or the sentinel `'unpinned'`.
|
|
75
|
+
*
|
|
76
|
+
* It is already inside `sql`, so this is NOT in the hash and changes no fingerprint. It rides
|
|
77
|
+
* structurally so the differ can compare the declared pin against the LIVE catalog directly
|
|
78
|
+
* rather than only against a recorded hash. Without that, `--baseline` records a declared pin
|
|
79
|
+
* the database does not have and every later check reports zero actions, forever: measured,
|
|
80
|
+
* and it is how a model came to claim `search_path=pg_catalog` about a database with none
|
|
81
|
+
* while every gate stayed green.
|
|
82
|
+
*
|
|
83
|
+
* `undefined` means NOT DECLARED — a parser-era db/sql object, or an invoker function with no
|
|
84
|
+
* pin. The comparison is skipped there; absent evidence is not evidence.
|
|
85
|
+
*/
|
|
86
|
+
searchPath?: string[] | 'unpinned';
|
|
63
87
|
/** kind 'sql' only: the declared PostgreSQL object kind (`'aggregate'`, …). */
|
|
64
88
|
objectKind?: string;
|
|
65
89
|
/** kind 'sql' only: the explicit DROP for kinds whose drop isn't derivable from the name. */
|
|
@@ -82,9 +106,15 @@ export const DECLARED_SOURCE_FILE = 'db/models (declared)';
|
|
|
82
106
|
/** The one content-hash formula. Unchanged since the db/sql era on purpose: provenance
|
|
83
107
|
* recorded from parser-era objects still matches the descriptor-compiled hash of the
|
|
84
108
|
* same SQL, so the db/sql → descriptor migration reconciles as a no-op. */
|
|
85
|
-
export function hashSourceContent(createSql: string, attachments: Attachment[]): string {
|
|
109
|
+
export function hashSourceContent(createSql: string, attachments: Attachment[], owner?: string): string {
|
|
86
110
|
const content = [normalizeSql(createSql), ...attachments.map((a) => normalizeSql(a.sql))].join('\n');
|
|
87
|
-
|
|
111
|
+
// Ownership is part of the declared state — a SECURITY DEFINER function that changes owner
|
|
112
|
+
// changes who it executes as, which is a rebuild, not an authz delta. It is folded in as a
|
|
113
|
+
// PREFIX only when declared, so every object without an owner hashes byte-identically to
|
|
114
|
+
// before this existed. That is deliberate: a consumer mid-adoption must not be forced into a
|
|
115
|
+
// second fingerprint re-mint in a week by a field they do not use.
|
|
116
|
+
const body = owner ? `-- owner: ${owner}\n${content}` : content;
|
|
117
|
+
return createHash('sha256').update(body).digest('hex');
|
|
88
118
|
}
|
|
89
119
|
|
|
90
120
|
/** A raw file the generic dir readers return (db/backfills, seed lanes). */
|
|
@@ -76,6 +76,9 @@ export const HELD_DROP_PREFIX = '-- DROP held back';
|
|
|
76
76
|
*/
|
|
77
77
|
function isRemoval(change: SchemaChange, replacedConstraints: Set<string>): boolean {
|
|
78
78
|
if (change.kind === 'dropColumn' || change.kind === 'dropTable' || change.kind === 'dropType' || change.kind === 'dropIndex') return true;
|
|
79
|
+
// DROP IDENTITY destroys the backing sequence and its counter — a removal, not a change.
|
|
80
|
+
// ADD/SET GENERATED are not: they create or retune the auto-value and lose nothing.
|
|
81
|
+
if (change.kind === 'setIdentity' && change.identity == null) return true;
|
|
79
82
|
if (change.kind === 'dropConstraint') return !replacedConstraints.has(`${change.table}::${change.name}`);
|
|
80
83
|
return false;
|
|
81
84
|
}
|
package/src/cli/model-render.ts
CHANGED
|
@@ -20,6 +20,7 @@ import type { DerivedRenderResult } from './derived-render.js';
|
|
|
20
20
|
import type { TableContract } from './authz-contract.js';
|
|
21
21
|
import { deriveAbilities, renderDerivedAbilities } from './authz-derive.js';
|
|
22
22
|
import { normalizeDefault, normalizeCheck } from './schema-diff.js';
|
|
23
|
+
import { generatedIndexName, generatedForeignKeyName } from './schema-compile.js';
|
|
23
24
|
|
|
24
25
|
/**
|
|
25
26
|
* Reverse a CHECK predicate back into the `.validate(z…)` that produced it — the inverse of
|
|
@@ -459,10 +460,17 @@ function renderField(table: TableSchema, col: ColumnSchema, known: Set<string>,
|
|
|
459
460
|
let expr = call ?? verbatimFieldCall(col.type);
|
|
460
461
|
let comment = call ? '' : ` // verbatim: no first-class field for '${col.type}' — carried as-is`;
|
|
461
462
|
|
|
463
|
+
// The auto-value the database actually holds. Rendered BEFORE .primaryKey()/.notNull() so a
|
|
464
|
+
// pulled surrogate key reads the way one is authored, and rendered at all because without it
|
|
465
|
+
// the rebuilt column has no auto-value and every INSERT that omits it fails NOT NULL.
|
|
466
|
+
if (col.identity) {
|
|
467
|
+
expr += col.identity === 'always' ? '.generatedAlwaysAsIdentity()' : '.generatedByDefaultAsIdentity()';
|
|
468
|
+
}
|
|
469
|
+
|
|
462
470
|
const isPk = table.primaryKey.includes(col.name);
|
|
463
471
|
if (isPk) expr += '.primaryKey()';
|
|
464
|
-
// A serial field is NOT NULL by construction, so the modifier would be redundant.
|
|
465
|
-
else if (col.notNull && !serial) expr += '.notNull()';
|
|
472
|
+
// A serial or identity field is NOT NULL by construction, so the modifier would be redundant.
|
|
473
|
+
else if (col.notNull && !serial && !col.identity) expr += '.notNull()';
|
|
466
474
|
|
|
467
475
|
if (table.uniques.some((u) => u.columns.length === 1 && u.columns[0] === col.name)) expr += '.unique()';
|
|
468
476
|
|
|
@@ -473,7 +481,11 @@ function renderField(table: TableSchema, col: ColumnSchema, known: Set<string>,
|
|
|
473
481
|
fk.onDelete ? `onDelete: ${tsLiteral(fk.onDelete)}` : null,
|
|
474
482
|
fk.onUpdate ? `onUpdate: ${tsLiteral(fk.onUpdate)}` : null,
|
|
475
483
|
].filter(Boolean);
|
|
476
|
-
|
|
484
|
+
// A8 — the FK's own name, said only when it is not the one the compiler would generate.
|
|
485
|
+
if (fk.name && fk.name !== generatedForeignKeyName(bareName(table.table), fk.columns, bareName(fk.refTable), fk.refColumns)) {
|
|
486
|
+
actions.push(`name: ${tsLiteral(fk.name)}`);
|
|
487
|
+
}
|
|
488
|
+
const opts = actions.length ? `, { ${actions.join(', ')} }` : '';
|
|
477
489
|
expr += `.references(() => ${modelVarName(fk.refTable)}${opts})`;
|
|
478
490
|
} else comment += ` // FK → ${fk.refTable} (not in the pulled set)`;
|
|
479
491
|
}
|
|
@@ -528,6 +540,15 @@ function renderConstraintsBlock(table: TableSchema, checks: CheckConstraint[], k
|
|
|
528
540
|
if (ix.unique) s += '.unique()';
|
|
529
541
|
if (ix.include?.length) s += `.include(${ix.include.map((c) => tsLiteral(toCamelCase(c))).join(', ')})`;
|
|
530
542
|
if (ix.where) s += `.where(sql\`${normalizeCheck(ix.where)}\`)`;
|
|
543
|
+
// A7 — keep the name the database actually has, but only say it when it is not the one the
|
|
544
|
+
// compiler would generate anyway. A brownfield schema arrives with its previous tooling's
|
|
545
|
+
// names (`index_contents_on_destination_id`), and rebuilding under ours renamed ~200 of one
|
|
546
|
+
// consumer's indexes: counts and definitions matched, so every gate stayed green while a
|
|
547
|
+
// planner regression test, runbooks and pg_stat_user_indexes history all broke. Declaring
|
|
548
|
+
// only the divergent ones keeps a greenfield pull clean and an adopted one faithful.
|
|
549
|
+
if (ix.name && ix.name !== generatedIndexName(bareName(table.table), ix.columns)) {
|
|
550
|
+
s += `.name(${tsLiteral(ix.name)})`;
|
|
551
|
+
}
|
|
531
552
|
items.push(s);
|
|
532
553
|
}
|
|
533
554
|
for (const fk of table.foreignKeys) {
|
|
@@ -542,6 +563,10 @@ function renderConstraintsBlock(table: TableSchema, checks: CheckConstraint[], k
|
|
|
542
563
|
fk.onDelete ? `onDelete: ${tsLiteral(fk.onDelete)}` : null,
|
|
543
564
|
fk.onUpdate ? `onUpdate: ${tsLiteral(fk.onUpdate)}` : null,
|
|
544
565
|
].filter(Boolean);
|
|
566
|
+
// A8 — the FK's own name, said only when it is not the one the compiler would generate.
|
|
567
|
+
if (fk.name && fk.name !== generatedForeignKeyName(bareName(table.table), fk.columns, bareName(fk.refTable), fk.refColumns)) {
|
|
568
|
+
actions.push(`name: ${tsLiteral(fk.name)}`);
|
|
569
|
+
}
|
|
545
570
|
const opts = actions.length ? `, { ${actions.join(', ')} }` : '';
|
|
546
571
|
items.push(`foreignKey(${cols}, () => ${modelVarName(fk.refTable)}, ${refCols}${opts})`);
|
|
547
572
|
}
|