@everystack/cli 0.4.36 → 0.4.39

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.
@@ -70,13 +70,10 @@ export interface UrlRunner {
70
70
  }
71
71
 
72
72
  /**
73
- * A QueryRunner over a direct postgres.js connection. One connection is enough —
74
- * introspection is a handful of sequential catalog queries.
73
+ * Load the postgres.js driver, or explain how to get it. Shared by every direct-connection
74
+ * runner below so the missing-driver instructions can never drift between them.
75
75
  */
76
- export async function createUrlRunner(
77
- url: string,
78
- load: () => Promise<any> = () => import('postgres'),
79
- ): Promise<UrlRunner> {
76
+ async function loadPostgresDriver(load: () => Promise<any>): Promise<any> {
80
77
  let mod: any;
81
78
  try {
82
79
  mod = await load();
@@ -85,7 +82,18 @@ export async function createUrlRunner(
85
82
  'The direct-connection path needs the "postgres" driver. It ships with @everystack/server; in a project without it: pnpm add -D postgres',
86
83
  );
87
84
  }
88
- const postgres = mod.default ?? mod;
85
+ return mod.default ?? mod;
86
+ }
87
+
88
+ /**
89
+ * A QueryRunner over a direct postgres.js connection. One connection is enough —
90
+ * introspection is a handful of sequential catalog queries.
91
+ */
92
+ export async function createUrlRunner(
93
+ url: string,
94
+ load: () => Promise<any> = () => import('postgres'),
95
+ ): Promise<UrlRunner> {
96
+ const postgres = await loadPostgresDriver(load);
89
97
  // max_lifetime: null — the driver's default recycles a connection after a random 30–60
90
98
  // minutes, resolving the in-flight query and THEN killing the session. Under reconcile's
91
99
  // BEGIN-across-calls transaction that is a silent session swap mid-batch (the reconcile
@@ -114,15 +122,7 @@ export async function createUrlPipelineRunner(
114
122
  url: string,
115
123
  load: () => Promise<any> = () => import('postgres'),
116
124
  ): Promise<UrlPipelineRunner> {
117
- let mod: any;
118
- try {
119
- mod = await load();
120
- } catch {
121
- throw new Error(
122
- 'The direct-connection path needs the "postgres" driver. It ships with @everystack/server; in a project without it: pnpm add -D postgres',
123
- );
124
- }
125
- const postgres = mod.default ?? mod;
125
+ const postgres = await loadPostgresDriver(load);
126
126
  const sql = postgres(url, { max: 1, onnotice: () => {}, ...sslDefaults(url) });
127
127
  return {
128
128
  query: async (query: string) => Array.from(await sql.unsafe(query)),
@@ -132,6 +132,57 @@ export async function createUrlPipelineRunner(
132
132
  };
133
133
  }
134
134
 
135
+ export interface UrlProbeRunner {
136
+ /** Read-only introspection, for the contract pull/diff. */
137
+ runner: QueryRunner;
138
+ /** The self-reverting red-team probe. See `probe` below. */
139
+ probe: (setup: string, read: string) => Promise<any[]>;
140
+ /** Close the client so the process can exit cleanly. */
141
+ end: () => Promise<void>;
142
+ }
143
+
144
+ /** Thrown to abort the probe transaction; never escapes `probe`. */
145
+ const PROBE_ROLLBACK = Symbol('authz_probe_rollback');
146
+
147
+ /**
148
+ * The direct-connection twin of the server's `db:authz:probe` action
149
+ * (`@everystack/server` plugin.ts) — the local venue for `db:authz:test` / `db:authz:owner`.
150
+ *
151
+ * The probe SQL must WRITE to test INSERT/UPDATE/DELETE privileges, so the only thing
152
+ * standing between a red-team run and a mutated developer database is the rollback. It is
153
+ * therefore unconditional: the transaction body always throws `PROBE_ROLLBACK` after
154
+ * reading the outcome rows, so postgres.js aborts it on every path — success included.
155
+ * There is no code path that commits. `SET ROLE` is transactional (its effect disappears
156
+ * when the transaction aborts), so the session's role is restored by the same rollback.
157
+ *
158
+ * `max: 1` is load-bearing, not tuning: the probe's SET ROLE / savepoint state only makes
159
+ * sense on the single connection that ran the setup.
160
+ */
161
+ export async function createUrlProbeRunner(
162
+ url: string,
163
+ load: () => Promise<any> = () => import('postgres'),
164
+ ): Promise<UrlProbeRunner> {
165
+ const postgres = await loadPostgresDriver(load);
166
+ const sql = postgres(url, { max: 1, max_lifetime: null, onnotice: () => {}, ...sslDefaults(url) });
167
+ return {
168
+ runner: async (query: string) => Array.from(await sql.unsafe(query)),
169
+ probe: async (setup: string, read: string) => {
170
+ let rows: any[] = [];
171
+ try {
172
+ await sql.begin(async (tx: any) => {
173
+ await tx.unsafe(setup);
174
+ rows = Array.from(await tx.unsafe(read));
175
+ throw PROBE_ROLLBACK; // discard every probe write — unconditional
176
+ });
177
+ } catch (err: unknown) {
178
+ if (err !== PROBE_ROLLBACK) throw err;
179
+ }
180
+ return rows;
181
+ },
182
+ end: () => sql.end({ timeout: 5 }),
183
+ };
184
+ }
185
+
135
186
  /**
136
187
  * Managed Postgres (RDS / Supabase / Neon) requires TLS — a direct connection without it
137
188
  * is rejected with pg_hba "no encryption". Default `ssl: 'require'` for remote hosts so the
@@ -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
  '',
package/src/cli/index.ts CHANGED
@@ -373,7 +373,7 @@ Usage:
373
373
  everystack db:restore --from <id> [--stage <name>] --confirm Restore a backup INTO the stage's DB (DESTRUCTIVE)
374
374
  everystack db:backup:download <id> [--stage <name>] Presigned URL to download a backup's dump (valid 1h)
375
375
  everystack db:export --schema <name> [--stage <name> | --database-url <url> [--out <file.dump>]] [--models <barrel>] Schema-scoped pg_dump artifact, stamped with the DECLARED schema fingerprint (the canonical-sync export; db:swap gates on that stamp). --stage dumps the stage's private DB via the ops Lambda → S3; --database-url (explicit flag, never the env) dumps a reachable DB to a local .dump + .meta.json — the build-locally → publish → swap on-ramp
376
- everystack db:swap --schema <name> --database-url <url> --from <artifact.dump> [--fingerprint <hash>] Land a schema artifact atomically: fingerprint gate → restore into <schema>_incoming (COPY-safe rewrite) → one txn (drop+rename+recreate app→schema FKs, re-apply authz) → verify → drop retiring. Refresh-free; app.* untouched. DESTRUCTIVE (--stage/--direct ops venue rides stage-write-lanes)
376
+ everystack db:swap --schema <name> --from <artifact.dump | artifact-id> [--stage <name> --direct | --database-url <url>] --confirm [--fingerprint <hash>] [--snapshot physical|logical|none] [--snapshot-ref <id>] [--rebuild-derived] [--dump-build <file.json>] Land a schema artifact atomically: fingerprint gate → pre-flight refusals → CONFIRMED snapshot → restore into <schema>_incoming (COPY-safe rewrite) → build the paired derived layer → one txn (drop+rename+recreate app→schema FKs, re-apply authz + schema USAGE) → assertions → drop retiring. Refresh-free; app.* untouched; the derived layer is never absent. DESTRUCTIVE. The venue is EXPLICIT — DATABASE_URL in the env is refused, and --stage requires --direct (a multi-GB restore exceeds the ops-Lambda 900s clock). The rollback point defaults to a PHYSICAL RDS snapshot on a stage that exposes databaseInstanceId (no locks, no pg_dump contending with the restore) and a WAITED logical db:backup otherwise; a bare --database-url refuses without --snapshot-ref <id> or --snapshot none. docs/schema-swap.md
377
377
  everystack db:generate [--stage <name> | --database-url <url>] [--name <label>] [--models db/models/index.ts] [--schema-out db/schema.generated.ts] [--allow-drops] [--apply] [--dry-run] Diff models vs the live DB → next migration file, or with --apply execute it directly (one transaction, schema_log recorded, verified by re-diff — no drizzle folder needed; direct connection only; DROPs held back unless --allow-drops). --dry-run prints the edge and writes NOTHING (no migration, no journal entry, no schema refresh) — the preview verb; db:diff computes a models-vs-models edge with no database at all. The resolved --schema-out is recorded in the migration journal: later flag-less runs reuse it (flag > recorded > default), a differing flag updates the record and says so
378
378
  everystack db:pull [--stage <name> | --database-url <url>] [--schema public] [--out <dir | file.ts>] [--derived-out <file.ts>] [--abilities public-read] Introspect the live DB → render field() Models (the brownfield on-ramp). --out <dir> writes one file per model + index.ts (the default shape); --out <file.ts> writes a single module; stdout otherwise. --derived-out <file.ts> extracts the derived layer (descriptors + sequences) as its own self-contained module — alone it leaves the models untouched (the hand-maintained-barrel splice); with --out the models render omits the now-external derived layer. Every model scaffolds its authz decision as comments (db:check fails until authored); --abilities public-read stamps the common stanza (public read, admin write) uncommented — explicit generated code, never a runtime default. --matviews-as-tables renders every matview as defineMaterializedTable with INTROSPECTED fields (the canonical-sync flip: a pipeline-owned table everystack migrates but never refreshes) — names land in an exported materializedTables array to spread into your models; fields come back nullable/unkeyed (matviews carry no PK/NOT NULL) — tighten on review; add --suggest-keys to probe the LIVE rows for functionally-unique columns (one scan per matview) and surface each as a commented .primaryKey() suggestion. docs/derived-objects.md#flipping-a-matview-to-a-materialized-table---matviews-as-tables
379
379
  Both introspect via the deployed ops Lambda by default; --database-url (or an inherited DATABASE_URL) connects directly — for a schema that exists only on a local Postgres.
@@ -394,10 +394,11 @@ Usage:
394
394
  everystack db:template:refresh [--database-url <url>] [--models <barrel>] [--seed "<cmd>"] [--no-seed] (Re)build the dev template <base>_tpl FROM THE DECLARED STATE (both layers, fingerprint MATCH bar) + seed-as-code (the app's db:seed script, run with DATABASE_URL pointed at the template; --seed overrides). All or nothing: a failed build/seed leaves NO template. Never a data copy
395
395
  everystack db:branch [--list | --drop --confirm | --prune --confirm] [--database-url <url>] Mint (or find) the current git branch's database from the template (CREATE DATABASE … TEMPLATE — schema, authz, derived layer, and seed rows inherited), print its DATABASE_URL, then db:sync evolves it with the checkout. --list maps every branch DB to its branch; --prune drops the ones whose branch is gone (never guesses: unknown mappings are kept)
396
396
  everystack db:fork --from-stage <src> --stage <target> --confirm [--backup <id>] Fork one DEPLOYED stage's database into another: back up the source (or reuse --backup <id>), presign the dump (the operator's credentials ARE the cross-stage authorization; expires in 1h), restore into the target via its ops Lambda. Production is never a target (that's db:restore); forking FROM production warns about PII; the branch's schema edge then lands via db:plan → db:apply (descent composes). Teardown: sst remove --stage <target>
397
- everystack db:authz:pull [--stage <name>] [--dir authz] Introspect live authz (rls/grants/policies/secdef) → reviewable contract files
398
- everystack db:authz:diff [--stage <name>] [--dir authz] Validate the live DB against the committed contract (non-zero exit on drift)
399
- everystack db:authz:test [--stage <name>] [--dir authz] Red-team enforcement: SET ROLE + attempt per role/table/command (non-zero exit on a hole)
400
- everystack db:authz:owner [--stage <name>] [--models db/models/index.ts] Red-team owner isolation: two JWT identities per owner-scoped table — catches IDOR (non-zero exit on a leak)
397
+ everystack db:authz:pull [--stage <name> | --database-url <url>] [--dir authz] Introspect live authz (rls/grants/policies/secdef) → reviewable contract files
398
+ everystack db:authz:diff [--stage <name> | --database-url <url>] [--dir authz] Validate the live DB against the committed contract (non-zero exit on drift)
399
+ everystack db:authz:test [--stage <name> | --database-url <url>] [--dir authz] Red-team enforcement: SET ROLE + attempt per role/table/command (non-zero exit on a hole)
400
+ everystack db:authz:owner [--stage <name> | --database-url <url>] [--models db/models/index.ts] Red-team owner isolation: two JWT identities per owner-scoped table — catches IDOR (non-zero exit on a leak)
401
+ All four accept a DIRECT venue (--database-url, or an inherited ADMIN_DATABASE_URL/DATABASE_URL) so a brownfield authz migration can be rehearsed against a local database instead of a deployed stage. Same SQL, same evaluation, same verdict — every run names the venue it judged. The red-team probes WRITE to test INSERT/UPDATE/DELETE privileges and are always rolled back.
401
402
  everystack db:authz:report [--dir authz] Render the committed contract as a human-readable authorization review (no DB)
402
403
  everystack console --stage <name> [--sandbox] Interactive REPL on deployed Lambda
403
404
  everystack status [--stage <name>] [--hours <n>] Platform health: CDN, Lambda, rollup summary
@@ -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:',
@@ -403,7 +424,7 @@ function renderConstraintsBlock(table: TableSchema, checks: CheckConstraint[], k
403
424
  }
404
425
 
405
426
  /** 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 {
427
+ export function renderModelBlock(table: TableSchema, known: Set<string>, enums: Map<string, string[]> = new Map(), abilities = 'commented', liveAuthz?: Map<string, TableContract>): string {
407
428
  // A CHECK that reverses to a single field's .validate() is rendered on the field (ergonomic);
408
429
  // the rest stay table-level check(). Both round-trip — this only chooses the nicer form.
409
430
  const validates = new Map<string, string>();
@@ -420,7 +441,7 @@ export function renderModelBlock(table: TableSchema, known: Set<string>, enums:
420
441
  // The authz decision renders FIRST — before fields — because it is the first thing a
421
442
  // reviewer must resolve about a model (and where the field-report consumer's codemod
422
443
  // put it, proving the position is mechanical-edit-friendly).
423
- const stanza = abilitiesStanza(abilities);
444
+ const stanza = abilitiesStanza(abilities, table, liveAuthz);
424
445
 
425
446
  return `export const ${modelVarName(table.table)} = defineModel('${bareName(table.table)}', {\n${stanza}\n fields: {\n${fields}\n },${constraints}\n});`;
426
447
  }
@@ -477,7 +498,7 @@ export function renderModelSource(snapshot: SchemaSnapshot, opts: RenderOptions
477
498
  const known = new Set(tables.map((t) => bareName(t.table)));
478
499
  const enums = new Map((snapshot.enums ?? []).map((e) => [e.name, e.values]));
479
500
 
480
- const blocks = tables.map((t) => renderModelBlock(t, known, enums, opts.abilities ?? 'commented'));
501
+ const blocks = tables.map((t) => renderModelBlock(t, known, enums, opts.abilities ?? 'commented', opts.liveAuthz));
481
502
  // The derived layer (B5): sequences + views/matviews/functions, after the models they
482
503
  // reference, before the module wrapper that composes all three.
483
504
  if (opts.derived?.block) blocks.push(opts.derived.block);
@@ -535,7 +556,7 @@ export function renderModelFiles(snapshot: SchemaSnapshot, opts: RenderOptions =
535
556
  const enums = new Map((snapshot.enums ?? []).map((e) => [e.name, e.values]));
536
557
 
537
558
  const files: RenderedModelFile[] = tables.map((t) => {
538
- const block = renderModelBlock(t, known, enums, opts.abilities ?? 'commented');
559
+ const block = renderModelBlock(t, known, enums, opts.abilities ?? 'commented', opts.liveAuthz);
539
560
  const crossImports = referencedTables(t, known).map(
540
561
  (target) => `import { ${modelVarName(target)} } from './${bareName(target).replace(/_/g, '-')}';`,
541
562
  );