@everystack/cli 0.4.38 → 0.4.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/cli/authz-compile.ts +71 -7
- package/src/cli/authz-derive.ts +210 -0
- package/src/cli/authz-redteam.ts +132 -9
- package/src/cli/commands/db-authz.ts +19 -3
- package/src/cli/commands/db-fingerprint.ts +19 -2
- package/src/cli/commands/db-pull.ts +30 -5
- package/src/cli/commands/db-swap.ts +7 -2
- package/src/cli/commands/db.ts +11 -0
- package/src/cli/derived-apply.ts +15 -1
- package/src/cli/derived-compile.ts +11 -1
- package/src/cli/derived-introspect.ts +36 -8
- package/src/cli/derived-plan.ts +53 -0
- package/src/cli/derived-render.ts +65 -5
- package/src/cli/model-render.ts +33 -6
- package/src/cli/pg-argtypes.ts +52 -0
|
@@ -31,6 +31,8 @@ import fs from 'node:fs/promises';
|
|
|
31
31
|
import path from 'node:path';
|
|
32
32
|
import { introspectSchema, MATVIEW_COLUMNS_SQL, matviewColumnsByIdentity, type ColumnRow, type ColumnSchema } from '../schema-introspect.js';
|
|
33
33
|
import { introspectDerived, type DerivedCatalog } from '../derived-introspect.js';
|
|
34
|
+
import { introspectContract, type TableContract } from '../authz-contract.js';
|
|
35
|
+
import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
|
|
34
36
|
import { renderDerivedSource, renderDerivedFile, type DerivedRenderResult } from '../derived-render.js';
|
|
35
37
|
import { renderModelSource, renderModelFiles, pullableTables, modelVarName, ABILITY_PRESETS } from '../model-render.js';
|
|
36
38
|
import type { QueryRunner } from '../authz-contract.js';
|
|
@@ -102,8 +104,8 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
102
104
|
// The read-model scaffold: default 'commented' surfaces the authz decision in every
|
|
103
105
|
// model; a preset stamps it. Validated up front so a typo fails before introspection.
|
|
104
106
|
const abilities = flags.abilities || 'commented';
|
|
105
|
-
if (abilities !== 'commented' && !ABILITY_PRESETS[abilities]) {
|
|
106
|
-
fail(`Unknown --abilities preset '${abilities}'. Known: ${Object.keys(ABILITY_PRESETS).join(', ')} (omit the flag to scaffold the decision as comments).`);
|
|
107
|
+
if (abilities !== 'commented' && abilities !== 'live' && !ABILITY_PRESETS[abilities]) {
|
|
108
|
+
fail(`Unknown --abilities preset '${abilities}'. Known: live, ${Object.keys(ABILITY_PRESETS).join(', ')} (omit the flag to scaffold the decision as comments).`);
|
|
107
109
|
process.exit(1);
|
|
108
110
|
}
|
|
109
111
|
|
|
@@ -123,6 +125,7 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
123
125
|
}
|
|
124
126
|
|
|
125
127
|
let current;
|
|
128
|
+
let liveAuthz: Map<string, TableContract> | undefined;
|
|
126
129
|
let derivedCatalog: DerivedCatalog | undefined;
|
|
127
130
|
let matviewColumns: Map<string, ColumnSchema[]> | undefined;
|
|
128
131
|
let candidatesByIdentity: Map<string, string[]> | undefined;
|
|
@@ -143,6 +146,28 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
143
146
|
// The derived layer rides the same pull (B5) — views/matviews/functions/sequences
|
|
144
147
|
// render as descriptors; adoption is pull → commit → --baseline → clean reconcile.
|
|
145
148
|
derivedCatalog = await introspectDerived(runner);
|
|
149
|
+
// --abilities live: the authz half of the on-ramp. Same connection, one more read —
|
|
150
|
+
// the grants and policies that already exist become the models' declared abilities,
|
|
151
|
+
// instead of a human transcribing them by hand.
|
|
152
|
+
if (abilities === 'live') {
|
|
153
|
+
note('Introspecting live authorization (grants + policies)...');
|
|
154
|
+
const contract = await introspectContract(runner, contractFunctionRow, FUNCTIONS_SQL);
|
|
155
|
+
liveAuthz = new Map(contract.tables.map((t) => [t.table, t]));
|
|
156
|
+
detail(`${liveAuthz.size} table(s) carry authorization.`);
|
|
157
|
+
// Roles outside the model vocabulary (anon/authenticated/admin) are usually ONE
|
|
158
|
+
// operator account granted across the whole schema. Said once here rather than
|
|
159
|
+
// repeated in all N models, where it would bury the per-table findings.
|
|
160
|
+
const unmapped = new Set<string>();
|
|
161
|
+
for (const t of contract.tables) {
|
|
162
|
+
for (const r of Object.keys(t.grants)) {
|
|
163
|
+
if (!['anon', 'authenticated', 'admin', 'PUBLIC', 'public'].includes(r)) unmapped.add(r);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (unmapped.size) {
|
|
167
|
+
note(`Grants exist for ${[...unmapped].sort().join(', ')} — not rendered: abilities name the roles the model knows (anon/authenticated/admin).`);
|
|
168
|
+
detail(`These are left exactly as they are in the database. Add can(..., { role }) only if you want the models to own them.`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
146
171
|
// --matviews-as-tables: the flip needs real fields — one extra catalog read for the
|
|
147
172
|
// matview columns the derived layer (definition-only) doesn't carry.
|
|
148
173
|
if (matviewsAsTables) {
|
|
@@ -235,7 +260,7 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
235
260
|
if (flags.out && !flags.out.endsWith('.ts')) {
|
|
236
261
|
// A directory: one file per model + index.ts — the default shape for a real app.
|
|
237
262
|
const dir = path.resolve(flags.out);
|
|
238
|
-
const files = renderModelFiles(current, { schema, abilities, derived: embeddedDerived });
|
|
263
|
+
const files = renderModelFiles(current, { schema, abilities, liveAuthz, derived: embeddedDerived });
|
|
239
264
|
try {
|
|
240
265
|
await fs.mkdir(dir, { recursive: true });
|
|
241
266
|
const written = new Set(files.map((f) => f.file));
|
|
@@ -252,7 +277,7 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
252
277
|
source = files.map((f) => f.source).join('\n');
|
|
253
278
|
} else if (flags.out) {
|
|
254
279
|
const outPath = path.resolve(flags.out);
|
|
255
|
-
source = renderModelSource(current, { schema, abilities, derived: embeddedDerived });
|
|
280
|
+
source = renderModelSource(current, { schema, abilities, liveAuthz, derived: embeddedDerived });
|
|
256
281
|
try {
|
|
257
282
|
await fs.mkdir(path.dirname(outPath), { recursive: true });
|
|
258
283
|
await fs.writeFile(outPath, source, 'utf8');
|
|
@@ -262,7 +287,7 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
262
287
|
}
|
|
263
288
|
ok(`Wrote ${path.relative(process.cwd(), outPath)} — ${pulled.length} model(s).`);
|
|
264
289
|
} else {
|
|
265
|
-
source = renderModelSource(current, { schema, abilities, derived: embeddedDerived });
|
|
290
|
+
source = renderModelSource(current, { schema, abilities, liveAuthz, derived: embeddedDerived });
|
|
266
291
|
process.stdout.write(source);
|
|
267
292
|
ok(`Rendered ${pulled.length} model(s) from schema "${schema}" (stdout — redirect or pass --out to save).`);
|
|
268
293
|
}
|
|
@@ -29,6 +29,7 @@ import { loadDeclaredDerived } from '../declared-derived.js';
|
|
|
29
29
|
import type { SourceObject } from '../derived-source.js';
|
|
30
30
|
import { pairedDerivedSchemas, renderPairedDerivedBuild, renderSwapSchemaUsage, swapSchemaRoles, expectedIncomingObjects, renderPairedProvenance } from '../swap-pair.js';
|
|
31
31
|
import { introspectDerived } from '../derived-introspect.js';
|
|
32
|
+
import { legacyFunctionIdentity } from '../pg-argtypes.js';
|
|
32
33
|
import { createUrlRunner } from '../db-source.js';
|
|
33
34
|
import type { QueryRunner } from '../authz-contract.js';
|
|
34
35
|
import { executeSwap, type SwapVerdict } from '../swap-execute.js';
|
|
@@ -637,7 +638,10 @@ export async function dbSwapCommand(flags: Record<string, string>): Promise<void
|
|
|
637
638
|
artifactFingerprint,
|
|
638
639
|
declaredFingerprint,
|
|
639
640
|
// What db:reconcile can regenerate — the set a dependent must be in to be safe to drop.
|
|
640
|
-
|
|
641
|
+
// NAME granularity: both consumers (the paired pre-flight's dependentIdentity, the TOC
|
|
642
|
+
// filter) read a function as `schema.name` with its argument list stripped, so the
|
|
643
|
+
// signature half of a derived identity is dropped here rather than never matching.
|
|
644
|
+
declaredIdentities: declaredDerivedObjects.map((o) => legacyFunctionIdentity(o.identity)),
|
|
641
645
|
rebuildDerived: flags['rebuild-derived'] === 'true',
|
|
642
646
|
paired,
|
|
643
647
|
// Schema-level USAGE, re-applied in the swap transaction and asserted after it commits.
|
|
@@ -709,7 +713,8 @@ export async function dbSwapCommand(flags: Record<string, string>): Promise<void
|
|
|
709
713
|
runner: r,
|
|
710
714
|
log: (m) => info(m),
|
|
711
715
|
warn: (m) => warn(m),
|
|
712
|
-
|
|
716
|
+
// Name granularity — the TOC entry's argument list is stripped before the lookup.
|
|
717
|
+
declaredIdentities: declaredDerivedObjects.map((o) => legacyFunctionIdentity(o.identity)),
|
|
713
718
|
});
|
|
714
719
|
},
|
|
715
720
|
snapshot: () => takePreSwapSnapshot(snapshotPlan, { stage, region, opsFn }),
|
package/src/cli/commands/db.ts
CHANGED
|
@@ -721,6 +721,17 @@ function printDoctorReport(report: any): void {
|
|
|
721
721
|
success('db:doctor — database is least-privilege and RLS-subject');
|
|
722
722
|
} else {
|
|
723
723
|
fail('db:doctor — the api connection is NOT correctly least-privilege (see failures above)');
|
|
724
|
+
// Probing a local database as your own superuser makes these fail by construction:
|
|
725
|
+
// the checks describe the credential the API serves with, not the schema. A developer
|
|
726
|
+
// reading them as defects in their app will go hunting for a bug that is not there.
|
|
727
|
+
if (report.api?.isSuperuser) {
|
|
728
|
+
console.log('');
|
|
729
|
+
info('Note: this probe connected as a SUPERUSER, so least-privilege / not-superuser / fails-closed');
|
|
730
|
+
info('cannot pass by construction. Those three describe the credential your API serves with —');
|
|
731
|
+
info('they are only meaningful against the app role (a deployed stage, or a local DATABASE_URL');
|
|
732
|
+
info('pointing at the least-privilege role from db:provision). The RLS and grant findings above');
|
|
733
|
+
info('are still real.');
|
|
734
|
+
}
|
|
724
735
|
}
|
|
725
736
|
}
|
|
726
737
|
|
package/src/cli/derived-apply.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { normalizeSql, parseQualified, type SourceObject } from './derived-sourc
|
|
|
17
17
|
import type { ReconcilePlan } from './derived-plan.js';
|
|
18
18
|
import { parseGrantAttachments } from './derived-grants.js';
|
|
19
19
|
import { quoteIdent, quoteQualified } from './pg-ident.js';
|
|
20
|
+
import { splitFunctionIdentity } from './pg-argtypes.js';
|
|
20
21
|
|
|
21
22
|
/** SQL string literal with '' doubling (standard_conforming_strings). */
|
|
22
23
|
export function escapeLiteral(value: string): string {
|
|
@@ -84,6 +85,19 @@ const DROP_KEYWORD: Record<string, string> = {
|
|
|
84
85
|
function: 'FUNCTION',
|
|
85
86
|
};
|
|
86
87
|
|
|
88
|
+
/**
|
|
89
|
+
* The DROP target for one identity. A function's identity carries its argument types, and
|
|
90
|
+
* PostgreSQL NEEDS them: `DROP FUNCTION IF EXISTS api.get_user` against two overloads is
|
|
91
|
+
* `ERROR: function name "api.get_user" is not unique` — the apply died on it. The types
|
|
92
|
+
* ride verbatim (they are the catalog's own spelling); only the name parts are quoted.
|
|
93
|
+
* A legacy identity with no signature keeps the bare rendering it always had.
|
|
94
|
+
*/
|
|
95
|
+
export function dropTarget(kind: string, identity: string): string {
|
|
96
|
+
if (kind !== 'function') return quoteQualified(identity);
|
|
97
|
+
const { qualified, args } = splitFunctionIdentity(identity);
|
|
98
|
+
return args === null ? quoteQualified(qualified) : `${quoteQualified(qualified)}(${args})`;
|
|
99
|
+
}
|
|
100
|
+
|
|
87
101
|
/** Rewrite `CREATE FUNCTION` to `CREATE OR REPLACE FUNCTION` (replace-in-place). */
|
|
88
102
|
export function ensureOrReplace(sql: string): string {
|
|
89
103
|
return sql.replace(/^(\s*)CREATE\s+FUNCTION/i, '$1CREATE OR REPLACE FUNCTION');
|
|
@@ -190,7 +204,7 @@ export function renderReconcileSql(plan: ReconcilePlan, source: SourceObject[]):
|
|
|
190
204
|
// The new kinds carry their drop on the action (triggers: composed from structure;
|
|
191
205
|
// 'sql' objects: the recorded/declared drop_sql) — the legacy kinds keep the
|
|
192
206
|
// keyword rendering they always had.
|
|
193
|
-
statements.push(action.dropSql ?? `DROP ${DROP_KEYWORD[action.kind]} IF EXISTS ${
|
|
207
|
+
statements.push(action.dropSql ?? `DROP ${DROP_KEYWORD[action.kind]} IF EXISTS ${dropTarget(action.kind, action.identity)}`);
|
|
194
208
|
// A rebuild's drop is followed by its create; only a true removal loses provenance.
|
|
195
209
|
if (!plan.actions.some((a) => a.action === 'create' && a.identity === action.identity)) {
|
|
196
210
|
remove.push(action.identity);
|
|
@@ -23,6 +23,7 @@ import type {
|
|
|
23
23
|
} from '@everystack/model';
|
|
24
24
|
import { hashSourceContent, parseQualified, DECLARED_SOURCE_FILE, type Attachment, type SourceObject } from './derived-source.js';
|
|
25
25
|
import { isPlainGrantAttachment } from './derived-grants.js';
|
|
26
|
+
import { functionIdentity } from './pg-argtypes.js';
|
|
26
27
|
import { findInvokerReachabilityGaps } from './derived-lint.js';
|
|
27
28
|
|
|
28
29
|
/** The provenance marker for descriptor-compiled objects (SourceObject.file). */
|
|
@@ -195,8 +196,17 @@ interface Node {
|
|
|
195
196
|
build: (seq: number) => SourceObject;
|
|
196
197
|
}
|
|
197
198
|
|
|
199
|
+
/**
|
|
200
|
+
* The reconciler's join key. Functions carry their ARGUMENT TYPES — PostgreSQL identifies
|
|
201
|
+
* a function by name + argtypes, and `schema.name` alone collapsed two overloads onto one
|
|
202
|
+
* object (one provenance row for two live functions, an ambiguous DROP, duplicate consts
|
|
203
|
+
* out of db:pull). The declared types are normalized to the catalog's spelling so the
|
|
204
|
+
* declared identity is the same string the live catalog produces. Everything else keeps
|
|
205
|
+
* `schema.name` unchanged.
|
|
206
|
+
*/
|
|
198
207
|
function derivedIdentity(d: DerivedDescriptor): string {
|
|
199
208
|
const { schema, name } = parseQualified(d.name);
|
|
209
|
+
if (d.kind === 'function') return functionIdentity(schema, name, d.args.map((a) => a.type));
|
|
200
210
|
return `${schema}.${name}`;
|
|
201
211
|
}
|
|
202
212
|
|
|
@@ -309,7 +319,7 @@ export function compileDerived(models: readonly ModelDescriptor[], derived: read
|
|
|
309
319
|
case 'function': {
|
|
310
320
|
const { sql, attachments } = renderFunction(d);
|
|
311
321
|
const setofDep = typeof d.returns === 'string' ? [] : depIdentities([d.returns.setof]);
|
|
312
|
-
nodes.push({ identity, deps: [...depIdentities(d.dependsOn), ...setofDep], build: make('function', d.name, sql, attachments) });
|
|
322
|
+
nodes.push({ identity, deps: [...depIdentities(d.dependsOn), ...setofDep], build: make('function', d.name, sql, attachments, { identity }) });
|
|
313
323
|
break;
|
|
314
324
|
}
|
|
315
325
|
case 'sql': {
|
|
@@ -26,7 +26,8 @@ export interface LiveObject {
|
|
|
26
26
|
kind: DerivedKind;
|
|
27
27
|
schema: string;
|
|
28
28
|
name: string;
|
|
29
|
-
/** `schema.name` — join key against source objects and provenance.
|
|
29
|
+
/** `schema.name` — join key against source objects and provenance.
|
|
30
|
+
* Functions carry their signature: `schema.name(integer, text)`. */
|
|
30
31
|
identity: string;
|
|
31
32
|
/** Canonical definition as the catalog deparses it. */
|
|
32
33
|
definition: string;
|
|
@@ -53,6 +54,9 @@ export interface LiveObject {
|
|
|
53
54
|
fn?: {
|
|
54
55
|
/** pg_get_function_arguments — `'query text, max integer DEFAULT 20'`. */
|
|
55
56
|
args: string;
|
|
57
|
+
/** The IDENTITY arguments — types only, catalog-spelled (`'integer, text'`).
|
|
58
|
+
* What `DROP FUNCTION` needs, and the signature half of `identity`. */
|
|
59
|
+
identityArgs?: string;
|
|
56
60
|
/** pg_get_function_result — `'integer'`, `'SETOF posts'`, `'trigger'`. */
|
|
57
61
|
returns: string;
|
|
58
62
|
language: string;
|
|
@@ -123,15 +127,30 @@ WHERE c.relkind IN ('v', 'm')
|
|
|
123
127
|
ORDER BY n.nspname, c.relname;
|
|
124
128
|
`.trim();
|
|
125
129
|
|
|
130
|
+
/**
|
|
131
|
+
* The catalog's spelling of a function's IDENTITY arguments — `format_type` over
|
|
132
|
+
* `proargtypes`, the exact list `DROP FUNCTION` wants. Types only, never argument names
|
|
133
|
+
* (renaming an argument is not a new function) and never defaults. `proargtypes` is the
|
|
134
|
+
* IN-argument vector, which is precisely what identifies a function.
|
|
135
|
+
*
|
|
136
|
+
* Referenced by every function-shaped read below, so a function's identity is the SAME
|
|
137
|
+
* string wherever it is produced. `<alias>` is substituted with the pg_proc alias in scope.
|
|
138
|
+
*/
|
|
139
|
+
const IDENTITY_ARGS_SQL = (alias: string): string => `
|
|
140
|
+
(SELECT COALESCE(string_agg(pg_catalog.format_type(t.oid, NULL), ', ' ORDER BY t.ord), '')
|
|
141
|
+
FROM unnest(${alias}.proargtypes::oid[]) WITH ORDINALITY AS t(oid, ord))`.trim();
|
|
142
|
+
|
|
126
143
|
/** Plain functions and procedures: the canonical definition (reconcile's def hash) plus
|
|
127
144
|
* the STRUCTURED fields db:pull renders into defineFunction — args/returns from the
|
|
128
145
|
* deparse helpers, language, SECDEF, volatility, the pinned search_path, and the raw
|
|
129
146
|
* body (prosrc). Anything the v1 signature vocabulary can't say falls back to defineSql
|
|
130
|
-
* with the whole pg_get_functiondef.
|
|
147
|
+
* with the whole pg_get_functiondef. `identity_args` carries the signature half of the
|
|
148
|
+
* identity — without it two overloads collapse onto one object. */
|
|
131
149
|
export const DERIVED_FUNCTIONS_SQL = `
|
|
132
150
|
SELECT
|
|
133
151
|
n.nspname AS schema,
|
|
134
152
|
p.proname AS name,
|
|
153
|
+
${IDENTITY_ARGS_SQL('p')} AS identity_args,
|
|
135
154
|
pg_get_functiondef(p.oid) AS definition,
|
|
136
155
|
obj_description(p.oid, 'pg_proc') AS comment,
|
|
137
156
|
pg_get_function_arguments(p.oid) AS args,
|
|
@@ -151,7 +170,7 @@ WHERE p.prokind IN ('f', 'p')
|
|
|
151
170
|
AND NOT EXISTS (
|
|
152
171
|
SELECT 1 FROM pg_depend dep WHERE dep.objid = p.oid AND dep.deptype = 'e'
|
|
153
172
|
)
|
|
154
|
-
ORDER BY n.nspname, p.proname;
|
|
173
|
+
ORDER BY n.nspname, p.proname, 3;
|
|
155
174
|
`.trim();
|
|
156
175
|
|
|
157
176
|
/** Every index on a materialized view, as its canonical CREATE INDEX text. */
|
|
@@ -204,7 +223,7 @@ WHERE d.classid = 'pg_rewrite'::regclass
|
|
|
204
223
|
)
|
|
205
224
|
UNION
|
|
206
225
|
SELECT DISTINCT
|
|
207
|
-
dn.nspname, dc.relname, rn.nspname, rp.proname
|
|
226
|
+
dn.nspname, dc.relname, rn.nspname, rp.proname || '(' || ${IDENTITY_ARGS_SQL('rp')} || ')'
|
|
208
227
|
FROM pg_depend d
|
|
209
228
|
JOIN pg_rewrite rw ON rw.oid = d.objid
|
|
210
229
|
JOIN pg_class dc ON dc.oid = rw.ev_class
|
|
@@ -234,7 +253,7 @@ UNION
|
|
|
234
253
|
-- Latent until something UPSTREAM of such a view actually changes, which is why an app can carry
|
|
235
254
|
-- this shape for a long time and only meet it the first time the view has to rebuild.
|
|
236
255
|
SELECT DISTINCT
|
|
237
|
-
dn.nspname, dp.proname, rn.nspname, rc.relname
|
|
256
|
+
dn.nspname, dp.proname || '(' || ${IDENTITY_ARGS_SQL('dp')} || ')', rn.nspname, rc.relname
|
|
238
257
|
FROM pg_depend d
|
|
239
258
|
JOIN pg_proc dp ON dp.oid = d.objid
|
|
240
259
|
JOIN pg_namespace dn ON dn.oid = dp.pronamespace
|
|
@@ -286,7 +305,7 @@ GROUP BY n.nspname, c.relname, r.rolname
|
|
|
286
305
|
UNION ALL
|
|
287
306
|
SELECT
|
|
288
307
|
n.nspname,
|
|
289
|
-
p.proname,
|
|
308
|
+
p.proname || '(' || ${IDENTITY_ARGS_SQL('p')} || ')',
|
|
290
309
|
'f',
|
|
291
310
|
COALESCE(r.rolname, 'PUBLIC'),
|
|
292
311
|
array_agg(DISTINCT a.privilege_type ORDER BY a.privilege_type)
|
|
@@ -301,7 +320,7 @@ WHERE p.prokind IN ('f', 'p')
|
|
|
301
320
|
AND NOT EXISTS (
|
|
302
321
|
SELECT 1 FROM pg_depend dep WHERE dep.objid = p.oid AND dep.deptype = 'e'
|
|
303
322
|
)
|
|
304
|
-
GROUP BY n.nspname, p.proname, r.rolname
|
|
323
|
+
GROUP BY n.nspname, p.oid, p.proname, r.rolname
|
|
305
324
|
ORDER BY 1, 2, 4;
|
|
306
325
|
`.trim();
|
|
307
326
|
|
|
@@ -349,6 +368,9 @@ export interface RelationRow {
|
|
|
349
368
|
export interface FunctionRow {
|
|
350
369
|
schema: string;
|
|
351
370
|
name: string;
|
|
371
|
+
/** `format_type` over proargtypes — the signature half of the identity. Absent on a
|
|
372
|
+
* pre-signature caller/fixture, which falls back to the legacy `schema.name` identity. */
|
|
373
|
+
identity_args?: unknown;
|
|
352
374
|
definition: unknown;
|
|
353
375
|
comment: unknown;
|
|
354
376
|
/** Structured signature fields (B5 pull rendering). Optional — pre-B5 fixtures omit them. */
|
|
@@ -485,7 +507,12 @@ export function assembleDerivedCatalog(rows: DerivedRows): DerivedCatalog {
|
|
|
485
507
|
|
|
486
508
|
for (const row of rows.functions) {
|
|
487
509
|
if (IGNORED_SCHEMAS.has(row.schema)) continue;
|
|
488
|
-
|
|
510
|
+
// Identity is name + argument types — two overloads are two objects. A row without
|
|
511
|
+
// identity_args predates the signature and keeps the legacy `schema.name`.
|
|
512
|
+
const identityArgs = row.identity_args == null ? null : String(row.identity_args);
|
|
513
|
+
const identity = identityArgs === null
|
|
514
|
+
? `${row.schema}.${row.name}`
|
|
515
|
+
: `${row.schema}.${row.name}(${identityArgs})`;
|
|
489
516
|
const definition = String(row.definition ?? '');
|
|
490
517
|
const comment = row.comment == null ? undefined : String(row.comment);
|
|
491
518
|
objects.push({
|
|
@@ -497,6 +524,7 @@ export function assembleDerivedCatalog(rows: DerivedRows): DerivedCatalog {
|
|
|
497
524
|
...(row.src !== undefined ? {
|
|
498
525
|
fn: {
|
|
499
526
|
args: String(row.args ?? ''),
|
|
527
|
+
...(identityArgs !== null ? { identityArgs } : {}),
|
|
500
528
|
returns: String(row.returns ?? ''),
|
|
501
529
|
language: String(row.language ?? 'sql'),
|
|
502
530
|
secdef: row.secdef === true || row.secdef === 't' || row.secdef === 'true',
|
package/src/cli/derived-plan.ts
CHANGED
|
@@ -28,6 +28,7 @@ import { type ParsedSources, type SourceObject, type DerivedKind } from './deriv
|
|
|
28
28
|
import type { DerivedCatalog, LiveObject } from './derived-introspect.js';
|
|
29
29
|
import { triggerDropSql } from './derived-apply.js';
|
|
30
30
|
import { parseGrantAttachments, diffObjectGrants } from './derived-grants.js';
|
|
31
|
+
import { legacyFunctionIdentity, splitFunctionIdentity } from './pg-argtypes.js';
|
|
31
32
|
|
|
32
33
|
export type ReconcileVerb = 'create' | 'replace' | 'refresh' | 'drop' | 'baseline' | 'rebaseline' | 'prune' | 'backfill';
|
|
33
34
|
|
|
@@ -166,6 +167,33 @@ export function planReconcile(
|
|
|
166
167
|
migrations.push({ from: prov.identity, to });
|
|
167
168
|
}
|
|
168
169
|
}
|
|
170
|
+
// Function provenance predates the signature: a row recorded as `schema.name` claims the
|
|
171
|
+
// SAME object the catalog now reports as `schema.name(argtypes)`. Re-key it in place —
|
|
172
|
+
// no forced rebaseline, no rebuild, converged after one apply. Driven off the LIVE
|
|
173
|
+
// catalog (the ground truth for what exists), so a function the source has since removed
|
|
174
|
+
// still drops through its provenance instead of reading as unmanaged.
|
|
175
|
+
//
|
|
176
|
+
// Skipped where it would be a guess: an overloaded name (one legacy row cannot stand for
|
|
177
|
+
// two objects — those baseline honestly), a row a live NON-function already owns (a view
|
|
178
|
+
// and a function can share `schema.name`), and a 'sql'-kind row (its identity is its
|
|
179
|
+
// declared name, not a catalog signature).
|
|
180
|
+
const liveFnByLegacy = new Map<string, LiveObject[]>();
|
|
181
|
+
for (const o of live.objects) {
|
|
182
|
+
if (o.kind !== 'function') continue;
|
|
183
|
+
const legacy = legacyFunctionIdentity(o.identity);
|
|
184
|
+
if (legacy === o.identity) continue; // no signature introspected — nothing to migrate
|
|
185
|
+
liveFnByLegacy.set(legacy, [...(liveFnByLegacy.get(legacy) ?? []), o]);
|
|
186
|
+
}
|
|
187
|
+
for (const [legacy, objs] of liveFnByLegacy) {
|
|
188
|
+
if (objs.length !== 1 || liveById.has(legacy)) continue;
|
|
189
|
+
const to = objs[0].identity;
|
|
190
|
+
const prov = provById.get(legacy);
|
|
191
|
+
if (!prov || prov.kind === 'sql' || provById.has(to)) continue;
|
|
192
|
+
provById.delete(legacy);
|
|
193
|
+
provById.set(to, { ...prov, identity: to });
|
|
194
|
+
migrations.push({ from: legacy, to });
|
|
195
|
+
}
|
|
196
|
+
|
|
169
197
|
// Every provenance consumer below reads the POST-MIGRATION view.
|
|
170
198
|
const provRows = [...provById.values()];
|
|
171
199
|
|
|
@@ -330,6 +358,31 @@ export function planReconcile(
|
|
|
330
358
|
else unmanaged.push(liveObj.identity);
|
|
331
359
|
}
|
|
332
360
|
|
|
361
|
+
// A declared function that joins nothing, sitting next to a live function of the same
|
|
362
|
+
// name and arity that nothing declares: the two spellings of ONE signature disagree.
|
|
363
|
+
// normalizeArgType resolves the SQL aliases, but it cannot know that a custom type
|
|
364
|
+
// outside the search_path prints qualified (`api.my_enum`) while the declaration says
|
|
365
|
+
// `my_enum`. Name the pair — the rebuild below is correct but would repeat forever.
|
|
366
|
+
const arityOf = (identity: string): number => {
|
|
367
|
+
const { args } = splitFunctionIdentity(identity);
|
|
368
|
+
if (args === null) return -1;
|
|
369
|
+
return args.trim() === '' ? 0 : args.split(',').length;
|
|
370
|
+
};
|
|
371
|
+
for (const src of source.objects) {
|
|
372
|
+
if (src.kind !== 'function' || liveById.has(src.identity)) continue;
|
|
373
|
+
const legacy = legacyFunctionIdentity(src.identity);
|
|
374
|
+
const candidates = live.objects.filter((o) =>
|
|
375
|
+
o.kind === 'function'
|
|
376
|
+
&& !srcById.has(o.identity)
|
|
377
|
+
&& legacyFunctionIdentity(o.identity) === legacy
|
|
378
|
+
&& arityOf(o.identity) === arityOf(src.identity));
|
|
379
|
+
if (candidates.length === 1) {
|
|
380
|
+
extraWarnings.push(
|
|
381
|
+
`${src.identity}: nothing live matches, but ${candidates[0].identity} matches by name and arity — the declared argument types must be spelled the way the catalog prints them. Until they agree this plans a rebuild on EVERY apply.`,
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
333
386
|
for (const prov of provRows) {
|
|
334
387
|
if (only && !only.has(prov.identity)) continue;
|
|
335
388
|
if (srcById.has(prov.identity) || liveById.has(prov.identity)) continue;
|
|
@@ -17,6 +17,7 @@ import type { DerivedCatalog, LiveObject } from './derived-introspect.js';
|
|
|
17
17
|
import type { ColumnSchema, SequenceSchema, TableSchema } from './schema-introspect.js';
|
|
18
18
|
import { parseIndexDefinition } from './schema-introspect.js';
|
|
19
19
|
import { modelFileName, renderFieldLines } from './model-render.js';
|
|
20
|
+
import { splitFunctionIdentity } from './pg-argtypes.js';
|
|
20
21
|
|
|
21
22
|
export interface DerivedRenderResult {
|
|
22
23
|
/** The source block: `export const … = defineView(…)` etc., dependency-ordered. */
|
|
@@ -98,6 +99,19 @@ function relationAbilities(grants: Record<string, string[]>): string[] | null {
|
|
|
98
99
|
return out;
|
|
99
100
|
}
|
|
100
101
|
|
|
102
|
+
/**
|
|
103
|
+
* A function identity's argument types as a PascalCase suffix — `(integer, text)` →
|
|
104
|
+
* `IntegerText`, `(text[])` → `TextArray`, `()` → `Void`. Only ever appended to
|
|
105
|
+
* disambiguate overloads that would otherwise emit the same `export const`.
|
|
106
|
+
*/
|
|
107
|
+
function argSuffix(identity: string): string {
|
|
108
|
+
const { args } = splitFunctionIdentity(identity);
|
|
109
|
+
if (args === null) return '';
|
|
110
|
+
const words = args.replace(/\[\]/g, ' array ').replace(/[^A-Za-z0-9]+/g, ' ').trim();
|
|
111
|
+
if (!words) return 'Void';
|
|
112
|
+
return words.split(/\s+/).map((w) => w[0].toUpperCase() + w.slice(1)).join('');
|
|
113
|
+
}
|
|
114
|
+
|
|
101
115
|
/** VOLATILITY letters → the descriptor words ('v' is the default, never spelled). */
|
|
102
116
|
const VOLATILITY: Record<string, string> = { i: 'immutable', s: 'stable' };
|
|
103
117
|
|
|
@@ -107,6 +121,24 @@ interface ParsedArg {
|
|
|
107
121
|
default?: string;
|
|
108
122
|
}
|
|
109
123
|
|
|
124
|
+
/**
|
|
125
|
+
* The SQL type names that are more than one word, by their FIRST word and the word that
|
|
126
|
+
* can follow it. An UNNAMED argument of such a type (`timestamp with time zone`) looks
|
|
127
|
+
* exactly like a named one (`ts timestamptz`) to a "first token is the name" rule, and was
|
|
128
|
+
* read as an argument called `timestamp` of type `with time zone` — which then rendered as
|
|
129
|
+
* `arg('timestamp', 'with time zone')`, a signature the catalog has never heard of.
|
|
130
|
+
* Requiring the CONTINUATION word keeps a genuine argument named `time` (`time integer`)
|
|
131
|
+
* reading as an argument.
|
|
132
|
+
*/
|
|
133
|
+
const TYPE_CONTINUATIONS: Record<string, RegExp> = {
|
|
134
|
+
double: /^precision\b/i,
|
|
135
|
+
character: /^varying\b/i,
|
|
136
|
+
bit: /^varying\b/i,
|
|
137
|
+
timestamp: /^with(out)?\b/i,
|
|
138
|
+
time: /^with(out)?\b/i,
|
|
139
|
+
national: /^character\b/i,
|
|
140
|
+
};
|
|
141
|
+
|
|
110
142
|
/** Parse `pg_get_function_arguments` output. Null = beyond the v1 vocabulary. */
|
|
111
143
|
function parseArgs(args: string): ParsedArg[] | null {
|
|
112
144
|
const trimmed = args.trim();
|
|
@@ -130,6 +162,11 @@ function parseArgs(args: string): ParsedArg[] | null {
|
|
|
130
162
|
if (!m || !m[2]) return null;
|
|
131
163
|
// A single token is an UNNAMED arg ('text') — the match would misread type as name.
|
|
132
164
|
if (!/\s/.test(entry.replace(/\s+DEFAULT\s+.+$/i, '')) ) return null;
|
|
165
|
+
// …and so is a MULTI-WORD type ('timestamp with time zone'), which has a first token
|
|
166
|
+
// that reads perfectly well as an argument name. defineFunction cannot say an unnamed
|
|
167
|
+
// argument, so this is a FIXME → defineSql, not a signature invented from the words.
|
|
168
|
+
const continuation = TYPE_CONTINUATIONS[m[1].toLowerCase()];
|
|
169
|
+
if (continuation && continuation.test(m[2])) return null;
|
|
133
170
|
out.push({ name: m[1], type: m[2], ...(m[3] ? { default: m[3] } : {}) });
|
|
134
171
|
}
|
|
135
172
|
return out;
|
|
@@ -196,13 +233,22 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
|
|
|
196
233
|
}
|
|
197
234
|
const byIdentity = new Map(renderable.map((o) => [o.identity, o]));
|
|
198
235
|
// Var names carry the schema for non-public objects — two schemas can share a bare name.
|
|
236
|
+
// OVERLOADS share everything but their argument types, so a bare name would emit two
|
|
237
|
+
// `export const apiGetUser = …` and the barrel would not compile (it did not — that is
|
|
238
|
+
// how this whole class surfaced). The argument-type suffix appears ONLY on a collision,
|
|
239
|
+
// so a schema without overloads renders byte-identically to before.
|
|
240
|
+
const baseVar = (o: LiveObject): string => toCamelCase(o.schema === 'public' ? o.name : `${o.schema}_${o.name}`);
|
|
241
|
+
const varCounts = new Map<string, number>();
|
|
242
|
+
for (const o of renderable) varCounts.set(baseVar(o), (varCounts.get(baseVar(o)) ?? 0) + 1);
|
|
199
243
|
const varOf = new Map(renderable.map((o) =>
|
|
200
|
-
[o.identity,
|
|
244
|
+
[o.identity, (varCounts.get(baseVar(o)) ?? 0) > 1 ? baseVar(o) + argSuffix(o.identity) : baseVar(o)]));
|
|
201
245
|
// The name a descriptor DECLARES: bare for public, qualified otherwise. parseQualified
|
|
202
246
|
// round-trips it, so the compiled identity matches the live object it was pulled from —
|
|
203
247
|
// a bare name would compile to public.<name>, baseline would never join, and reconcile
|
|
204
248
|
// would plan duplicate creates in public (the red-team's pull-qualification finding).
|
|
205
|
-
|
|
249
|
+
// NEVER the identity for a function — that carries the signature, which the descriptor
|
|
250
|
+
// derives from its own `args` rather than parsing out of the name.
|
|
251
|
+
const declaredName = (o: LiveObject): string => (o.schema === 'public' ? o.name : `${o.schema}.${o.name}`);
|
|
206
252
|
|
|
207
253
|
const indegree = new Map<string, number>();
|
|
208
254
|
const dependents = new Map<string, string[]>();
|
|
@@ -259,7 +305,17 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
|
|
|
259
305
|
const abilities = relationAbilities(o.grants ?? {});
|
|
260
306
|
if (abilities === null) {
|
|
261
307
|
const grantText = Object.entries(o.grants ?? {}).map(([r, p]) => `${r}: ${p.join('/')}`).join(', ');
|
|
262
|
-
|
|
308
|
+
// A view carrying INSERT/UPDATE/DELETE almost never means someone intended a
|
|
309
|
+
// writable view — it usually traces to a blanket `GRANT ALL ON ALL TABLES IN
|
|
310
|
+
// SCHEMA public` in an old migration, which sweeps views up with the tables.
|
|
311
|
+
// Naming that here saves the diagnosis; an adopter paid for it once already.
|
|
312
|
+
const writeGrants = Object.values(o.grants ?? {}).some((ps) =>
|
|
313
|
+
ps.some((p) => p === 'INSERT' || p === 'UPDATE' || p === 'DELETE'),
|
|
314
|
+
);
|
|
315
|
+
const hint = writeGrants
|
|
316
|
+
? ' Write privileges on a view usually come from a blanket `GRANT ALL ON ALL TABLES IN SCHEMA …` rather than a deliberate writable view — check that first.'
|
|
317
|
+
: '';
|
|
318
|
+
warnings.push(`${o.identity}: live grants (${grantText}) are not expressible as relation abilities (read-only, role-shaped) — object skipped; migrate it by hand.${hint}`);
|
|
263
319
|
lines.push(`// FIXME: ${o.identity} skipped — live grants (${grantText}) are not expressible as abilities (views are read surfaces).`, '');
|
|
264
320
|
continue;
|
|
265
321
|
}
|
|
@@ -364,11 +420,15 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
|
|
|
364
420
|
|
|
365
421
|
if (!f || args === null || (f.language !== 'sql' && f.language !== 'plpgsql')) {
|
|
366
422
|
warnings.push(`${o.identity}: signature beyond the v1 vocabulary (${f ? `language ${f.language}, args '${f.args}'` : 'no structured fields'}) — rendered as defineSql.`);
|
|
423
|
+
// A defineSql object is identified by its NAME alone, so an overload pair would
|
|
424
|
+
// collapse to one provenance row here too — the declared name carries the signature
|
|
425
|
+
// (the drop is explicit, so the name is a label, and a unique one is what it needs).
|
|
426
|
+
const sqlName = declaredName(o) + (f?.identityArgs !== undefined ? `(${f.identityArgs})` : '');
|
|
367
427
|
lines.push(
|
|
368
428
|
`// FIXME: ${o.identity} — signature beyond defineFunction's v1 vocabulary; kept verbatim as defineSql.`,
|
|
369
|
-
`export const ${varName} = defineSql(${tsString(
|
|
429
|
+
`export const ${varName} = defineSql(${tsString(sqlName)}, {`,
|
|
370
430
|
` kind: 'function',`,
|
|
371
|
-
` drop: sql\`DROP FUNCTION IF EXISTS ${o.
|
|
431
|
+
` drop: sql\`DROP FUNCTION IF EXISTS ${o.schema === 'public' ? o.name : `${o.schema}.${o.name}`}${f?.identityArgs !== undefined ? `(${f.identityArgs})` : ''}\`,`,
|
|
372
432
|
` as: sql\`${tsTemplate(o.definition.trim().replace(/;$/, ''))}\`,`,
|
|
373
433
|
'});',
|
|
374
434
|
'',
|
package/src/cli/model-render.ts
CHANGED
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
|
|
18
18
|
import type { SchemaSnapshot, TableSchema, ColumnSchema, CheckConstraint } from './schema-introspect.js';
|
|
19
19
|
import type { DerivedRenderResult } from './derived-render.js';
|
|
20
|
+
import type { TableContract } from './authz-contract.js';
|
|
21
|
+
import { deriveAbilities, renderDerivedAbilities } from './authz-derive.js';
|
|
20
22
|
import { normalizeDefault, normalizeCheck } from './schema-diff.js';
|
|
21
23
|
|
|
22
24
|
/**
|
|
@@ -68,6 +70,14 @@ export interface RenderOptions {
|
|
|
68
70
|
* as reviewable code. Grants are authored, never inherited; there is no runtime default.
|
|
69
71
|
*/
|
|
70
72
|
abilities?: string;
|
|
73
|
+
/**
|
|
74
|
+
* Live authorization, keyed by schema-qualified table — supplied when `abilities` is
|
|
75
|
+
* `'live'`. Each table's stanza is DERIVED from the grants and policies actually in the
|
|
76
|
+
* database rather than scaffolded or stamped: an ability is emitted only where the grant
|
|
77
|
+
* and the policy agree, and anything else arrives as a comment naming why. See
|
|
78
|
+
* authz-derive.ts for the rule (policy presence is not effective privilege).
|
|
79
|
+
*/
|
|
80
|
+
liveAuthz?: Map<string, TableContract>;
|
|
71
81
|
/** The rendered derived layer (B5) — rides the barrel: block after the models,
|
|
72
82
|
* sequences/derived arrays on the module wrapper, symbols on the import header. */
|
|
73
83
|
derived?: DerivedRenderResult;
|
|
@@ -87,7 +97,18 @@ export const ABILITY_PRESETS: Record<string, string> = {
|
|
|
87
97
|
* same thing the db:check gate failure says — one voice, two doorways), or a preset
|
|
88
98
|
* stamped uncommented. An unknown preset throws — grants are authored, never guessed.
|
|
89
99
|
*/
|
|
90
|
-
function abilitiesStanza(mode: string): string {
|
|
100
|
+
function abilitiesStanza(mode: string, table?: TableSchema, liveAuthz?: Map<string, TableContract>): string {
|
|
101
|
+
if (mode === 'live') {
|
|
102
|
+
const contract = table && liveAuthz?.get(table.table);
|
|
103
|
+
if (!contract) {
|
|
104
|
+
return [
|
|
105
|
+
' // no live authorization found for this table — nothing was granted, so nothing is',
|
|
106
|
+
' // rendered. Author the read model deliberately, or leave it internal:',
|
|
107
|
+
' // private: true,',
|
|
108
|
+
].join('\n');
|
|
109
|
+
}
|
|
110
|
+
return renderDerivedAbilities(deriveAbilities(contract));
|
|
111
|
+
}
|
|
91
112
|
if (mode === 'commented') {
|
|
92
113
|
return [
|
|
93
114
|
' // Declare the read model — db:check fails this model until its authz is authored:',
|
|
@@ -205,7 +226,13 @@ function renderDefault(expr: string): string | null {
|
|
|
205
226
|
const str = n.match(/^'([\s\S]*)'$/);
|
|
206
227
|
if (str) return `.default(${JSON.stringify(str[1].replace(/''/g, "'"))})`;
|
|
207
228
|
if (n === 'true' || n === 'false') return `.default(${n})`;
|
|
208
|
-
|
|
229
|
+
// A numeric default only survives as a JS number when the number renders back to the
|
|
230
|
+
// SAME text. `0.0` is the number 0, so `.default(0.0)` stores 0 and compiles to
|
|
231
|
+
// `DEFAULT 0` against a live `DEFAULT 0.0` — drift on every apply, forever (21 statements
|
|
232
|
+
// on the first real schema this was measured against). Same for `1.50`, `0.10`, `1e3`.
|
|
233
|
+
// Where the text cannot round-trip, fall through to `.defaultSql()`, which carries the
|
|
234
|
+
// live spelling verbatim. The exact-integer and exact-decimal cases are unchanged.
|
|
235
|
+
if (/^-?\d+(\.\d+)?$/.test(n)) return String(Number(n)) === n ? `.default(${n})` : null;
|
|
209
236
|
return null;
|
|
210
237
|
}
|
|
211
238
|
|
|
@@ -403,7 +430,7 @@ function renderConstraintsBlock(table: TableSchema, checks: CheckConstraint[], k
|
|
|
403
430
|
}
|
|
404
431
|
|
|
405
432
|
/** One `export const X = defineModel(...)` block for a table. */
|
|
406
|
-
export function renderModelBlock(table: TableSchema, known: Set<string>, enums: Map<string, string[]> = new Map(), abilities = 'commented'): string {
|
|
433
|
+
export function renderModelBlock(table: TableSchema, known: Set<string>, enums: Map<string, string[]> = new Map(), abilities = 'commented', liveAuthz?: Map<string, TableContract>): string {
|
|
407
434
|
// A CHECK that reverses to a single field's .validate() is rendered on the field (ergonomic);
|
|
408
435
|
// the rest stay table-level check(). Both round-trip — this only chooses the nicer form.
|
|
409
436
|
const validates = new Map<string, string>();
|
|
@@ -420,7 +447,7 @@ export function renderModelBlock(table: TableSchema, known: Set<string>, enums:
|
|
|
420
447
|
// The authz decision renders FIRST — before fields — because it is the first thing a
|
|
421
448
|
// reviewer must resolve about a model (and where the field-report consumer's codemod
|
|
422
449
|
// put it, proving the position is mechanical-edit-friendly).
|
|
423
|
-
const stanza = abilitiesStanza(abilities);
|
|
450
|
+
const stanza = abilitiesStanza(abilities, table, liveAuthz);
|
|
424
451
|
|
|
425
452
|
return `export const ${modelVarName(table.table)} = defineModel('${bareName(table.table)}', {\n${stanza}\n fields: {\n${fields}\n },${constraints}\n});`;
|
|
426
453
|
}
|
|
@@ -477,7 +504,7 @@ export function renderModelSource(snapshot: SchemaSnapshot, opts: RenderOptions
|
|
|
477
504
|
const known = new Set(tables.map((t) => bareName(t.table)));
|
|
478
505
|
const enums = new Map((snapshot.enums ?? []).map((e) => [e.name, e.values]));
|
|
479
506
|
|
|
480
|
-
const blocks = tables.map((t) => renderModelBlock(t, known, enums, opts.abilities ?? 'commented'));
|
|
507
|
+
const blocks = tables.map((t) => renderModelBlock(t, known, enums, opts.abilities ?? 'commented', opts.liveAuthz));
|
|
481
508
|
// The derived layer (B5): sequences + views/matviews/functions, after the models they
|
|
482
509
|
// reference, before the module wrapper that composes all three.
|
|
483
510
|
if (opts.derived?.block) blocks.push(opts.derived.block);
|
|
@@ -535,7 +562,7 @@ export function renderModelFiles(snapshot: SchemaSnapshot, opts: RenderOptions =
|
|
|
535
562
|
const enums = new Map((snapshot.enums ?? []).map((e) => [e.name, e.values]));
|
|
536
563
|
|
|
537
564
|
const files: RenderedModelFile[] = tables.map((t) => {
|
|
538
|
-
const block = renderModelBlock(t, known, enums, opts.abilities ?? 'commented');
|
|
565
|
+
const block = renderModelBlock(t, known, enums, opts.abilities ?? 'commented', opts.liveAuthz);
|
|
539
566
|
const crossImports = referencedTables(t, known).map(
|
|
540
567
|
(target) => `import { ${modelVarName(target)} } from './${bareName(target).replace(/_/g, '-')}';`,
|
|
541
568
|
);
|