@everystack/cli 0.4.53 → 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 +4 -3
- 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-build.ts +153 -0
- package/src/cli/commands/db-check.ts +20 -3
- package/src/cli/commands/db-fingerprint.ts +16 -2
- package/src/cli/commands/db-generate.ts +3 -3
- package/src/cli/commands/db-plan.ts +7 -1
- package/src/cli/commands/db-pull.ts +6 -1
- package/src/cli/commands/db-reconcile.ts +41 -2
- package/src/cli/commands/db-sync.ts +5 -2
- package/src/cli/commands/security.ts +24 -3
- package/src/cli/db-build.ts +36 -1
- package/src/cli/declared-derived.ts +11 -0
- 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/edge-plan.ts +7 -0
- package/src/cli/index.ts +5 -0
- package/src/cli/migration-generate.ts +24 -1
- package/src/cli/model-render.ts +28 -3
- package/src/cli/schema-compile.ts +75 -12
- package/src/cli/schema-diff.ts +133 -14
- package/src/cli/schema-fingerprint.ts +59 -6
- package/src/cli/schema-introspect.ts +53 -3
- package/src/cli/schema-source.ts +54 -8
- package/src/cli/security-audit.ts +131 -2
- package/src/cli/security-catalog.ts +18 -3
- package/src/cli/state-apply.ts +11 -2
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). */
|
package/src/cli/edge-plan.ts
CHANGED
|
@@ -123,6 +123,12 @@ export interface MintOptions {
|
|
|
123
123
|
* MINTED PLAN carries those revokes, which is where they would actually be applied.
|
|
124
124
|
*/
|
|
125
125
|
governedRoles?: string[];
|
|
126
|
+
/**
|
|
127
|
+
* Extensions the modules declare. A plan minted without them silently omits every
|
|
128
|
+
* `CREATE EXTENSION` the target lacks — so a stage evolving onto a model that newly uses
|
|
129
|
+
* an extension type fails at APPLY, with the plan having promised it would not.
|
|
130
|
+
*/
|
|
131
|
+
extensions?: string[];
|
|
126
132
|
}
|
|
127
133
|
|
|
128
134
|
/**
|
|
@@ -217,6 +223,7 @@ export function mintEdgePlan(
|
|
|
217
223
|
allowDrops: opts.allowDrops,
|
|
218
224
|
liveAuthz: contract,
|
|
219
225
|
governedRoles: opts.governedRoles,
|
|
226
|
+
extensions: opts.extensions,
|
|
220
227
|
});
|
|
221
228
|
const classified = classifyGeneratedStatements(statements);
|
|
222
229
|
if (classified.heldDrops.length > 0) {
|
package/src/cli/index.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { dbDiffCommand } from './commands/db-diff.js';
|
|
|
19
19
|
import { dbPlanCommand } from './commands/db-plan.js';
|
|
20
20
|
import { dbApplyCommand } from './commands/db-apply.js';
|
|
21
21
|
import { dbCheckCommand } from './commands/db-check.js';
|
|
22
|
+
import { dbBuildCommand } from './commands/db-build.js';
|
|
22
23
|
import { dbApproversCommand } from './commands/db-approvers.js';
|
|
23
24
|
import { dbBackfillCommand } from './commands/db-backfill.js';
|
|
24
25
|
import { dbExecCommand } from './commands/db-exec.js';
|
|
@@ -208,6 +209,9 @@ async function main() {
|
|
|
208
209
|
case 'db:check':
|
|
209
210
|
await dbCheckCommand(flags);
|
|
210
211
|
break;
|
|
212
|
+
case 'db:build':
|
|
213
|
+
await dbBuildCommand(flags);
|
|
214
|
+
break;
|
|
211
215
|
case 'db:approvers':
|
|
212
216
|
await dbApproversCommand(flags);
|
|
213
217
|
break;
|
|
@@ -368,6 +372,7 @@ Usage:
|
|
|
368
372
|
everystack db:diff --from-models <barrel> [--to-models db/models/index.ts] [--allow-drops] [--check] [--json] The state edge between two declared states, NO database: the SQL db:generate would produce, computed purely — CI plan previews (--check exits 1 on a non-empty edge) and computed rollbacks (swap the flags)
|
|
369
373
|
everystack db:plan [--stage <name> [--direct] | --database-url <url>] [--models <barrel>] [--allow-drops] [--out db.plan.json | --out -] Mint a verified edge against a target: asks the TARGET its fingerprint, diffs the models, writes ONE reviewable plan (edge + both endpoint fingerprints). Held drops refuse the mint (--allow-drops carries destruction explicitly). Read-only; plans are ephemeral, never committed. VENUES: --stage runs via the ops Lambda, which reads TWICE and refuses on disagreement — agreement there is a DETECTOR, not a verification. --stage --direct resolves the stage's operator connection from its IAM-gated ops Lambda, holds it in memory only, and reads ONCE over one session: the lane for a fingerprint you intend to trust, with the credential never on argv. --database-url is the local-dev venue (same read guarantee, but against a deployed stage it puts a privileged DSN on the command line)
|
|
370
374
|
everystack db:apply --plan <file.plan.json> [--database-url <url>] [--stage <name>] [--models <barrel>] [--confirm] [--snapshot-ref <ref>] [--force-descent <snapshot-ref> --confirm] Run a reviewed plan: verify the target is EXACTLY where the plan started (live fingerprint == plan.from, else refuse — the concurrency lock), verify the checkout DESCENDS from the commit declaring the target's state (the fast-forward rule, else refuse — "rebase first"), and for DESTRUCTIVE plans require --confirm always + an attested --snapshot-ref + the stage's approver set when declared (STS identity-verified). The STAGE lane (--stage without --direct) runs every catalog query in its own ops-Lambda invoke, so one read can be assembled from several containers: it reads the target TWICE and REFUSES when the two disagree (inconsistent containers), reports agreement as a NON-DETECTION (it cannot verify read consistency), and REFUSES a DESTRUCTIVE plan outright — destructive applies go over --database-url (direct) with the full ceremony. Every refusal that reaches the ops Lambda is recorded in schema_log. Apply as one transaction (plan_ref stamped), verify it landed exactly on plan.to; idempotent when already there
|
|
375
|
+
everystack db:build --database-url <url> [--models <barrel>] Build a database FROM the models and KEEP it — the fresh-database bootstrap a deleted migration folder is replaced BY. Runs the same compose db:check proves buildability with (extensions -> schemas -> contract roles -> declared functions -> state -> derived layer), against a real target, then reports MATCH. Venue is EXPLICIT: --database-url only, never the ambient environment, because this writes a whole schema. REFUSES a database that already holds objects — evolving an existing one is db:sync (dev) or db:plan -> db:apply (a stage). Contract roles are created NOLOGIN if absent, and roles are cluster-level
|
|
371
376
|
everystack db:check [--models <barrel>] [--schema-out <file.ts>] [--database-url <url>] [--json] The CI gate, per PR: the merged declared state must COMPOSE (models load, no duplicate tables, descriptors compile), every exposed RLS-enabled table must declare a read path (no force-RLS-with-no-read landmine that goes dark on the superuser drop), and generated artifacts must MATCH regeneration byte-for-byte; with a scratch PostgreSQL it builds the state from scratch on an ephemeral database (created + dropped) and requires fingerprint MATCH. Exit 1 on any failure; never touches a real target
|
|
372
377
|
everystack db:approvers --stage <name> [--set "cto,arn:..."] [--remove] Declare who can DESTROY: the stage's destructive-approver set (SSM parameter, admin-writable). Destructive db:apply runs are then identity-verified (STS) against it; --set '' disables destructive applies; --remove returns the stage to ceremony-only
|
|
373
378
|
everystack db:backfill [--database-url <url>] [--dir db/backfills] [--apply] [--mark-applied <file.sql>] [--json] One-shot data jobs in their own lane: plan shows applied (by CONTENT identity — renames/comment edits are no-ops) / pending (in order, unbounded-pass advisories) / blocked (a name that already ran in a different form — one-shot jobs are immutable). --apply runs each pending job as its own transaction, recorded in everystack.backfill_log (a failure rolls back alone, is recorded, stops the run); --mark-applied records without running. Never runs as a schema side effect; direct connection required
|
|
@@ -28,6 +28,9 @@ export interface GenerateOptions {
|
|
|
28
28
|
schema?: string;
|
|
29
29
|
/** Standalone sequences the modules declare (state layer — created before tables). */
|
|
30
30
|
sequences?: SequenceDescriptor[];
|
|
31
|
+
/** Postgres extensions the modules declare — emitted first, before anything using their
|
|
32
|
+
* types. Omitted = none declared; the live side's absent `extensions` means unknown. */
|
|
33
|
+
extensions?: string[];
|
|
31
34
|
/**
|
|
32
35
|
* Emit real `DROP COLUMN`/`DROP CONSTRAINT`/`DROP TABLE` for things present in the
|
|
33
36
|
* database but absent from the Models. Default `false` — those are held back as
|
|
@@ -73,6 +76,9 @@ export const HELD_DROP_PREFIX = '-- DROP held back';
|
|
|
73
76
|
*/
|
|
74
77
|
function isRemoval(change: SchemaChange, replacedConstraints: Set<string>): boolean {
|
|
75
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;
|
|
76
82
|
if (change.kind === 'dropConstraint') return !replacedConstraints.has(`${change.table}::${change.name}`);
|
|
77
83
|
return false;
|
|
78
84
|
}
|
|
@@ -140,6 +146,23 @@ export function generateMigrationSql(models: ModelDescriptor[], current: SchemaS
|
|
|
140
146
|
// databases already have their schemas (their own migrations made them), so this is
|
|
141
147
|
// invisible in the brownfield loop. IF NOT EXISTS keeps an empty-but-existing schema
|
|
142
148
|
// (no tables for the live side to reveal it by) from failing the create.
|
|
149
|
+
// 0-pre. EXTENSIONS, before anything that could use their types.
|
|
150
|
+
//
|
|
151
|
+
// The from-scratch compiler emitted these from day one; this DIFF path never did, so the
|
|
152
|
+
// brownfield loop (db:sync, db:generate --apply) and the whole db:plan/db:apply stage lane
|
|
153
|
+
// were silently missing them. Two distinct failures, both measured on a consumer's
|
|
154
|
+
// checkout: a fresh build dies on `type "hstore" does not exist`, and a DEPLOYED stage
|
|
155
|
+
// evolving onto a model that newly uses an extension type fails the same way at apply.
|
|
156
|
+
//
|
|
157
|
+
// `current.extensions` is ABSENT when the caller never ran the extensions query — absent
|
|
158
|
+
// means unknown, not empty, so everything declared is emitted. `IF NOT EXISTS` makes that
|
|
159
|
+
// safe: the cost of not knowing is a no-op statement, never a failure.
|
|
160
|
+
const liveExtensions = current.extensions === undefined ? null : new Set(current.extensions);
|
|
161
|
+
const extensionPhase = [...new Set(opts.extensions ?? [])]
|
|
162
|
+
.filter((e) => liveExtensions === null || !liveExtensions.has(e))
|
|
163
|
+
.sort()
|
|
164
|
+
.map((e) => `CREATE EXTENSION IF NOT EXISTS "${e}"`);
|
|
165
|
+
|
|
143
166
|
const liveSchemas = new Set(current.tables.map((t) => t.table.split('.')[0]));
|
|
144
167
|
const schemaPhase = [...new Set(desired.tables.map((t) => t.table.split('.')[0]))]
|
|
145
168
|
.filter((s) => s !== 'public' && !liveSchemas.has(s))
|
|
@@ -269,7 +292,7 @@ export function generateMigrationSql(models: ModelDescriptor[], current: SchemaS
|
|
|
269
292
|
})
|
|
270
293
|
: [];
|
|
271
294
|
|
|
272
|
-
return [...schemaPhase, ...usagePhase, ...dataPhase, ...authzPhase];
|
|
295
|
+
return [...extensionPhase, ...schemaPhase, ...usagePhase, ...dataPhase, ...authzPhase];
|
|
273
296
|
}
|
|
274
297
|
|
|
275
298
|
/** The marker drizzle migration files put between statements. */
|
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
|
}
|