@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.
- package/bin/everystack.mjs +52 -0
- package/package.json +5 -4
- 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 +112 -40
- 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 +122 -21
- package/src/cli/commands/db.ts +11 -0
- package/src/cli/db-source.ts +67 -16
- 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/index.ts +6 -5
- package/src/cli/model-render.ts +26 -5
- package/src/cli/pg-argtypes.ts +52 -0
- package/src/cli/swap-heartbeat.ts +106 -17
- package/src/cli/swap-snapshot.ts +260 -0
package/src/cli/db-source.ts
CHANGED
|
@@ -70,13 +70,10 @@ export interface UrlRunner {
|
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
/**
|
|
73
|
-
*
|
|
74
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
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/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> --
|
|
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
|
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:',
|
|
@@ -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
|
);
|