@everystack/cli 0.2.39 → 0.3.3
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/README.md +28 -5
- package/package.json +12 -2
- package/src/cli/authz-compile.ts +364 -0
- package/src/cli/authz-contract-io.ts +92 -0
- package/src/cli/authz-contract.ts +589 -0
- package/src/cli/authz-owner-probe.ts +218 -0
- package/src/cli/authz-reconcile.ts +152 -0
- package/src/cli/authz-redteam.ts +251 -0
- package/src/cli/authz-render.ts +248 -0
- package/src/cli/aws.ts +100 -0
- package/src/cli/backup.ts +99 -0
- package/src/cli/commands/db-authz.ts +300 -0
- package/src/cli/commands/db-backup.ts +218 -0
- package/src/cli/commands/db-generate.ts +211 -0
- package/src/cli/commands/db-pull.ts +83 -0
- package/src/cli/commands/db-snapshot.ts +113 -0
- package/src/cli/commands/deploy.ts +46 -0
- package/src/cli/config.ts +8 -0
- package/src/cli/index.ts +68 -0
- package/src/cli/migration-compile.ts +133 -0
- package/src/cli/migration-generate.ts +178 -0
- package/src/cli/model-api.ts +36 -0
- package/src/cli/model-render.ts +262 -0
- package/src/cli/pg-ident.ts +59 -0
- package/src/cli/rds-snapshot.ts +47 -0
- package/src/cli/schema-compile.ts +496 -0
- package/src/cli/schema-diff.ts +608 -0
- package/src/cli/schema-introspect.ts +425 -0
- package/src/cli/schema-source.ts +416 -0
- package/src/cli/security-audit.ts +7 -0
- package/src/cli/security-catalog.ts +16 -1
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one generator — compose the Model into a single ordered migration.
|
|
3
|
+
*
|
|
4
|
+
* This is the unified-migration thesis made concrete: a set of Models compiles to
|
|
5
|
+
* ONE ordered list of SQL statements covering *both* layers, in dependency order:
|
|
6
|
+
*
|
|
7
|
+
* 1. CREATE TABLE … (data — fields → DDL + CHECK)
|
|
8
|
+
* 2. ALTER TABLE … FOREIGN KEY (data — references, after every table exists)
|
|
9
|
+
* 3. ENABLE/FORCE RLS · policies · grants (authz — abilities, after the
|
|
10
|
+
* columns they reference exist)
|
|
11
|
+
*
|
|
12
|
+
* It is not two pipelines: the authz statements reference columns the DDL just
|
|
13
|
+
* created, so they live in the same ordered migration. This composes the four
|
|
14
|
+
* compilers already built — compileCreateTable + foreignKeySql (data) and
|
|
15
|
+
* compileTableContract + emitReconcileSql (authz) — with no new SQL emission.
|
|
16
|
+
*
|
|
17
|
+
* This is the greenfield form (empty database → the whole schema). The brownfield
|
|
18
|
+
* form diffs against an introspected snapshot instead of the empty baseline; that
|
|
19
|
+
* lands with whole-DB introspection. The emission is identical either way.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { ModelDescriptor, Module } from '@everystack/model';
|
|
23
|
+
import type { AuthzContract, TableContract } from './authz-contract.js';
|
|
24
|
+
import { compileCreateTable, compileEnums, foreignKeySql, modelConstraintSpecs, qualifiedTable, type CompileTableOptions } from './schema-compile.js';
|
|
25
|
+
import { compileTableContract } from './authz-compile.js';
|
|
26
|
+
import { emitReconcileSql } from './authz-reconcile.js';
|
|
27
|
+
import { emitSchemaSql, type SchemaChange } from './schema-diff.js';
|
|
28
|
+
|
|
29
|
+
/** The empty (not-yet-created) authz state for a table — the greenfield baseline. */
|
|
30
|
+
function emptyTable(table: string): TableContract {
|
|
31
|
+
return { table, rls: { enabled: false, forced: false }, grants: {}, policies: [] };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Compile a set of Models into one ordered migration (greenfield: empty DB → full
|
|
36
|
+
* schema). Tables, then foreign keys, then authz — the order a fresh database must
|
|
37
|
+
* apply them in.
|
|
38
|
+
*/
|
|
39
|
+
export function compileMigration(models: ModelDescriptor[], opts: CompileTableOptions = {}): string[] {
|
|
40
|
+
const sql: string[] = [];
|
|
41
|
+
const schemaOf = (m: ModelDescriptor): string => m.schema ?? 'public';
|
|
42
|
+
|
|
43
|
+
// 0a. Non-public schemas — `CREATE SCHEMA` for each distinct one a model lives in,
|
|
44
|
+
// before any table is created in it. public is the implicit default, never emitted.
|
|
45
|
+
const schemas = [...new Set(models.map(schemaOf))].filter((s) => s !== 'public').sort();
|
|
46
|
+
sql.push(...schemas.map((s) => `CREATE SCHEMA IF NOT EXISTS "${s}";`));
|
|
47
|
+
|
|
48
|
+
// The authz contracts — computed once, used for the schema USAGE grants here and the
|
|
49
|
+
// table-level reconcile below.
|
|
50
|
+
const contracts = models.map((m) => compileTableContract(m));
|
|
51
|
+
|
|
52
|
+
// 0a-bis. Schema USAGE — a role can't reach a table in a non-public schema without USAGE on
|
|
53
|
+
// it, so a table/column grant there is dead without this. Derived from the grants: every
|
|
54
|
+
// role that holds any privilege on a table in a non-public schema gets USAGE on that schema
|
|
55
|
+
// (public USAGE is granted to PUBLIC by default, so it is never emitted). This replaces the
|
|
56
|
+
// hand-written `GRANT USAGE ON SCHEMA auth/ops/dist …` the old bootstrap carried.
|
|
57
|
+
for (const schema of schemas) {
|
|
58
|
+
const roles = new Set<string>();
|
|
59
|
+
for (const c of contracts) {
|
|
60
|
+
if (!c.table.startsWith(`${schema}.`)) continue;
|
|
61
|
+
for (const r of Object.keys(c.grants)) roles.add(r);
|
|
62
|
+
for (const r of Object.keys(c.columnGrants ?? {})) roles.add(r);
|
|
63
|
+
}
|
|
64
|
+
if (roles.size) {
|
|
65
|
+
const targets = [...roles].sort().map((r) => (r.toUpperCase() === 'PUBLIC' ? 'PUBLIC' : r)).join(', ');
|
|
66
|
+
sql.push(`GRANT USAGE ON SCHEMA "${schema}" TO ${targets};`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// 0b. Enum types — `CREATE TYPE … AS ENUM`, before any table/column references them.
|
|
71
|
+
const enumCreates: SchemaChange[] = compileEnums(models).map((e) => ({ kind: 'createType', name: e.name, values: e.values }));
|
|
72
|
+
sql.push(...emitSchemaSql(enumCreates));
|
|
73
|
+
|
|
74
|
+
// 1. Tables (with columns, CHECKs, single/composite PK, uniques).
|
|
75
|
+
for (const model of models) sql.push(compileCreateTable(model, opts));
|
|
76
|
+
|
|
77
|
+
// 2. Foreign keys — after every table exists.
|
|
78
|
+
for (const model of models) sql.push(...foreignKeySql(model));
|
|
79
|
+
|
|
80
|
+
// 2b. Standalone indexes — after the columns they cover exist. The ON clause is the
|
|
81
|
+
// schema-qualified table, so a non-public index targets the right table.
|
|
82
|
+
const indexCreates: SchemaChange[] = [];
|
|
83
|
+
for (const model of models) {
|
|
84
|
+
for (const ix of modelConstraintSpecs(model).indexes) {
|
|
85
|
+
indexCreates.push({ kind: 'createIndex', table: qualifiedTable(model), name: ix.name, columns: ix.columns, unique: ix.unique, ...(ix.where ? { where: ix.where } : {}) });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
sql.push(...emitSchemaSql(indexCreates));
|
|
89
|
+
|
|
90
|
+
// 3. Authz — RLS + policies + grants, after the columns they reference. Reuse
|
|
91
|
+
// the reconcile emitter against the empty baseline: every policy/grant is a
|
|
92
|
+
// pure creation, in the same dependency-correct order reconcile already uses.
|
|
93
|
+
// The baseline keys on each model's resolved schema, so a non-public table
|
|
94
|
+
// diffs against its own empty state, not a phantom public one.
|
|
95
|
+
const desired: AuthzContract = { tables: contracts, functions: [] };
|
|
96
|
+
const baseline: AuthzContract = { tables: models.map((m) => emptyTable(`${schemaOf(m)}.${m.table}`)), functions: [] };
|
|
97
|
+
sql.push(...emitReconcileSql(desired, baseline));
|
|
98
|
+
|
|
99
|
+
return sql;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Compile a set of Modules into one COMPLETE greenfield migration — the whole deploy, no
|
|
104
|
+
* hand-written bootstrap left:
|
|
105
|
+
*
|
|
106
|
+
* 1. CREATE EXTENSION (deduped, sorted) — the bootstrap a package's SQL needs
|
|
107
|
+
* 2. compileMigration(flattened models) — schemas, tables, FKs, indexes, RLS, authz
|
|
108
|
+
* 3. package functions/triggers (each thunk) — imperative package SQL, AFTER the tables
|
|
109
|
+
*
|
|
110
|
+
* Roles are NOT emitted — they are cluster-global (`db:provision`), and the GRANTs in step 2
|
|
111
|
+
* assume they exist (as the dogfood pre-creates them). `compileMigration` is left untouched as
|
|
112
|
+
* the schema+authz core the contract round-trips through; the functions are emitted verbatim
|
|
113
|
+
* here, outside that round-trip (they are imperative, package-owned — not modeled).
|
|
114
|
+
*/
|
|
115
|
+
export function compileModuleMigration(modules: Module[], opts: CompileTableOptions = {}): string[] {
|
|
116
|
+
const sql: string[] = [];
|
|
117
|
+
|
|
118
|
+
// 1. Extensions — deduped + sorted, quoted so a hyphenated name (`uuid-ossp`) is valid.
|
|
119
|
+
const extensions = [...new Set(modules.flatMap((m) => m.extensions))].sort();
|
|
120
|
+
sql.push(...extensions.map((e) => `CREATE EXTENSION IF NOT EXISTS "${e}";`));
|
|
121
|
+
|
|
122
|
+
// 2. Schema + authz for every modeled table.
|
|
123
|
+
sql.push(...compileMigration(modules.flatMap((m) => m.models), opts));
|
|
124
|
+
|
|
125
|
+
// 3. Package SQL — functions + triggers, after the tables they reference. Each thunk's
|
|
126
|
+
// output is a self-contained multi-statement block applied as one unit.
|
|
127
|
+
for (const fn of modules.flatMap((m) => m.functions)) {
|
|
128
|
+
const text = fn().trim();
|
|
129
|
+
if (text) sql.push(text);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return sql;
|
|
133
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `db:generate`'s pure core — Models + live snapshot → a migration file.
|
|
3
|
+
*
|
|
4
|
+
* The brownfield generator, composed from the pieces already built and proven:
|
|
5
|
+
*
|
|
6
|
+
* compileTableSchema + compileRenames (the desired side, from the Models)
|
|
7
|
+
* → diffSchema (vs the introspected `current`)
|
|
8
|
+
* → emitSchemaSql (the ALTER/CREATE/RENAME DDL)
|
|
9
|
+
*
|
|
10
|
+
* A whole *new* table renders through the greenfield `compileCreateTable` (byte-identical
|
|
11
|
+
* to what the app ships) rather than the IR renderer; its foreign keys still arrive as the
|
|
12
|
+
* diff's `addConstraint` changes, so they land after every table exists.
|
|
13
|
+
*
|
|
14
|
+
* Everything here is pure and deterministic — the clock (`when`) is injected, never read —
|
|
15
|
+
* so the command shell (commands/db-generate.ts) is a thin layer of IO over it.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { ModelDescriptor } from '@everystack/model';
|
|
19
|
+
import type { SchemaSnapshot } from './schema-introspect.js';
|
|
20
|
+
import type { AuthzContract } from './authz-contract.js';
|
|
21
|
+
import { compileTableSchema, compileRenames, compileCreateTable, compileEnums } from './schema-compile.js';
|
|
22
|
+
import { compileTableContract } from './authz-compile.js';
|
|
23
|
+
import { emitReconcileSql } from './authz-reconcile.js';
|
|
24
|
+
import { diffSchema, emitSchemaSql, type SchemaChange } from './schema-diff.js';
|
|
25
|
+
|
|
26
|
+
export interface GenerateOptions {
|
|
27
|
+
/** The Postgres schema the Models live in. Default: `public`. */
|
|
28
|
+
schema?: string;
|
|
29
|
+
/**
|
|
30
|
+
* Emit real `DROP COLUMN`/`DROP CONSTRAINT`/`DROP TABLE` for things present in the
|
|
31
|
+
* database but absent from the Models. Default `false` — those are held back as
|
|
32
|
+
* commented notices (see {@link HELD_DROP_PREFIX}), so a partial or lossy Model set
|
|
33
|
+
* never silently loses data. Destruction needs intent, the same rule renames are built on.
|
|
34
|
+
*/
|
|
35
|
+
allowDrops?: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* The live authorization contract (`introspectContract`). When provided, the migration
|
|
38
|
+
* carries the authz layer too — the Models' `abilities` compiled to RLS/policies/grants,
|
|
39
|
+
* diffed against this live state, appended after the data phase as one ordered file (the
|
|
40
|
+
* unified-migration thesis: declare once, generate both layers). Omitted → data-only, the
|
|
41
|
+
* backward-compatible default. Authz drops (`DROP POLICY`/`REVOKE`) are data-safe, so they
|
|
42
|
+
* are emitted regardless of `allowDrops` — `allowDrops` gates only data destruction.
|
|
43
|
+
*/
|
|
44
|
+
liveAuthz?: AuthzContract;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The comment prefix a held-back destructive change carries (the command greps it to count them). */
|
|
48
|
+
export const HELD_DROP_PREFIX = '-- DROP held back';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A destructive change that removes data or an integrity guard with no re-creation —
|
|
52
|
+
* the set held back unless `allowDrops`. A constraint *replace* (a `dropConstraint`
|
|
53
|
+
* paired with an `addConstraint` of the same name) is NOT a removal: it loses no data
|
|
54
|
+
* and its add depends on the drop, so it is kept intact.
|
|
55
|
+
*/
|
|
56
|
+
function isRemoval(change: SchemaChange, replacedConstraints: Set<string>): boolean {
|
|
57
|
+
if (change.kind === 'dropColumn' || change.kind === 'dropTable' || change.kind === 'dropType' || change.kind === 'dropIndex') return true;
|
|
58
|
+
if (change.kind === 'dropConstraint') return !replacedConstraints.has(`${change.table}::${change.name}`);
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Comment out a destructive statement, showing the exact DDL to run by hand or with `--allow-drops`. */
|
|
63
|
+
function holdDrop(sql: string): string {
|
|
64
|
+
return `${HELD_DROP_PREFIX} (destructive — re-run db:generate with --allow-drops to emit it, or run it by hand):\n-- ${sql}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Tables present in the live database that no model declares. `db:generate` leaves these
|
|
69
|
+
* untouched — they belong to another concern (auth, jobs, observability all ship their own
|
|
70
|
+
* migrations), and dropping a table must be deliberate, never a side effect of a missing
|
|
71
|
+
* model. The command reports the count so the omission is visible, not silent.
|
|
72
|
+
*/
|
|
73
|
+
export function unmodeledTables(models: ModelDescriptor[], current: SchemaSnapshot, opts: GenerateOptions = {}): string[] {
|
|
74
|
+
const schema = opts.schema ?? 'public';
|
|
75
|
+
const declared = new Set(models.map((m) => `${schema}.${m.table}`));
|
|
76
|
+
return current.tables.map((t) => t.table).filter((t) => !declared.has(t)).sort();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Compile the Models into the ordered SQL that evolves `current` to match them. Empty when
|
|
81
|
+
* the database already matches (the no-op generate).
|
|
82
|
+
*
|
|
83
|
+
* Scoped to the tables the models DECLARE: the diff never sees a table no model owns, so it
|
|
84
|
+
* cannot propose dropping one. db:generate manages your declared schema; everything else in
|
|
85
|
+
* the database (auth, jobs, observability) is left to its own migrations.
|
|
86
|
+
*/
|
|
87
|
+
export function generateMigrationSql(models: ModelDescriptor[], current: SchemaSnapshot, opts: GenerateOptions = {}): string[] {
|
|
88
|
+
const schema = opts.schema ?? 'public';
|
|
89
|
+
// A table's schema comes from the model first (package tables live in dist/ops/auth),
|
|
90
|
+
// then the call default — so one run spans schemas.
|
|
91
|
+
const schemaOf = (m: ModelDescriptor): string => m.schema ?? schema;
|
|
92
|
+
const desiredEnums = compileEnums(models);
|
|
93
|
+
const desired: SchemaSnapshot = { tables: models.map((m) => compileTableSchema(m, { schema })), enums: desiredEnums };
|
|
94
|
+
const declaredTables = new Set(desired.tables.map((t) => t.table));
|
|
95
|
+
const declaredEnums = new Set(desiredEnums.map((e) => e.name));
|
|
96
|
+
// Scope both tables and enums to what the Models declare — an undeclared table OR enum
|
|
97
|
+
// (auth, jobs, observability ship their own) is invisible to the diff, so it is never dropped.
|
|
98
|
+
const scopedCurrent: SchemaSnapshot = {
|
|
99
|
+
tables: current.tables.filter((t) => declaredTables.has(t.table)),
|
|
100
|
+
enums: (current.enums ?? []).filter((e) => declaredEnums.has(e.name)),
|
|
101
|
+
};
|
|
102
|
+
const renames = compileRenames(models, { schema });
|
|
103
|
+
const changes = diffSchema(desired, scopedCurrent, renames);
|
|
104
|
+
|
|
105
|
+
const modelByTable = new Map(models.map((m) => [`${schemaOf(m)}.${m.table}`, m]));
|
|
106
|
+
const emitted = emitSchemaSql(changes, {
|
|
107
|
+
createTable: (change) => {
|
|
108
|
+
const model = modelByTable.get(change.table);
|
|
109
|
+
if (!model) throw new Error(`db:generate: no model found for new table ${change.table}`);
|
|
110
|
+
return compileCreateTable(model);
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Non-destructive by default: a column/constraint/table in the database but absent
|
|
115
|
+
// from the Models would otherwise become a DROP — silent data loss for a partial or
|
|
116
|
+
// lossy Model set. Hold removals back as comments unless `allowDrops`. emitSchemaSql
|
|
117
|
+
// returns one statement per change, index-aligned, so the gate stays a pure
|
|
118
|
+
// post-pass — `diffSchema`/`emitSchemaSql` remain honest (they always emit the DROP).
|
|
119
|
+
let dataPhase = emitted;
|
|
120
|
+
if (!opts.allowDrops) {
|
|
121
|
+
const replacedConstraints = new Set(
|
|
122
|
+
changes
|
|
123
|
+
.filter((c): c is Extract<SchemaChange, { kind: 'addConstraint' }> => c.kind === 'addConstraint')
|
|
124
|
+
.map((c) => `${c.table}::${c.constraint.name}`),
|
|
125
|
+
);
|
|
126
|
+
dataPhase = emitted.map((sql, i) => (isRemoval(changes[i], replacedConstraints) ? holdDrop(sql) : sql));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Authz phase — appended after the data phase so a policy/grant references columns the
|
|
130
|
+
// data DDL just created. Emitted only when the live contract is supplied; the Models'
|
|
131
|
+
// `abilities` compile to the same contract shape introspection reads, so the two diff.
|
|
132
|
+
// Authz changes are data-safe (no row touches), so they are never held by `allowDrops`.
|
|
133
|
+
const authzPhase = opts.liveAuthz
|
|
134
|
+
? emitReconcileSql({ tables: models.map((m) => compileTableContract(m, { schema })), functions: [] }, opts.liveAuthz)
|
|
135
|
+
: [];
|
|
136
|
+
|
|
137
|
+
return [...dataPhase, ...authzPhase];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The marker drizzle migration files put between statements. */
|
|
141
|
+
const BREAKPOINT = '\n--> statement-breakpoint\n';
|
|
142
|
+
|
|
143
|
+
/** Join statements into a drizzle-compatible migration file body (trailing newline). */
|
|
144
|
+
export function formatMigrationFile(statements: string[]): string {
|
|
145
|
+
return statements.join(BREAKPOINT) + '\n';
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface JournalEntry {
|
|
149
|
+
idx: number;
|
|
150
|
+
version: string;
|
|
151
|
+
when: number;
|
|
152
|
+
tag: string;
|
|
153
|
+
breakpoints: boolean;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface Journal {
|
|
157
|
+
version: string;
|
|
158
|
+
dialect: string;
|
|
159
|
+
entries: JournalEntry[];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Sanitize a migration name into the snake_case the `NNNN_<tag>.sql` convention uses. */
|
|
163
|
+
function sanitizeName(name: string): string {
|
|
164
|
+
return name.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'generated';
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Plan the next migration file: its tag, filename, and the journal with the new entry
|
|
169
|
+
* appended. Pure — `when` is passed in (the command stamps it with the clock), so the same
|
|
170
|
+
* inputs always produce the same plan. The next index is one past the highest existing
|
|
171
|
+
* `idx`, matching how the runner and drizzle order migrations.
|
|
172
|
+
*/
|
|
173
|
+
export function planMigrationFile(journal: Journal, name: string, when: number): { tag: string; filename: string; journal: Journal } {
|
|
174
|
+
const idx = journal.entries.reduce((max, e) => Math.max(max, e.idx + 1), 0);
|
|
175
|
+
const tag = `${String(idx).padStart(4, '0')}_${sanitizeName(name)}`;
|
|
176
|
+
const entry: JournalEntry = { idx, version: journal.version || '7', when, tag, breakpoints: true };
|
|
177
|
+
return { tag, filename: `${tag}.sql`, journal: { ...journal, entries: [...journal.entries, entry] } };
|
|
178
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@everystack/cli/model` — the programmatic surface of the v3 Model generator and red-team.
|
|
3
|
+
*
|
|
4
|
+
* Everything the CLI commands do (`db:generate`, `db:pull`, `db:authz:*`) is pure logic over a
|
|
5
|
+
* `QueryRunner`; this barrel exposes it so a consumer can generate and VERIFY their schema in
|
|
6
|
+
* their own tests/CI without shelling out to the CLI. The reference app dogfoods exactly this
|
|
7
|
+
* surface — its real Models are round-tripped through `compileMigration` / `generateMigrationSql`
|
|
8
|
+
* and red-teamed with the owner probe, against a real Postgres, proving the generator on a real app.
|
|
9
|
+
*
|
|
10
|
+
* The two entry shapes:
|
|
11
|
+
* - GENERATE: `compileMigration(models)` (greenfield) or `generateMigrationSql(models, snapshot,
|
|
12
|
+
* { liveAuthz })` (brownfield, both layers) → ordered SQL.
|
|
13
|
+
* - VERIFY: `introspectSchema(run)` / `introspectContract(run, …)` → diff against the Models, and
|
|
14
|
+
* `buildOwnerProbeSql` / `evaluateOwnerProbe` for behavioural owner-isolation checks.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// --- data layer: compile, introspect, diff, render -------------------------
|
|
18
|
+
export * from './schema-compile.js';
|
|
19
|
+
export * from './schema-introspect.js';
|
|
20
|
+
export * from './schema-diff.js';
|
|
21
|
+
export * from './schema-source.js';
|
|
22
|
+
export * from './model-render.js';
|
|
23
|
+
export * from './migration-compile.js';
|
|
24
|
+
export * from './migration-generate.js';
|
|
25
|
+
|
|
26
|
+
// --- authz layer: contract compile/introspect/diff/reconcile ---------------
|
|
27
|
+
export * from './authz-compile.js';
|
|
28
|
+
export * from './authz-contract.js';
|
|
29
|
+
export * from './authz-reconcile.js';
|
|
30
|
+
|
|
31
|
+
// --- red-team: grant enforcement + owner isolation -------------------------
|
|
32
|
+
export * from './authz-redteam.js';
|
|
33
|
+
export * from './authz-owner-probe.js';
|
|
34
|
+
|
|
35
|
+
// --- the SECDEF function catalog the contract introspection needs -----------
|
|
36
|
+
export { FUNCTIONS_SQL, catalogFunctionToDescriptor } from './security-catalog.js';
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `db:pull`'s pure core — an introspected SchemaSnapshot back into `field()` model source.
|
|
3
|
+
*
|
|
4
|
+
* The inverse of compileTableSchema: that turns a Model into the structured schema; this
|
|
5
|
+
* turns the structured schema (read from a live database) into the TypeScript a Model is
|
|
6
|
+
* written in. The brownfield on-ramp — point at an existing database, get reviewable Models
|
|
7
|
+
* to start from.
|
|
8
|
+
*
|
|
9
|
+
* It renders what the field builder can express today (type, primary key, not-null,
|
|
10
|
+
* single-column unique, single-column references, the common defaults) and SURFACES what it
|
|
11
|
+
* can't as inline comments rather than faking it: an unmapped column type, a composite
|
|
12
|
+
* unique, a CHECK that needs a manual `.validate()`, a foreign key to a table outside the
|
|
13
|
+
* pulled set. Reversing a CHECK back to its Zod refinement is a later increment.
|
|
14
|
+
*
|
|
15
|
+
* Pure and deterministic — the command shell (commands/db-pull.ts) does the IO.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { SchemaSnapshot, TableSchema, ColumnSchema, CheckConstraint } from './schema-introspect.js';
|
|
19
|
+
import { normalizeDefault, normalizeCheck } from './schema-diff.js';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Reverse a CHECK predicate back into the `.validate(z…)` that produced it — the inverse of
|
|
23
|
+
* `fieldCheckPredicate`. Only the stable, SQL-expressible shapes that compiler emits round-trip;
|
|
24
|
+
* anything else returns null and stays a raw `check(sql\`…\`)`. Returns the single column the
|
|
25
|
+
* check is on and the Zod expression, so the renderer can hang `.validate()` on that field.
|
|
26
|
+
*/
|
|
27
|
+
export function checkToValidate(expr: string): { column: string; zod: string } | null {
|
|
28
|
+
const s = normalizeCheck(expr);
|
|
29
|
+
let m: RegExpMatchArray | null;
|
|
30
|
+
|
|
31
|
+
if ((m = s.match(/^char_length\((\w+)\) = (\d+)$/))) return { column: m[1], zod: `z.string().length(${m[2]})` };
|
|
32
|
+
if ((m = s.match(/^char_length\((\w+)\) BETWEEN (\d+) AND (\d+)$/))) return { column: m[1], zod: `z.string().min(${m[2]}).max(${m[3]})` };
|
|
33
|
+
if ((m = s.match(/^char_length\((\w+)\) >= (\d+)$/))) return { column: m[1], zod: `z.string().min(${m[2]})` };
|
|
34
|
+
if ((m = s.match(/^char_length\((\w+)\) <= (\d+)$/))) return { column: m[1], zod: `z.string().max(${m[2]})` };
|
|
35
|
+
|
|
36
|
+
if ((m = s.match(/^(\w+) BETWEEN (-?\d+(?:\.\d+)?) AND (-?\d+(?:\.\d+)?)$/))) return { column: m[1], zod: `z.number().min(${m[2]}).max(${m[3]})` };
|
|
37
|
+
|
|
38
|
+
if ((m = s.match(/^(\w+) IN \((.+)\)$/))) {
|
|
39
|
+
const vals = m[2].split(',').map((v) => v.trim());
|
|
40
|
+
if (vals.length && vals.every((v) => /^'.*'$/.test(v))) {
|
|
41
|
+
const items = vals.map((v) => v.slice(1, -1).replace(/''/g, "'"));
|
|
42
|
+
return { column: m[1], zod: `z.enum([${items.map((i) => JSON.stringify(i)).join(', ')}])` };
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// One or more numeric comparisons on the same column (`x >= 1`, `x > 1 AND x < 10`).
|
|
48
|
+
let column: string | null = null;
|
|
49
|
+
const chain: string[] = [];
|
|
50
|
+
for (const part of s.split(' AND ')) {
|
|
51
|
+
const c = part.match(/^(\w+) (>=|>|<=|<) (-?\d+(?:\.\d+)?)$/);
|
|
52
|
+
if (!c || (column && column !== c[1])) return null;
|
|
53
|
+
column = c[1];
|
|
54
|
+
chain.push(c[2] === '>=' ? `min(${c[3]})` : c[2] === '>' ? `gt(${c[3]})` : c[2] === '<=' ? `max(${c[3]})` : `lt(${c[3]})`);
|
|
55
|
+
}
|
|
56
|
+
return column ? { column, zod: `z.number().${chain.join('.')}` } : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface RenderOptions {
|
|
60
|
+
/** Only pull tables in this Postgres schema (others are framework-managed). Default: `public`. */
|
|
61
|
+
schema?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** `format_type` → the `field.*()` factory that produces it (the non-parameterized types). */
|
|
65
|
+
const FIELD_FACTORY: Record<string, string> = {
|
|
66
|
+
uuid: 'uuid',
|
|
67
|
+
text: 'text',
|
|
68
|
+
integer: 'integer',
|
|
69
|
+
bigint: 'bigint',
|
|
70
|
+
boolean: 'boolean',
|
|
71
|
+
jsonb: 'jsonb',
|
|
72
|
+
date: 'date',
|
|
73
|
+
'timestamp with time zone': 'timestamptz',
|
|
74
|
+
'timestamp without time zone': 'timestamp',
|
|
75
|
+
timestamp: 'timestamp',
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The full `field.*()` call for a `format_type` string, or null when no field maps it.
|
|
80
|
+
* Handles the parameterized types `format_type` spells out — `numeric(p,s)` and
|
|
81
|
+
* `character varying(n)` — so a pulled column round-trips back to the same DDL. A
|
|
82
|
+
* scale-0 numeric renders as the cleaner `field.numeric(p)` (identical to `(p, 0)`).
|
|
83
|
+
*/
|
|
84
|
+
export function fieldFactoryCall(type: string): string | null {
|
|
85
|
+
const direct = FIELD_FACTORY[type];
|
|
86
|
+
if (direct) return `field.${direct}()`;
|
|
87
|
+
|
|
88
|
+
const num = type.match(/^numeric(?:\((\d+),(\d+)\))?$/);
|
|
89
|
+
if (num) {
|
|
90
|
+
if (num[1] == null) return 'field.numeric()';
|
|
91
|
+
return num[2] === '0' ? `field.numeric(${num[1]})` : `field.numeric(${num[1]}, ${num[2]})`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const vc = type.match(/^character varying(?:\((\d+)\))?$/);
|
|
95
|
+
if (vc) return vc[1] != null ? `field.varchar(${vc[1]})` : 'field.varchar()';
|
|
96
|
+
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** `public.image_variants` / `image_variants` → `image_variants` (the bare table name). */
|
|
101
|
+
function bareName(table: string): string {
|
|
102
|
+
return table.replace(/^[^.]+\./, '');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A table name → its model variable: PascalCase, naively singularized. `image_variants` → `ImageVariant`. */
|
|
106
|
+
export function modelVarName(table: string): string {
|
|
107
|
+
const bare = bareName(table);
|
|
108
|
+
const singular = bare.endsWith('s') ? bare.slice(0, -1) : bare;
|
|
109
|
+
return singular.split('_').filter(Boolean).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join('');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** `author_id` → `authorId` (a SQL column to its camelCase field key — the inverse of the compiler's snake_case). */
|
|
113
|
+
function toCamelCase(name: string): string {
|
|
114
|
+
return name.replace(/_([a-z0-9])/g, (_, c) => c.toUpperCase());
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The `.default*()` modifier for a column default, or null when the expression isn't one we map. */
|
|
118
|
+
function renderDefault(expr: string): string | null {
|
|
119
|
+
const n = normalizeDefault(expr);
|
|
120
|
+
if (n == null) return null;
|
|
121
|
+
if (n === 'now()') return '.defaultNow()';
|
|
122
|
+
if (n === 'gen_random_uuid()') return '.defaultRandom()';
|
|
123
|
+
const str = n.match(/^'([\s\S]*)'$/);
|
|
124
|
+
if (str) return `.default(${JSON.stringify(str[1].replace(/''/g, "'"))})`;
|
|
125
|
+
if (n === 'true' || n === 'false') return `.default(${n})`;
|
|
126
|
+
if (/^-?\d+(\.\d+)?$/.test(n)) return `.default(${n})`;
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** A single-quoted TS string literal (the model idiom), escaping embedded quotes. */
|
|
131
|
+
function tsLiteral(value: string): string {
|
|
132
|
+
return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** One field line: ` authorId: field.uuid().notNull().references(() => Profile),` (+ any surfacing comment). */
|
|
136
|
+
function renderField(table: TableSchema, col: ColumnSchema, known: Set<string>, enums: Map<string, string[]>, validates: Map<string, string>): string {
|
|
137
|
+
// An enum column's type is the enum's name; render the explicit-name field.enum with its
|
|
138
|
+
// values (the always-explicit form round-trips the name and the value order).
|
|
139
|
+
const enumValues = enums.get(col.type);
|
|
140
|
+
const call = enumValues
|
|
141
|
+
? `field.enum(${tsLiteral(col.type)}, [${enumValues.map(tsLiteral).join(', ')}])`
|
|
142
|
+
: fieldFactoryCall(col.type);
|
|
143
|
+
let expr = call ?? 'field.text()';
|
|
144
|
+
let comment = call ? '' : ` // FIXME: column type '${col.type}' has no field mapping`;
|
|
145
|
+
|
|
146
|
+
const isPk = table.primaryKey.includes(col.name);
|
|
147
|
+
if (isPk) expr += '.primaryKey()';
|
|
148
|
+
else if (col.notNull) expr += '.notNull()';
|
|
149
|
+
|
|
150
|
+
if (table.uniques.some((u) => u.columns.length === 1 && u.columns[0] === col.name)) expr += '.unique()';
|
|
151
|
+
|
|
152
|
+
const fk = table.foreignKeys.find((f) => f.columns.length === 1 && f.columns[0] === col.name);
|
|
153
|
+
if (fk) {
|
|
154
|
+
if (known.has(bareName(fk.refTable))) {
|
|
155
|
+
const actions = [
|
|
156
|
+
fk.onDelete ? `onDelete: ${tsLiteral(fk.onDelete)}` : null,
|
|
157
|
+
fk.onUpdate ? `onUpdate: ${tsLiteral(fk.onUpdate)}` : null,
|
|
158
|
+
].filter(Boolean);
|
|
159
|
+
const opts = actions.length ? `, { ${actions.join(', ')} }` : '';
|
|
160
|
+
expr += `.references(() => ${modelVarName(fk.refTable)}${opts})`;
|
|
161
|
+
} else comment += ` // FK → ${fk.refTable} (not in the pulled set)`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (col.default != null) {
|
|
165
|
+
const d = renderDefault(col.default);
|
|
166
|
+
if (d) expr += d;
|
|
167
|
+
else comment += ` // TODO: unmapped default ${col.default}`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const validate = validates.get(col.name);
|
|
171
|
+
if (validate) expr += `.validate(${validate})`;
|
|
172
|
+
|
|
173
|
+
return ` ${toCamelCase(col.name)}: ${expr},${comment}`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The `constraints: [...]` block for a table's composite uniques, checks, indexes, and
|
|
178
|
+
* multi-column foreign keys — the table-level surface a column-scoped `field()` can't carry.
|
|
179
|
+
* Single-column uniques and FKs are rendered on the field (`.unique()` / `.references()`), so
|
|
180
|
+
* only composite ones land here. A composite FK whose target isn't in the pulled set is
|
|
181
|
+
* surfaced as a comment, not faked. Returns '' when there are none.
|
|
182
|
+
*/
|
|
183
|
+
function renderConstraintsBlock(table: TableSchema, checks: CheckConstraint[], known: Set<string>): string {
|
|
184
|
+
const items: string[] = [];
|
|
185
|
+
for (const u of table.uniques) {
|
|
186
|
+
if (u.columns.length > 1) items.push(`unique(${u.columns.map((c) => tsLiteral(toCamelCase(c))).join(', ')})`);
|
|
187
|
+
}
|
|
188
|
+
for (const ck of checks) {
|
|
189
|
+
items.push(`check(sql\`${normalizeCheck(ck.expr)}\`)`);
|
|
190
|
+
}
|
|
191
|
+
for (const ix of table.indexes) {
|
|
192
|
+
let s = `index(${ix.columns.map((c) => tsLiteral(toCamelCase(c))).join(', ')})`;
|
|
193
|
+
if (ix.unique) s += '.unique()';
|
|
194
|
+
if (ix.where) s += `.where(sql\`${normalizeCheck(ix.where)}\`)`;
|
|
195
|
+
items.push(s);
|
|
196
|
+
}
|
|
197
|
+
for (const fk of table.foreignKeys) {
|
|
198
|
+
if (fk.columns.length <= 1) continue; // single-column FKs are rendered on the field
|
|
199
|
+
const cols = `[${fk.columns.map((c) => tsLiteral(toCamelCase(c))).join(', ')}]`;
|
|
200
|
+
const refCols = `[${fk.refColumns.map((c) => tsLiteral(toCamelCase(c))).join(', ')}]`;
|
|
201
|
+
if (!known.has(bareName(fk.refTable))) {
|
|
202
|
+
items.push(`// FIXME: composite FK ${cols} → ${fk.refTable} ${refCols} (target not in the pulled set)`);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
const actions = [
|
|
206
|
+
fk.onDelete ? `onDelete: ${tsLiteral(fk.onDelete)}` : null,
|
|
207
|
+
fk.onUpdate ? `onUpdate: ${tsLiteral(fk.onUpdate)}` : null,
|
|
208
|
+
].filter(Boolean);
|
|
209
|
+
const opts = actions.length ? `, { ${actions.join(', ')} }` : '';
|
|
210
|
+
items.push(`foreignKey(${cols}, () => ${modelVarName(fk.refTable)}, ${refCols}${opts})`);
|
|
211
|
+
}
|
|
212
|
+
if (!items.length) return '';
|
|
213
|
+
return `\n constraints: [\n${items.map((i) => ` ${i},`).join('\n')}\n ],`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** One `export const X = defineModel(...)` block for a table. */
|
|
217
|
+
export function renderModelBlock(table: TableSchema, known: Set<string>, enums: Map<string, string[]> = new Map()): string {
|
|
218
|
+
// A CHECK that reverses to a single field's .validate() is rendered on the field (ergonomic);
|
|
219
|
+
// the rest stay table-level check(). Both round-trip — this only chooses the nicer form.
|
|
220
|
+
const validates = new Map<string, string>();
|
|
221
|
+
const columnNames = new Set(table.columns.map((c) => c.name));
|
|
222
|
+
const tableChecks: CheckConstraint[] = [];
|
|
223
|
+
for (const ck of table.checks) {
|
|
224
|
+
const v = checkToValidate(ck.expr);
|
|
225
|
+
if (v && columnNames.has(v.column) && !validates.has(v.column)) validates.set(v.column, v.zod);
|
|
226
|
+
else tableChecks.push(ck);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const fields = table.columns.map((c) => renderField(table, c, known, enums, validates)).join('\n');
|
|
230
|
+
const constraints = renderConstraintsBlock(table, tableChecks, known);
|
|
231
|
+
|
|
232
|
+
return `export const ${modelVarName(table.table)} = defineModel('${bareName(table.table)}', {\n fields: {\n${fields}\n },${constraints}\n});`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Render a whole snapshot as a single Models module: the import, one block per table (scoped
|
|
237
|
+
* to `opts.schema`), and the `models` array the rest of the framework consumes. The tables
|
|
238
|
+
* come pre-sorted from assembleSchema, so the output is stable.
|
|
239
|
+
*/
|
|
240
|
+
export function renderModelSource(snapshot: SchemaSnapshot, opts: RenderOptions = {}): string {
|
|
241
|
+
const schema = opts.schema ?? 'public';
|
|
242
|
+
const tables = snapshot.tables.filter((t) => t.table.startsWith(`${schema}.`));
|
|
243
|
+
const known = new Set(tables.map((t) => bareName(t.table)));
|
|
244
|
+
const enums = new Map((snapshot.enums ?? []).map((e) => [e.name, e.values]));
|
|
245
|
+
|
|
246
|
+
const blocks = tables.map((t) => renderModelBlock(t, known, enums));
|
|
247
|
+
const body = blocks.join('\n\n');
|
|
248
|
+
|
|
249
|
+
// Import only what the rendered models actually use, so the file reads clean.
|
|
250
|
+
const symbols = ['defineModel', 'field'];
|
|
251
|
+
if (/\bunique\(/.test(body)) symbols.push('unique');
|
|
252
|
+
if (/\bcheck\(/.test(body)) symbols.push('check');
|
|
253
|
+
if (/\bindex\(/.test(body)) symbols.push('index');
|
|
254
|
+
if (/\bforeignKey\(/.test(body)) symbols.push('foreignKey');
|
|
255
|
+
if (/\bsql`/.test(body)) symbols.push('sql');
|
|
256
|
+
const imports = [`import { ${symbols.join(', ')} } from '@everystack/model';`];
|
|
257
|
+
if (/\.validate\(/.test(body)) imports.push(`import { z } from 'zod';`);
|
|
258
|
+
const header = imports.join('\n');
|
|
259
|
+
const footer = `export const models = [${tables.map((t) => modelVarName(t.table)).join(', ')}];`;
|
|
260
|
+
|
|
261
|
+
return [header, ...blocks, footer].join('\n\n') + '\n';
|
|
262
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Postgres identifier quoting for emitted DDL.
|
|
3
|
+
*
|
|
4
|
+
* A bare identifier that collides with a reserved keyword (`user`, `order`, `group`,
|
|
5
|
+
* `table`, …) is a syntax error: `GRANT … ON public.user` fails to parse. The data layer
|
|
6
|
+
* already quotes every table name unconditionally (`compileCreateTable` emits
|
|
7
|
+
* `CREATE TABLE "user"`); the authz layer emitted bare names and so broke on a reserved
|
|
8
|
+
* table name. This is the shared quoter that closes that gap.
|
|
9
|
+
*
|
|
10
|
+
* The rule mirrors what drizzle/pg_dump do: a simple lowercase identifier that is NOT a
|
|
11
|
+
* reserved word is left bare (so `public.posts` stays `public.posts` — the proven output is
|
|
12
|
+
* unchanged), and anything else — a reserved word, a mixed-case or special-character name,
|
|
13
|
+
* or an already-quoted token — is double-quoted. Quoting preserves the exact stored name,
|
|
14
|
+
* which is correct because that is the name the table was created under.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Postgres *reserved* keywords — the set that cannot appear as a table/column identifier
|
|
19
|
+
* without quoting (the "reserved" column of the keyword table; the "reserved (can be
|
|
20
|
+
* function or type)" words are usable as table names and are deliberately omitted, so we
|
|
21
|
+
* never over-quote). Lowercased for a case-insensitive lookup.
|
|
22
|
+
*/
|
|
23
|
+
const RESERVED = new Set([
|
|
24
|
+
'all', 'analyse', 'analyze', 'and', 'any', 'array', 'as', 'asc', 'asymmetric',
|
|
25
|
+
'both', 'case', 'cast', 'check', 'collate', 'column', 'constraint', 'create',
|
|
26
|
+
'current_catalog', 'current_date', 'current_role', 'current_time', 'current_timestamp',
|
|
27
|
+
'current_user', 'default', 'deferrable', 'desc', 'distinct', 'do', 'else', 'end',
|
|
28
|
+
'except', 'false', 'fetch', 'for', 'foreign', 'from', 'grant', 'group', 'having',
|
|
29
|
+
'in', 'initially', 'intersect', 'into', 'lateral', 'leading', 'limit', 'localtime',
|
|
30
|
+
'localtimestamp', 'not', 'null', 'offset', 'on', 'only', 'or', 'order', 'placing',
|
|
31
|
+
'primary', 'references', 'returning', 'select', 'session_user', 'some', 'symmetric',
|
|
32
|
+
'table', 'then', 'to', 'trailing', 'true', 'union', 'unique', 'user', 'using',
|
|
33
|
+
'variadic', 'when', 'where', 'window', 'with',
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
/** A bare-safe identifier: a simple lowercase name that is not a reserved keyword. */
|
|
37
|
+
function isBareSafe(id: string): boolean {
|
|
38
|
+
return /^[a-z_][a-z0-9_]*$/.test(id) && !RESERVED.has(id);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Quote a single identifier when it needs it. A bare-safe name is returned unchanged
|
|
43
|
+
* (preserving the existing output for normal tables); an already-quoted token is passed
|
|
44
|
+
* through; anything else is double-quoted with embedded quotes doubled.
|
|
45
|
+
*/
|
|
46
|
+
export function quoteIdent(id: string): string {
|
|
47
|
+
if (/^".*"$/.test(id)) return id;
|
|
48
|
+
if (isBareSafe(id)) return id;
|
|
49
|
+
return `"${id.replace(/"/g, '""')}"`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Quote each part of a (possibly) schema-qualified name independently:
|
|
54
|
+
* `public.user` → `public."user"`, `public.posts` → `public.posts`. The contract's table
|
|
55
|
+
* names are `<schema>.<table>` with simple parts, so a plain dot split is exact.
|
|
56
|
+
*/
|
|
57
|
+
export function quoteQualified(qualified: string): string {
|
|
58
|
+
return qualified.split('.').map(quoteIdent).join('.');
|
|
59
|
+
}
|