@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.
@@ -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
- declaredIdentities: declaredDerivedObjects.map((o) => o.identity),
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
- declaredIdentities: declaredDerivedObjects.map((o) => o.identity),
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 }),
@@ -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
 
@@ -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 ${quoteQualified(action.identity)}`);
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
- const identity = `${row.schema}.${row.name}`;
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',
@@ -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, toCamelCase(o.schema === 'public' ? o.name : `${o.schema}_${o.name}`)]));
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
- const declaredName = (o: LiveObject): string => (o.schema === 'public' ? o.name : o.identity);
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
- warnings.push(`${o.identity}: live grants (${grantText}) are not expressible as relation abilities (read-only, role-shaped) — object skipped; migrate it by hand.`);
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(declaredName(o))}, {`,
429
+ `export const ${varName} = defineSql(${tsString(sqlName)}, {`,
370
430
  ` kind: 'function',`,
371
- ` drop: sql\`DROP FUNCTION IF EXISTS ${o.identity.startsWith('public.') ? o.name : o.identity}\`,`,
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
  '',
@@ -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
- if (/^-?\d+(\.\d+)?$/.test(n)) return `.default(${n})`;
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
  );