@everystack/cli 0.2.37 → 0.3.0
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/LICENSE +681 -0
- package/README.md +33 -6
- package/package.json +28 -9
- package/src/cli/authz-compile.ts +212 -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 +200 -0
- package/src/cli/authz-reconcile.ts +127 -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 +292 -0
- package/src/cli/commands/db-backup.ts +218 -0
- package/src/cli/commands/db-generate.ts +133 -0
- package/src/cli/commands/db-pull.ts +83 -0
- package/src/cli/commands/db-snapshot.ts +113 -0
- package/src/cli/commands/db.ts +215 -5
- package/src/cli/commands/deploy.ts +46 -0
- package/src/cli/commands/logs.ts +67 -33
- package/src/cli/commands/security.ts +185 -0
- package/src/cli/config.ts +8 -0
- package/src/cli/index.ts +85 -2
- package/src/cli/migration-compile.ts +69 -0
- package/src/cli/migration-generate.ts +175 -0
- package/src/cli/model-api.ts +35 -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 +439 -0
- package/src/cli/schema-diff.ts +605 -0
- package/src/cli/schema-introspect.ts +425 -0
- package/src/cli/security-audit.ts +405 -0
- package/src/cli/security-catalog.ts +170 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* db:snapshot — the pure core for RDS *physical* snapshots (the thin `aws rds` wrapper).
|
|
3
|
+
*
|
|
4
|
+
* RDS snapshot identifiers are far stricter than our backup ids: 1–255 chars, must start with a
|
|
5
|
+
* letter, letters/digits/hyphens only, no two consecutive hyphens, no trailing hyphen, stored
|
|
6
|
+
* lowercase. (Underscores, dots, colons — all rejected by RDS.) So our `YYYYMMDDTHHMMSSZ` stamp
|
|
7
|
+
* can't be used verbatim; this module builds a guaranteed-valid identifier and a validator that
|
|
8
|
+
* pins the contract. No IO — the clock is passed in. See docs/plans/db-backup-restore.md.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* RDS DBSnapshotIdentifier validity (per the AWS API reference):
|
|
13
|
+
* - 1 to 255 letters, numbers, or hyphens
|
|
14
|
+
* - first character must be a letter
|
|
15
|
+
* - can't end with a hyphen or contain two consecutive hyphens
|
|
16
|
+
*/
|
|
17
|
+
const RDS_SNAPSHOT_ID = /^[A-Za-z][A-Za-z0-9]*(-[A-Za-z0-9]+)*$/;
|
|
18
|
+
|
|
19
|
+
/** Whether `id` satisfies RDS's DBSnapshotIdentifier constraints. */
|
|
20
|
+
export function isValidRdsSnapshotId(id: string): boolean {
|
|
21
|
+
return id.length >= 1 && id.length <= 255 && RDS_SNAPSHOT_ID.test(id);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Sanitize a stage name into the leading segment of an RDS identifier: lowercase, non-alphanumeric
|
|
26
|
+
* runs collapse to a single hyphen, and it is forced to start with a letter (a `db` prefix is
|
|
27
|
+
* prepended when the stage starts with a digit or is empty). Never emits a trailing hyphen.
|
|
28
|
+
*/
|
|
29
|
+
function sanitizeStage(stage: string): string {
|
|
30
|
+
let s = stage.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
31
|
+
if (s === '' || !/^[a-z]/.test(s)) s = `db-${s}`.replace(/-+$/g, '');
|
|
32
|
+
return s;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Build a valid, deterministic RDS snapshot identifier from a stage and a `YYYYMMDDTHHMMSSZ` stamp.
|
|
37
|
+
* The stamp is lowercased (its `T`/`Z` become valid letters); the result is
|
|
38
|
+
* `<sanitized-stage>-<stamp>`, e.g. `dev-20260628t120000z`. Throws if — against expectation — the
|
|
39
|
+
* composed id is still invalid, so a malformed id can never reach the AWS API.
|
|
40
|
+
*/
|
|
41
|
+
export function rdsSnapshotIdentifier(stage: string, stamp: string): string {
|
|
42
|
+
const id = `${sanitizeStage(stage)}-${stamp.toLowerCase()}`;
|
|
43
|
+
if (!isValidRdsSnapshotId(id)) {
|
|
44
|
+
throw new Error(`Could not build a valid RDS snapshot identifier from stage="${stage}", stamp="${stamp}" (got "${id}")`);
|
|
45
|
+
}
|
|
46
|
+
return id;
|
|
47
|
+
}
|