@everystack/cli 0.4.55 → 0.4.57
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-derive.ts +46 -5
- package/src/cli/authz-reconcile.ts +146 -1
- package/src/cli/authz-render.ts +26 -4
- package/src/cli/commands/db-apply.ts +11 -0
- package/src/cli/commands/db-check.ts +4 -1
- package/src/cli/commands/db-fingerprint.ts +16 -2
- package/src/cli/commands/db-plan.ts +20 -3
- 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/git-descent.ts +91 -19
- package/src/cli/migration-generate.ts +3 -0
- package/src/cli/model-api.ts +24 -0
- package/src/cli/model-render.ts +100 -11
- 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
|
@@ -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/git-descent.ts
CHANGED
|
@@ -49,6 +49,14 @@ export interface DescentOptions {
|
|
|
49
49
|
/** The Postgres schema the Models default to. Default: `public`. */
|
|
50
50
|
schema?: string;
|
|
51
51
|
loader?: ModelsLoader;
|
|
52
|
+
/**
|
|
53
|
+
* How a tree is extracted. Default: {@link materializeTree}.
|
|
54
|
+
*
|
|
55
|
+
* Injected for the same reason `loader` is: an in-module call goes through the local
|
|
56
|
+
* binding, so it cannot be spied on, and extraction failure is a condition that MUST be
|
|
57
|
+
* covered by a test — it is the one that used to be misreported as database drift.
|
|
58
|
+
*/
|
|
59
|
+
materialize?: (tree: string, dest: string, cwd: string) => void;
|
|
52
60
|
/**
|
|
53
61
|
* Where historical trees materialize. Default: a fresh temp dir under the
|
|
54
62
|
* repo's node_modules — bare imports (`@everystack/model`) must resolve
|
|
@@ -69,7 +77,15 @@ export type DescentVerdict =
|
|
|
69
77
|
/** The declaring commits exist but none is an ancestor of HEAD — rebase first. */
|
|
70
78
|
| { status: 'diverged'; commits: string[]; tree: string; reason: string }
|
|
71
79
|
/** No committed models state declares the live fingerprint — operator decision. */
|
|
72
|
-
| { status: 'drift'; scannedTrees: number; compileFailures: string[]; reason: string }
|
|
80
|
+
| { status: 'drift'; scannedTrees: number; compileFailures: string[]; reason: string }
|
|
81
|
+
/**
|
|
82
|
+
* One or more trees could not be EVALUATED at all — extraction or IO failed, so "no tree
|
|
83
|
+
* declares this state" is not a conclusion we are entitled to draw. Distinct from `drift`
|
|
84
|
+
* because the operator's next move is different: retry, not `db:pull`.
|
|
85
|
+
*
|
|
86
|
+
* `unevaluated` names the trees and why each failed.
|
|
87
|
+
*/
|
|
88
|
+
| { status: 'indeterminate'; scannedTrees: number; unevaluated: string[]; reason: string };
|
|
73
89
|
|
|
74
90
|
export interface ModelTreeCandidate {
|
|
75
91
|
/** The models directory's tree oid — the identity of a declared state. */
|
|
@@ -134,14 +150,33 @@ export function enumerateModelTrees(modelsDir: string, cwd: string): ModelTreeCa
|
|
|
134
150
|
return order.map((tree) => ({ tree, commits: byTree.get(tree)! }));
|
|
135
151
|
}
|
|
136
152
|
|
|
137
|
-
/**
|
|
153
|
+
/**
|
|
154
|
+
* Extract a tree into `dest`, via a temporary archive FILE — never a pipe.
|
|
155
|
+
*
|
|
156
|
+
* This used to buffer `git archive` into memory (512MB `maxBuffer`) and hand that buffer to
|
|
157
|
+
* `tar` as `input`. `execFileSync` writes the whole buffer to the child's stdin, so if the
|
|
158
|
+
* child is not draining it the write fails with **EPIPE** — and under a loaded machine it
|
|
159
|
+
* does. Measured 2026-08-11 on a full-repo run: `spawnSync tar EPIPE`, recorded against a
|
|
160
|
+
* historical tree, twice in four runs.
|
|
161
|
+
*
|
|
162
|
+
* It failed badly, not loudly: `verifyDescent` recorded it as a COMPILE failure and returned
|
|
163
|
+
* `drift`, telling the operator their database had been hand-edited. See the `indeterminate`
|
|
164
|
+
* verdict for that half of the fix; this is the half that stops the failure happening.
|
|
165
|
+
*
|
|
166
|
+
* `git archive -o` writes the archive itself and `tar -xf <file>` reads it — two independent
|
|
167
|
+
* processes, no shared pipe, no backpressure to lose, and no multi-hundred-MB buffer through
|
|
168
|
+
* the parent. Retrying the pipe harder would only have made it rarer.
|
|
169
|
+
*/
|
|
138
170
|
export function materializeTree(tree: string, dest: string, cwd: string): void {
|
|
139
171
|
fs.mkdirSync(dest, { recursive: true });
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
172
|
+
// Sibling of `dest`, not inside it — a file under `dest` would be extracted over.
|
|
173
|
+
const archivePath = `${dest}.tar`;
|
|
174
|
+
try {
|
|
175
|
+
execFileSync('git', ['archive', '--format=tar', '-o', archivePath, tree], { cwd });
|
|
176
|
+
execFileSync('tar', ['-xf', archivePath, '-C', dest]);
|
|
177
|
+
} finally {
|
|
178
|
+
fs.rmSync(archivePath, { force: true });
|
|
179
|
+
}
|
|
145
180
|
}
|
|
146
181
|
|
|
147
182
|
/**
|
|
@@ -181,39 +216,60 @@ export async function verifyDescent(
|
|
|
181
216
|
?? fs.mkdtempSync(path.join(fs.existsSync(nodeModules) ? nodeModules : os.tmpdir(), '.everystack-descent-'));
|
|
182
217
|
const ownsRoot = !opts.materializeRoot;
|
|
183
218
|
const loader = opts.loader ?? defaultLoader;
|
|
219
|
+
const materialize = opts.materialize ?? materializeTree;
|
|
184
220
|
|
|
185
221
|
const compileFailures: string[] = [];
|
|
222
|
+
const unevaluated: string[] = [];
|
|
186
223
|
const declaringButDiverged: ModelTreeCandidate[] = [];
|
|
187
224
|
try {
|
|
188
225
|
for (const candidate of candidates) {
|
|
189
226
|
const dest = path.join(materializeRoot, candidate.tree);
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
//
|
|
193
|
-
// a transient materialize/import failure (fd pressure, module-loader contention under
|
|
194
|
-
// a parallel test run or a busy CI box) is not — so a failed candidate gets exactly
|
|
195
|
-
// one retry, from a clean materialization, before it is recorded as a compile failure.
|
|
227
|
+
const where = `${short(candidate.tree)} (at ${short(candidate.commits[0])})`;
|
|
228
|
+
|
|
229
|
+
// TWO KINDS OF FAILURE, and conflating them is what made this path lie.
|
|
196
230
|
//
|
|
231
|
+
// EXTRACTION failing says nothing about the tree — we never saw it. It was recorded as a
|
|
232
|
+
// "compile failure" and the verdict fell through to `drift`, which tells an operator
|
|
233
|
+
// their database was hand-edited. Observed for real: `spawnSync tar EPIPE` under a loaded
|
|
234
|
+
// machine. Those trees now go to `unevaluated` and produce `indeterminate`.
|
|
235
|
+
//
|
|
236
|
+
// COMPILING failing IS a property of the tree — a models file that no longer builds
|
|
237
|
+
// against today's @everystack/model is genuinely unusable, deterministically, and
|
|
238
|
+
// skipping it is correct. Those keep `compileFailures` and still allow `drift`.
|
|
239
|
+
//
|
|
240
|
+
// Each gets ONE retry from a clean materialization first, because either can also be
|
|
241
|
+
// transient under load.
|
|
242
|
+
try {
|
|
243
|
+
if (!fs.existsSync(dest)) materialize(candidate.tree, dest, repoRoot);
|
|
244
|
+
} catch {
|
|
245
|
+
try {
|
|
246
|
+
fs.rmSync(dest, { recursive: true, force: true });
|
|
247
|
+
materialize(candidate.tree, dest, repoRoot);
|
|
248
|
+
} catch (err: any) {
|
|
249
|
+
unevaluated.push(`${where}: could not extract — ${err.message}`);
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
197
254
|
// The declares-test is GOVERNED on both sides, per candidate: the commit's
|
|
198
255
|
// models define the governed set, and the live hash is filtered through the
|
|
199
256
|
// SAME set as the prediction — comparing a governed prediction against the
|
|
200
257
|
// raw live hash can never match on a brownfield target with foreign roles.
|
|
201
|
-
const
|
|
202
|
-
if (!fs.existsSync(dest)) materializeTree(candidate.tree, dest, repoRoot);
|
|
258
|
+
const evaluate = async (): Promise<boolean> => {
|
|
203
259
|
const models = await loader(path.join(dest, barrel));
|
|
204
260
|
const governedRoles = governedRolesForModels(models);
|
|
205
261
|
const predicted = predictLiveFingerprint(models, live.snapshot, live.contract, { schema: opts.schema, governedRoles });
|
|
206
262
|
return predicted === governedLiveFingerprint(live.snapshot, live.contract, governedRoles);
|
|
207
263
|
};
|
|
264
|
+
let declares: boolean;
|
|
208
265
|
try {
|
|
209
266
|
try {
|
|
210
|
-
declares = await
|
|
267
|
+
declares = await evaluate();
|
|
211
268
|
} catch {
|
|
212
|
-
|
|
213
|
-
declares = await attempt();
|
|
269
|
+
declares = await evaluate();
|
|
214
270
|
}
|
|
215
271
|
} catch (err: any) {
|
|
216
|
-
compileFailures.push(`${
|
|
272
|
+
compileFailures.push(`${where}: ${err.message}`);
|
|
217
273
|
continue;
|
|
218
274
|
}
|
|
219
275
|
if (!declares) continue;
|
|
@@ -254,6 +310,22 @@ export async function verifyDescent(
|
|
|
254
310
|
};
|
|
255
311
|
}
|
|
256
312
|
|
|
313
|
+
// Nothing matched — but if a tree was never evaluated, "nothing matched" is a claim we
|
|
314
|
+
// cannot make. The unevaluated one could be the declaring tree. Reported BEFORE drift
|
|
315
|
+
// because drift accuses the database, and this condition is about our own run.
|
|
316
|
+
if (unevaluated.length > 0) {
|
|
317
|
+
return {
|
|
318
|
+
status: 'indeterminate',
|
|
319
|
+
scannedTrees: candidates.length,
|
|
320
|
+
unevaluated,
|
|
321
|
+
reason:
|
|
322
|
+
`${unevaluated.length} of ${candidates.length} historical tree(s) could not be extracted, ` +
|
|
323
|
+
'so the search was not exhaustive and one of them may be the commit that declares this state. ' +
|
|
324
|
+
'This is a fault in THIS run, not evidence about the database — retry, and if it persists ' +
|
|
325
|
+
`check disk space and open-file limits. Details: ${unevaluated.join('; ')}`,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
257
329
|
const failureNote = compileFailures.length > 0
|
|
258
330
|
? ` (${compileFailures.length} historical tree(s) no longer compile and were skipped)`
|
|
259
331
|
: '';
|
|
@@ -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-api.ts
CHANGED
|
@@ -35,6 +35,30 @@ export * from './authz-owner-probe.js';
|
|
|
35
35
|
// --- the SECDEF function catalog the contract introspection needs -----------
|
|
36
36
|
export { FUNCTIONS_SQL, catalogFunctionToDescriptor } from './security-catalog.js';
|
|
37
37
|
|
|
38
|
+
// --- how you HAND a database to the VERIFY calls ---------------------------
|
|
39
|
+
// `introspectSchema` and `introspectContract` take a `SessionRunner`, not a `QueryRunner`:
|
|
40
|
+
// their queries must describe ONE moment, so they run as one transaction on one connection.
|
|
41
|
+
// This barrel exported neither the type nor a way to build one, which left a consumer able
|
|
42
|
+
// to CALL those functions with nothing legitimate to pass them. That gap is not academic —
|
|
43
|
+
// the reference app's own dogfood suite kept passing a `QueryRunner` long after the
|
|
44
|
+
// signature moved, and Postgres reported it as `syntax error at or near ","` (the array of
|
|
45
|
+
// statements stringified) rather than as the type error it was.
|
|
46
|
+
//
|
|
47
|
+
// `sessionRunnerOver(sql)` wraps a postgres.js client whose pool is a SINGLE connection.
|
|
48
|
+
// `INTROSPECTION_SESSION` is the options both introspections expect — read only, repeatable
|
|
49
|
+
// read, canonical `search_path`. Pass it through; do not re-derive it.
|
|
50
|
+
export { sessionRunnerOver } from './db-source.js';
|
|
51
|
+
export {
|
|
52
|
+
INTROSPECTION_SESSION,
|
|
53
|
+
borrowedSessionRunner,
|
|
54
|
+
isSessionError,
|
|
55
|
+
rowsOrEmpty,
|
|
56
|
+
type SessionRunner,
|
|
57
|
+
type SessionStatement,
|
|
58
|
+
type SessionOptions,
|
|
59
|
+
type SessionResult,
|
|
60
|
+
} from './session.js';
|
|
61
|
+
|
|
38
62
|
// --- databases FROM the declared state: ephemeral test DBs + the builder ----
|
|
39
63
|
// `createEphemeralDatabase(adminUrl, models, { sources })` is the packaged
|
|
40
64
|
// create-sync-drop shape for consumer test suites: a fresh database at the
|
package/src/cli/model-render.ts
CHANGED
|
@@ -20,6 +20,11 @@ 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';
|
|
24
|
+
// The soft-delete RULE itself — one boolean combination, shared with `defineModel`'s refusal.
|
|
25
|
+
// Only the FACT EXTRACTION is duplicated here (this renderer holds text, not descriptors);
|
|
26
|
+
// the rule is not, so the two surfaces cannot drift on when a declaration is required.
|
|
27
|
+
import { needsSoftDeleteDeclaration, privilegesGrantDelete } from '@everystack/model';
|
|
23
28
|
|
|
24
29
|
/**
|
|
25
30
|
* Reverse a CHECK predicate back into the `.validate(z…)` that produced it — the inverse of
|
|
@@ -126,13 +131,30 @@ export function isPublicReadAbility(expr: string): boolean {
|
|
|
126
131
|
return !role || role[1] === 'anon';
|
|
127
132
|
}
|
|
128
133
|
|
|
134
|
+
/**
|
|
135
|
+
* A rendered ability that hands out DELETE — the TEXT form of `isDeleteAbility` from
|
|
136
|
+
* `@everystack/model`.
|
|
137
|
+
*
|
|
138
|
+
* The second fact the soft-delete rule turns on, and the one the old guard missed entirely.
|
|
139
|
+
* `manage` counts: it is "all actions" and compiles to the admin-bypass ALL policy, so it
|
|
140
|
+
* destroys rows exactly as `can('delete')` does.
|
|
141
|
+
*
|
|
142
|
+
* Same two-representations situation as its sibling above, for the same reason — this renderer
|
|
143
|
+
* only ever holds rendered source. The BOOLEAN RULE both facts feed is not duplicated here: it
|
|
144
|
+
* lives once in `needsSoftDeleteDeclaration`. Only the fact extraction is representation-
|
|
145
|
+
* specific, and the pin test drives both extractors over one ability matrix.
|
|
146
|
+
*/
|
|
147
|
+
export function isDeleteAbility(expr: string): boolean {
|
|
148
|
+
return /^can\('(delete|manage)'/.test(expr.trim());
|
|
149
|
+
}
|
|
150
|
+
|
|
129
151
|
/**
|
|
130
152
|
* The scaffold stanza for one model, plus whether it declares a public read — the renderer
|
|
131
153
|
* needs the second fact to decide the `softDelete` line, and it must come from the STRUCTURED
|
|
132
154
|
* abilities, not a regex over the joined text (a live predicate can span lines and carry its
|
|
133
155
|
* own braces). An unknown preset throws — grants are authored, never guessed.
|
|
134
156
|
*/
|
|
135
|
-
function abilitiesStanza(mode: string, table?: TableSchema, liveAuthz?: Map<string, TableContract>, governRoles?: ReadonlySet<string>): { text: string; publicRead: boolean } {
|
|
157
|
+
function abilitiesStanza(mode: string, table?: TableSchema, liveAuthz?: Map<string, TableContract>, governRoles?: ReadonlySet<string>): { text: string; publicRead: boolean; grantsDelete: boolean } {
|
|
136
158
|
if (mode === 'live') {
|
|
137
159
|
const contract = table && liveAuthz?.get(table.table);
|
|
138
160
|
if (!contract) {
|
|
@@ -143,6 +165,7 @@ function abilitiesStanza(mode: string, table?: TableSchema, liveAuthz?: Map<stri
|
|
|
143
165
|
' // private: true,',
|
|
144
166
|
].join('\n'),
|
|
145
167
|
publicRead: false,
|
|
168
|
+
grantsDelete: false,
|
|
146
169
|
};
|
|
147
170
|
}
|
|
148
171
|
const derived = deriveAbilities(contract, { governRoles });
|
|
@@ -158,7 +181,13 @@ function abilitiesStanza(mode: string, table?: TableSchema, liveAuthz?: Map<stri
|
|
|
158
181
|
if (!derived.abilities.length && !contract.rls.enabled) {
|
|
159
182
|
lines.push(` rls: false, // live reality: row security is OFF — authorization here is grants-only.`);
|
|
160
183
|
}
|
|
161
|
-
return {
|
|
184
|
+
return {
|
|
185
|
+
text: lines.join('\n'),
|
|
186
|
+
publicRead: derived.abilities.some(isPublicReadAbility),
|
|
187
|
+
// Both spellings. `privileges` is where pull puts a live DELETE grant no policy covers,
|
|
188
|
+
// which is precisely the shape that can hard-delete on an rls: false table.
|
|
189
|
+
grantsDelete: derived.abilities.some(isDeleteAbility) || privilegesGrantDelete(derived.privileges),
|
|
190
|
+
};
|
|
162
191
|
}
|
|
163
192
|
if (mode === 'commented') {
|
|
164
193
|
return {
|
|
@@ -170,13 +199,18 @@ function abilitiesStanza(mode: string, table?: TableSchema, liveAuthz?: Map<stri
|
|
|
170
199
|
].join('\n'),
|
|
171
200
|
// Nothing is stamped uncommented, so the model declares no read at all yet.
|
|
172
201
|
publicRead: false,
|
|
202
|
+
grantsDelete: false,
|
|
173
203
|
};
|
|
174
204
|
}
|
|
175
205
|
const preset = ABILITY_PRESETS[mode];
|
|
176
206
|
if (!preset) {
|
|
177
207
|
throw new Error(`Unknown --abilities preset '${mode}' — known: ${Object.keys(ABILITY_PRESETS).join(', ')} (or omit the flag for the commented scaffold).`);
|
|
178
208
|
}
|
|
179
|
-
return {
|
|
209
|
+
return {
|
|
210
|
+
text: ` abilities: [${preset.join(', ')}],`,
|
|
211
|
+
publicRead: preset.some(isPublicReadAbility),
|
|
212
|
+
grantsDelete: preset.some(isDeleteAbility),
|
|
213
|
+
};
|
|
180
214
|
}
|
|
181
215
|
|
|
182
216
|
/**
|
|
@@ -209,10 +243,41 @@ function writtenByStanza(table: TableSchema, liveAuthz?: Map<string, TableContra
|
|
|
209
243
|
+ `'app' (the default) would FORCE it — a change to who bypasses row security, not an adoption.\n`;
|
|
210
244
|
}
|
|
211
245
|
|
|
212
|
-
function softDeleteStanza(table: TableSchema, publicRead: boolean): string {
|
|
213
|
-
const
|
|
214
|
-
if (!
|
|
215
|
-
|
|
246
|
+
function softDeleteStanza(table: TableSchema, publicRead: boolean, grantsDelete: boolean): string {
|
|
247
|
+
const hasDeletedAtField = table.columns.some((c) => c.name === 'deleted_at');
|
|
248
|
+
if (!needsSoftDeleteDeclaration({ hasDeletedAtField, hasPublicRead: publicRead, grantsDelete })) return '';
|
|
249
|
+
|
|
250
|
+
// A PUBLIC read forces `false`, and the constraint is the fingerprint, not a preference:
|
|
251
|
+
// `true` AND-s `deleted_at IS NULL` into the anon policy, so rendering it would transcribe a
|
|
252
|
+
// predicate no live policy carries and the pulled checkout would MISMATCH the database it
|
|
253
|
+
// was pulled from. Live reality is what a pull is for.
|
|
254
|
+
if (publicRead) {
|
|
255
|
+
return ` softDelete: false, // live reality: no policy filters deleted_at, so false is what this database does.\n`
|
|
256
|
+
+ ` // true would AND deleted_at IS NULL into the public read policy — a predicate no live\n`
|
|
257
|
+
+ ` // policy carries, so it would MISMATCH the database this was pulled from. It would also\n`
|
|
258
|
+
+ ` // soften DELETE and hide marked rows from the data API. Change it deliberately, with a plan.\n`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// No public read: the flag adds no POLICY (the compiler reads its guard only on the
|
|
262
|
+
// public-read branch), so either value fingerprints identically and introspection cannot
|
|
263
|
+
// recover which the app intended — a hard DELETE and a soft one leave the same schema.
|
|
264
|
+
//
|
|
265
|
+
// Fingerprint-neutral is NOT behaviour-neutral, and the comment must not imply it is: the
|
|
266
|
+
// generic data API also FILTERS soft-deleted rows out of reads. On a table where `deleted_at`
|
|
267
|
+
// is carried for audit rather than for deletion, adopting with `true` quietly drops rows from
|
|
268
|
+
// every listing — which is the same class of harm as the bug this rule exists to prevent,
|
|
269
|
+
// pointed the other way. So the comment states the full blast radius and asks the one
|
|
270
|
+
// question introspection cannot answer.
|
|
271
|
+
//
|
|
272
|
+
// `true` and not `false` because the worst cases are not symmetric: `true` hides rows
|
|
273
|
+
// recoverably (flip the flag; they were never destroyed, and they are still there over raw
|
|
274
|
+
// SQL), while `false` destroys data on the next DELETE and re-exposes rows the old
|
|
275
|
+
// application already treated as deleted. Irreversibility loses.
|
|
276
|
+
return ` softDelete: true, // ADOPTION DECISION — does deleted_at here mean DELETED, or merely audited?\n`
|
|
277
|
+
+ ` // true: DELETE marks deleted_at instead of destroying the row, AND the data API\n`
|
|
278
|
+
+ ` // hides marked rows from reads (every role, unless ?deleted=include|only)\n`
|
|
279
|
+
+ ` // and from updates. No RLS policy changes either way — there is no public read.\n`
|
|
280
|
+
+ ` // false: DELETE is permanent, marked rows stay visible. Set it if deleted_at is audit-only.\n`;
|
|
216
281
|
}
|
|
217
282
|
|
|
218
283
|
// The inverse type map lives with the vocabulary it inverts (`@everystack/model`),
|
|
@@ -459,10 +524,17 @@ function renderField(table: TableSchema, col: ColumnSchema, known: Set<string>,
|
|
|
459
524
|
let expr = call ?? verbatimFieldCall(col.type);
|
|
460
525
|
let comment = call ? '' : ` // verbatim: no first-class field for '${col.type}' — carried as-is`;
|
|
461
526
|
|
|
527
|
+
// The auto-value the database actually holds. Rendered BEFORE .primaryKey()/.notNull() so a
|
|
528
|
+
// pulled surrogate key reads the way one is authored, and rendered at all because without it
|
|
529
|
+
// the rebuilt column has no auto-value and every INSERT that omits it fails NOT NULL.
|
|
530
|
+
if (col.identity) {
|
|
531
|
+
expr += col.identity === 'always' ? '.generatedAlwaysAsIdentity()' : '.generatedByDefaultAsIdentity()';
|
|
532
|
+
}
|
|
533
|
+
|
|
462
534
|
const isPk = table.primaryKey.includes(col.name);
|
|
463
535
|
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()';
|
|
536
|
+
// A serial or identity field is NOT NULL by construction, so the modifier would be redundant.
|
|
537
|
+
else if (col.notNull && !serial && !col.identity) expr += '.notNull()';
|
|
466
538
|
|
|
467
539
|
if (table.uniques.some((u) => u.columns.length === 1 && u.columns[0] === col.name)) expr += '.unique()';
|
|
468
540
|
|
|
@@ -473,7 +545,11 @@ function renderField(table: TableSchema, col: ColumnSchema, known: Set<string>,
|
|
|
473
545
|
fk.onDelete ? `onDelete: ${tsLiteral(fk.onDelete)}` : null,
|
|
474
546
|
fk.onUpdate ? `onUpdate: ${tsLiteral(fk.onUpdate)}` : null,
|
|
475
547
|
].filter(Boolean);
|
|
476
|
-
|
|
548
|
+
// A8 — the FK's own name, said only when it is not the one the compiler would generate.
|
|
549
|
+
if (fk.name && fk.name !== generatedForeignKeyName(bareName(table.table), fk.columns, bareName(fk.refTable), fk.refColumns)) {
|
|
550
|
+
actions.push(`name: ${tsLiteral(fk.name)}`);
|
|
551
|
+
}
|
|
552
|
+
const opts = actions.length ? `, { ${actions.join(', ')} }` : '';
|
|
477
553
|
expr += `.references(() => ${modelVarName(fk.refTable)}${opts})`;
|
|
478
554
|
} else comment += ` // FK → ${fk.refTable} (not in the pulled set)`;
|
|
479
555
|
}
|
|
@@ -528,6 +604,15 @@ function renderConstraintsBlock(table: TableSchema, checks: CheckConstraint[], k
|
|
|
528
604
|
if (ix.unique) s += '.unique()';
|
|
529
605
|
if (ix.include?.length) s += `.include(${ix.include.map((c) => tsLiteral(toCamelCase(c))).join(', ')})`;
|
|
530
606
|
if (ix.where) s += `.where(sql\`${normalizeCheck(ix.where)}\`)`;
|
|
607
|
+
// A7 — keep the name the database actually has, but only say it when it is not the one the
|
|
608
|
+
// compiler would generate anyway. A brownfield schema arrives with its previous tooling's
|
|
609
|
+
// names (`index_contents_on_destination_id`), and rebuilding under ours renamed ~200 of one
|
|
610
|
+
// consumer's indexes: counts and definitions matched, so every gate stayed green while a
|
|
611
|
+
// planner regression test, runbooks and pg_stat_user_indexes history all broke. Declaring
|
|
612
|
+
// only the divergent ones keeps a greenfield pull clean and an adopted one faithful.
|
|
613
|
+
if (ix.name && ix.name !== generatedIndexName(bareName(table.table), ix.columns)) {
|
|
614
|
+
s += `.name(${tsLiteral(ix.name)})`;
|
|
615
|
+
}
|
|
531
616
|
items.push(s);
|
|
532
617
|
}
|
|
533
618
|
for (const fk of table.foreignKeys) {
|
|
@@ -542,6 +627,10 @@ function renderConstraintsBlock(table: TableSchema, checks: CheckConstraint[], k
|
|
|
542
627
|
fk.onDelete ? `onDelete: ${tsLiteral(fk.onDelete)}` : null,
|
|
543
628
|
fk.onUpdate ? `onUpdate: ${tsLiteral(fk.onUpdate)}` : null,
|
|
544
629
|
].filter(Boolean);
|
|
630
|
+
// A8 — the FK's own name, said only when it is not the one the compiler would generate.
|
|
631
|
+
if (fk.name && fk.name !== generatedForeignKeyName(bareName(table.table), fk.columns, bareName(fk.refTable), fk.refColumns)) {
|
|
632
|
+
actions.push(`name: ${tsLiteral(fk.name)}`);
|
|
633
|
+
}
|
|
545
634
|
const opts = actions.length ? `, { ${actions.join(', ')} }` : '';
|
|
546
635
|
items.push(`foreignKey(${cols}, () => ${modelVarName(fk.refTable)}, ${refCols}${opts})`);
|
|
547
636
|
}
|
|
@@ -569,7 +658,7 @@ export function renderModelBlock(table: TableSchema, known: Set<string>, enums:
|
|
|
569
658
|
// put it, proving the position is mechanical-edit-friendly).
|
|
570
659
|
const stanza = abilitiesStanza(abilities, table, liveAuthz, governRoles);
|
|
571
660
|
const writtenBy = writtenByStanza(table, liveAuthz);
|
|
572
|
-
const softDelete = softDeleteStanza(table, stanza.publicRead);
|
|
661
|
+
const softDelete = softDeleteStanza(table, stanza.publicRead, stanza.grantsDelete);
|
|
573
662
|
|
|
574
663
|
// A non-public table carries `schema:` — defineModel stores the name VERBATIM, so the
|
|
575
664
|
// qualification cannot ride in the first argument (that would make the table literally
|