@everystack/cli 0.4.99 → 0.4.102
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/CHANGELOG.md +105 -0
- package/package.json +3 -3
- package/src/cli/apply-execute.ts +15 -1
- package/src/cli/authz-baseline.ts +8 -4
- package/src/cli/authz-compile.ts +61 -7
- package/src/cli/authz-contract.ts +23 -14
- package/src/cli/authz-derive.ts +282 -30
- package/src/cli/authz-reconcile.ts +6 -4
- package/src/cli/catalog-rename.ts +115 -0
- package/src/cli/commands/db-check.ts +40 -0
- package/src/cli/commands/db-generate.ts +10 -9
- package/src/cli/commands/db-plan.ts +30 -13
- package/src/cli/commands/db-pull.ts +153 -63
- package/src/cli/commands/db-reconcile.ts +22 -3
- package/src/cli/commands/db-sync.ts +6 -5
- package/src/cli/commands/db.ts +91 -8
- package/src/cli/commands/update.ts +3 -2
- package/src/cli/db-build.ts +1 -0
- package/src/cli/declared-derived.ts +3 -1
- package/src/cli/declared-diff.ts +4 -1
- package/src/cli/derived-apply.ts +116 -10
- package/src/cli/derived-grants.ts +4 -2
- package/src/cli/derived-introspect.ts +23 -3
- package/src/cli/derived-plan.ts +37 -1
- package/src/cli/derived-render.ts +33 -34
- package/src/cli/edge-plan.ts +57 -9
- package/src/cli/generated-artifacts.ts +22 -0
- package/src/cli/generated-names.ts +108 -0
- package/src/cli/host-url.ts +48 -27
- package/src/cli/index.ts +5 -5
- package/src/cli/lifecycle-canon.ts +37 -0
- package/src/cli/lifecycle-derived.ts +721 -0
- package/src/cli/lifecycle-pull.ts +177 -0
- package/src/cli/lifecycle-verify.ts +232 -0
- package/src/cli/migration-compile.ts +8 -3
- package/src/cli/migration-generate.ts +23 -3
- package/src/cli/model-map.ts +255 -0
- package/src/cli/model-render.ts +243 -77
- package/src/cli/plain-output.ts +198 -10
- package/src/cli/runbook/model-section.ts +8 -5
- package/src/cli/schema-compile.ts +57 -18
- package/src/cli/schema-diff.ts +184 -41
- package/src/cli/schema-fingerprint.ts +39 -6
- package/src/cli/schema-introspect.ts +66 -2
- package/src/cli/schema-source.ts +40 -9
- package/src/cli/schema-swap.ts +3 -2
- package/src/cli/state-apply.ts +10 -4
- package/src/cli/ts-template.ts +52 -0
|
@@ -85,6 +85,13 @@ export interface LiveObject {
|
|
|
85
85
|
/** The WHEN predicate as the catalog deparses it. */
|
|
86
86
|
when?: string;
|
|
87
87
|
};
|
|
88
|
+
/**
|
|
89
|
+
* Trigger only: `pg_trigger.tgenabled` — `O` (fires on origin, the default every declared
|
|
90
|
+
* trigger has), `D` disabled, `R` replica-only, `A` always. Read LIVE and compared to `O` by
|
|
91
|
+
* the plan; it is deliberately NOT in `defHash` (pg_get_triggerdef does not render it), so no
|
|
92
|
+
* recorded provenance hash changes. Absent means not measured.
|
|
93
|
+
*/
|
|
94
|
+
enabled?: string;
|
|
88
95
|
/** Live ACLs (grantee → sorted privileges), owner excluded, function defaults expanded.
|
|
89
96
|
* Absent on triggers (they take no grants) and when the introspection predates B4. */
|
|
90
97
|
grants?: Record<string, string[]>;
|
|
@@ -492,7 +499,8 @@ SELECT
|
|
|
492
499
|
(SELECT array_agg(a.attname::text ORDER BY x.ord)
|
|
493
500
|
FROM unnest(t.tgattr::int2[]) WITH ORDINALITY AS x(attnum, ord)
|
|
494
501
|
JOIN pg_attribute a ON a.attrelid = t.tgrelid AND a.attnum = x.attnum) AS update_of,
|
|
495
|
-
substring(pg_get_triggerdef(t.oid) from ' WHEN [(](.*)[)] EXECUTE ') AS when_expr
|
|
502
|
+
substring(pg_get_triggerdef(t.oid) from ' WHEN [(](.*)[)] EXECUTE ') AS when_expr,
|
|
503
|
+
t.tgenabled::text AS enabled
|
|
496
504
|
FROM pg_trigger t
|
|
497
505
|
JOIN pg_class c ON c.oid = t.tgrelid
|
|
498
506
|
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
@@ -587,6 +595,8 @@ export interface TriggerRow {
|
|
|
587
595
|
update_of?: unknown;
|
|
588
596
|
/** The WHEN predicate, scraped from pg_get_triggerdef — see DERIVED_TRIGGERS_SQL. */
|
|
589
597
|
when_expr?: unknown;
|
|
598
|
+
/** `tgenabled` as text (O/D/R/A). Optional: a pre-F2 read has none — not measured. */
|
|
599
|
+
enabled?: unknown;
|
|
590
600
|
}
|
|
591
601
|
|
|
592
602
|
export interface GrantAclRow {
|
|
@@ -651,13 +661,14 @@ export function assembleDerivedCatalog(rows: DerivedRows): DerivedCatalog {
|
|
|
651
661
|
for (const row of rows.grants ?? []) {
|
|
652
662
|
if (IGNORED_SCHEMAS.has(row.schema)) continue;
|
|
653
663
|
const key = `${String(row.kind)}:${row.schema}.${row.name}`;
|
|
654
|
-
|
|
664
|
+
// Null prototype: a grantee is a live role name, and '__proto__' is a legal one (see assembleContract).
|
|
665
|
+
const acc = grantsByKey.get(key) ?? Object.create(null) as Record<string, string[]>;
|
|
655
666
|
acc[row.grantee] = [...new Set([...(acc[row.grantee] ?? []), ...pgTextArray(row.privileges)])].sort();
|
|
656
667
|
grantsByKey.set(key, acc);
|
|
657
668
|
}
|
|
658
669
|
const grantsFor = (kindClass: 'r' | 'f', identity: string): { grants?: Record<string, string[]> } => {
|
|
659
670
|
if (rows.grants === undefined) return {}; // pre-B4 caller — absent, not empty
|
|
660
|
-
return { grants: grantsByKey.get(`${kindClass}:${identity}`) ??
|
|
671
|
+
return { grants: grantsByKey.get(`${kindClass}:${identity}`) ?? Object.create(null) };
|
|
661
672
|
};
|
|
662
673
|
|
|
663
674
|
const indexesByIdentity = new Map<string, string[]>();
|
|
@@ -756,6 +767,7 @@ export function assembleDerivedCatalog(rows: DerivedRows): DerivedCatalog {
|
|
|
756
767
|
defHash: computeDefHash(definition, []),
|
|
757
768
|
table: row.schema === 'public' ? row.table : ownerIdentity,
|
|
758
769
|
ownerIdentity,
|
|
770
|
+
...(row.enabled != null ? { enabled: String(row.enabled) } : {}),
|
|
759
771
|
...(row.tgtype != null && row.function_identity != null ? {
|
|
760
772
|
trg: {
|
|
761
773
|
shape: parseTriggerType(Number(row.tgtype)),
|
|
@@ -768,6 +780,14 @@ export function assembleDerivedCatalog(rows: DerivedRows): DerivedCatalog {
|
|
|
768
780
|
if (relationIdentities.has(ownerIdentity)) {
|
|
769
781
|
triggerEdges.push({ dependent: identity, referenced: ownerIdentity });
|
|
770
782
|
}
|
|
783
|
+
// The trigger depends on the function it executes: PostgreSQL refuses DROP FUNCTION while
|
|
784
|
+
// the trigger exists, so a run removing both must drop the trigger first. Trigger functions
|
|
785
|
+
// take no arguments; a pre-signature function row carries the bare name.
|
|
786
|
+
if (row.function_identity != null) {
|
|
787
|
+
const fn = String(row.function_identity);
|
|
788
|
+
const executes = [`${fn}()`, fn].find((id) => relationIdentities.has(id));
|
|
789
|
+
if (executes) triggerEdges.push({ dependent: identity, referenced: executes });
|
|
790
|
+
}
|
|
771
791
|
}
|
|
772
792
|
|
|
773
793
|
const edges: DependencyEdge[] = [
|
package/src/cli/derived-plan.ts
CHANGED
|
@@ -67,6 +67,29 @@ export interface RegrantFinding {
|
|
|
67
67
|
owner?: string;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/**
|
|
71
|
+
* A managed trigger whose live `tgenabled` is not `O`, the state every declared trigger has.
|
|
72
|
+
* `ALTER TABLE … DISABLE TRIGGER` touches no definition and no hash, so this is the only place it
|
|
73
|
+
* surfaces. Converged like a regrant, without ceremony: `ENABLE TRIGGER` restores the declared
|
|
74
|
+
* state and loses nothing but the drift itself.
|
|
75
|
+
*/
|
|
76
|
+
export interface ReenableFinding {
|
|
77
|
+
identity: string;
|
|
78
|
+
/** The trigger name. */
|
|
79
|
+
name: string;
|
|
80
|
+
/** Its table, always `schema.table` — the ALTER TABLE target. */
|
|
81
|
+
table: string;
|
|
82
|
+
/** The live tgenabled: D, R or A. */
|
|
83
|
+
state: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** What a tgenabled value means, in the words the report uses. */
|
|
87
|
+
export const TRIGGER_ENABLED_STATE: Record<string, string> = {
|
|
88
|
+
D: 'disabled — PostgreSQL never fires it',
|
|
89
|
+
R: 'enabled for replica only — it does not fire in an ordinary session',
|
|
90
|
+
A: 'enabled always — it also fires under session_replication_role = replica, where the declared trigger does not',
|
|
91
|
+
};
|
|
92
|
+
|
|
70
93
|
/** Declared `dependsOn` vs the live catalog's actual edges (B4: declared, then verified). */
|
|
71
94
|
export interface DependencyDriftFinding {
|
|
72
95
|
identity: string;
|
|
@@ -107,6 +130,9 @@ export interface ReconcilePlan {
|
|
|
107
130
|
regrants: RegrantFinding[];
|
|
108
131
|
/** Declared-vs-actual dependency drift — check-fails (fix the declaration), never blocks apply. */
|
|
109
132
|
dependencyDrift: DependencyDriftFinding[];
|
|
133
|
+
/** Managed triggers that are not enabled as declared. Optional because a plan can arrive over
|
|
134
|
+
* the ops-Lambda wire from a stage that predates it; absent reads as none. */
|
|
135
|
+
reenables?: ReenableFinding[];
|
|
110
136
|
}
|
|
111
137
|
|
|
112
138
|
export interface ReconcileOptions {
|
|
@@ -506,6 +532,7 @@ export function planReconcile(
|
|
|
506
532
|
|
|
507
533
|
const regrants: RegrantFinding[] = [];
|
|
508
534
|
const dependencyDrift: DependencyDriftFinding[] = [];
|
|
535
|
+
const reenables: ReenableFinding[] = [];
|
|
509
536
|
// Rebaselined objects join the lens: their live catalog rows are as real as a skip's,
|
|
510
537
|
// and running the checks NOW means a migration converges (grants ride the same apply)
|
|
511
538
|
// instead of surfacing on the next plan.
|
|
@@ -518,9 +545,15 @@ export function planReconcile(
|
|
|
518
545
|
const lensSet = new Set([...skipped, ...rebaseline, ...backfill, ...baseline]);
|
|
519
546
|
for (const src of source.objects) {
|
|
520
547
|
if (!lensSet.has(src.identity)) continue;
|
|
521
|
-
if (src.kind === 'trigger' || src.kind === 'sql') continue; // no grants, no rewrite edges
|
|
522
548
|
const liveObj = liveById.get(src.identity);
|
|
523
549
|
|
|
550
|
+
// Trigger enabled state — only for a trigger this source declares (internal FK triggers and
|
|
551
|
+
// unmanaged ones never reach here), and only when the catalog measured it (absent ≠ 'O').
|
|
552
|
+
if (src.kind === 'trigger' && liveObj?.enabled !== undefined && liveObj.enabled !== 'O' && liveObj.ownerIdentity) {
|
|
553
|
+
reenables.push({ identity: src.identity, name: liveObj.name, table: liveObj.ownerIdentity, state: liveObj.enabled });
|
|
554
|
+
}
|
|
555
|
+
if (src.kind === 'trigger' || src.kind === 'sql') continue; // no grants, no rewrite edges
|
|
556
|
+
|
|
524
557
|
// Grant drift — only when the catalog actually reported ACLs (absent ≠ empty).
|
|
525
558
|
// Every object is descriptor-compiled (db/sql retired, B7), and the compiler
|
|
526
559
|
// ALWAYS declares authz — a private relation is declared-empty, not undeclared —
|
|
@@ -735,5 +768,8 @@ export function planReconcile(
|
|
|
735
768
|
migrations,
|
|
736
769
|
regrants: regrants.sort((a, b) => a.identity.localeCompare(b.identity)),
|
|
737
770
|
dependencyDrift: dependencyDrift.sort((a, b) => a.identity.localeCompare(b.identity)),
|
|
771
|
+
// Present only when there is one: an unchanged database's plan (and its --json) stays
|
|
772
|
+
// byte-identical to what it was before this field existed.
|
|
773
|
+
...(reenables.length ? { reenables: reenables.sort((a, b) => a.identity.localeCompare(b.identity)) } : {}),
|
|
738
774
|
};
|
|
739
775
|
}
|
|
@@ -20,6 +20,8 @@ import { parseIndexDefinition } from './schema-introspect';
|
|
|
20
20
|
import { modelImportPath, modelFileName, renderFieldLines } from './model-render';
|
|
21
21
|
import { splitFunctionIdentity } from './pg-argtypes';
|
|
22
22
|
import { GOVERNED_VOCABULARY, KNOWN_ROLES } from './authz-derive';
|
|
23
|
+
import { templateBody, tsString, commentText } from './ts-template';
|
|
24
|
+
import { safeBinding, safeFileStem, liveObject, refuseProtoKey } from './generated-names';
|
|
23
25
|
|
|
24
26
|
/**
|
|
25
27
|
* One rendered object's own lines, plus what it depends on — the unit `--derived-out <dir>`
|
|
@@ -101,21 +103,12 @@ function toCamelCase(name: string): string {
|
|
|
101
103
|
return name.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase());
|
|
102
104
|
}
|
|
103
105
|
|
|
104
|
-
/** Escape a body for a template literal in generated source — it must always parse. */
|
|
105
|
-
function tsTemplate(body: string): string {
|
|
106
|
-
return body.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${');
|
|
107
|
-
}
|
|
108
|
-
|
|
109
106
|
/** An object key, bare when it is a valid identifier and quoted when it is not — a role
|
|
110
107
|
* name is a PostgreSQL identifier and may hold characters JavaScript will not take bare. */
|
|
111
108
|
function tsKey(s: string): string {
|
|
112
109
|
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(s) ? s : tsString(s);
|
|
113
110
|
}
|
|
114
111
|
|
|
115
|
-
function tsString(s: string): string {
|
|
116
|
-
return `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
112
|
/**
|
|
120
113
|
* Split a relation's live ACL into what `abilities` can SAY and what must be RECORDED.
|
|
121
114
|
*
|
|
@@ -134,6 +127,9 @@ function splitRelationGrants(
|
|
|
134
127
|
const privileges: Record<string, string[]> = {};
|
|
135
128
|
const foreign: string[] = [];
|
|
136
129
|
for (const [grantee, privs] of Object.entries(grants)) {
|
|
130
|
+
// `privileges[grantee] = …` below would set the prototype for a grantee named __proto__.
|
|
131
|
+
// Introspection keeps such a grant (null-prototype maps), so the pull refuses here, naming it.
|
|
132
|
+
refuseProtoKey(grantee, liveObject('role', grantee));
|
|
137
133
|
const selectOnly = privs.length === 1 && privs[0] === 'SELECT';
|
|
138
134
|
// ABILITIES are limited to the compiler's own vocabulary (authz-derive's KNOWN_ROLES).
|
|
139
135
|
// A foreign grantee is FACT, not opinion, so it is RECORDED — never declared as an
|
|
@@ -303,7 +299,7 @@ export function renderedOwner(
|
|
|
303
299
|
// in which case rendering would emit source that throws on import.
|
|
304
300
|
if (!RENDERABLE_ROLE.test(owner)) {
|
|
305
301
|
const why = `${o.identity}: owner '${owner}' is not a bare lowercase role name, which is all the model's owner property accepts (it is interpolated into SET ROLE) — ownership NOT declared. Rename the role, or declare the owner by hand.`;
|
|
306
|
-
return { fixme: `// FIXME: ${why}`, warning: why };
|
|
302
|
+
return { fixme: `// FIXME: ${commentText(why)}`, warning: why };
|
|
307
303
|
}
|
|
308
304
|
return { prop: `owner: ${tsString(owner)},` };
|
|
309
305
|
}
|
|
@@ -311,7 +307,7 @@ export function renderedOwner(
|
|
|
311
307
|
if (!ambient.length) {
|
|
312
308
|
// Owner known, discriminator not measured. Refuse to classify rather than guess.
|
|
313
309
|
return {
|
|
314
|
-
fixme: `// FIXME: ${o.identity} is owned by '${owner}', but this pull could not tell whether that is a chosen principal or the build credential — owner is NOT declared. Add owner: '${owner}' if that role is the intended owner
|
|
310
|
+
fixme: `// ${commentText(`FIXME: ${o.identity} is owned by '${owner}', but this pull could not tell whether that is a chosen principal or the build credential — owner is NOT declared. Add owner: '${owner}' if that role is the intended owner.`)}`,
|
|
315
311
|
};
|
|
316
312
|
}
|
|
317
313
|
|
|
@@ -321,11 +317,11 @@ export function renderedOwner(
|
|
|
321
317
|
// on a brownfield database it is the common shape, and echoing all of it would bury the rest.
|
|
322
318
|
return elevated
|
|
323
319
|
? {
|
|
324
|
-
fixme: `// FIXME SECURITY: ${o.identity} is ${elevated.posture} and owned by '${owner}' — ${because}. ${elevated.what} runs with that role's privileges. The observed owner is NOT declared, because declaring the build credential would make the accident reproducible; declare the intended owner with owner: '<role>'
|
|
320
|
+
fixme: `// ${commentText(`FIXME SECURITY: ${o.identity} is ${elevated.posture} and owned by '${owner}' — ${because}. ${elevated.what} runs with that role's privileges. The observed owner is NOT declared, because declaring the build credential would make the accident reproducible; declare the intended owner with owner: '<role>'.`)}`,
|
|
325
321
|
warning: `${o.identity}: ${elevated.posture}, owned by '${owner}' (${because}) — ${elevated.what.toLowerCase()} runs with that role's privileges. Ownership was NOT declared; declare the intended owner with owner: '<role>'.`,
|
|
326
322
|
}
|
|
327
323
|
: {
|
|
328
|
-
fixme: `// FIXME: ${o.identity} is owned by '${owner}' — ${because}, not a chosen principal, so ownership is NOT declared. Add owner: '<role>' if a specific role should own it
|
|
324
|
+
fixme: `// ${commentText(`FIXME: ${o.identity} is owned by '${owner}' — ${because}, not a chosen principal, so ownership is NOT declared. Add owner: '<role>' if a specific role should own it.`)}`,
|
|
329
325
|
};
|
|
330
326
|
}
|
|
331
327
|
|
|
@@ -344,12 +340,12 @@ export function renderedFunctionOwner(
|
|
|
344
340
|
function renderIndexBuilder(indexdef: string): string | null {
|
|
345
341
|
const parsed = parseIndexDefinition(indexdef);
|
|
346
342
|
if (!parsed) return null;
|
|
347
|
-
const entry = (c: string): string => (PLAIN_IDENT.test(c) ? tsString(c) : `sql\`${
|
|
343
|
+
const entry = (c: string): string => (PLAIN_IDENT.test(c) ? tsString(c) : `sql\`${templateBody(c)}\``);
|
|
348
344
|
let s = `index(${parsed.columns.map(entry).join(', ')})`;
|
|
349
345
|
if (parsed.using) s += `.using(${tsString(parsed.using)})`;
|
|
350
346
|
if (parsed.unique) s += '.unique()';
|
|
351
347
|
if (parsed.include?.length) s += `.include(${parsed.include.map(tsString).join(', ')})`;
|
|
352
|
-
if (parsed.where) s += `.where(sql\`${
|
|
348
|
+
if (parsed.where) s += `.where(sql\`${templateBody(parsed.where)}\`)`;
|
|
353
349
|
return s;
|
|
354
350
|
}
|
|
355
351
|
|
|
@@ -361,12 +357,12 @@ function renderIndexBuilder(indexdef: string): string | null {
|
|
|
361
357
|
function renderTableIndexBuilder(indexdef: string): string | null {
|
|
362
358
|
const parsed = parseIndexDefinition(indexdef);
|
|
363
359
|
if (!parsed) return null;
|
|
364
|
-
const entry = (c: string): string => (PLAIN_IDENT.test(c) ? tsString(toCamelCase(c)) : `sql\`${
|
|
360
|
+
const entry = (c: string): string => (PLAIN_IDENT.test(c) ? tsString(toCamelCase(c)) : `sql\`${templateBody(c)}\``);
|
|
365
361
|
let s = `index(${parsed.columns.map(entry).join(', ')})`;
|
|
366
362
|
if (parsed.using) s += `.using(${tsString(parsed.using)})`;
|
|
367
363
|
if (parsed.unique) s += '.unique()';
|
|
368
364
|
if (parsed.include?.length) s += `.include(${parsed.include.map((c) => tsString(toCamelCase(c))).join(', ')})`;
|
|
369
|
-
if (parsed.where) s += `.where(sql\`${
|
|
365
|
+
if (parsed.where) s += `.where(sql\`${templateBody(parsed.where)}\`)`;
|
|
370
366
|
return s;
|
|
371
367
|
}
|
|
372
368
|
|
|
@@ -412,7 +408,7 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
|
|
|
412
408
|
const sequenceNames: string[] = [];
|
|
413
409
|
for (const seq of opts.sequences ?? []) {
|
|
414
410
|
const linesStart = lines.length;
|
|
415
|
-
const varName = toCamelCase(seq.name.replace(/\./g, '_'));
|
|
411
|
+
const varName = safeBinding(toCamelCase(seq.name.replace(/\./g, '_')), liveObject('sequence', seq.name));
|
|
416
412
|
const props: string[] = [];
|
|
417
413
|
if (seq.as !== 'bigint') props.push(`as: ${tsString(seq.as)}`);
|
|
418
414
|
if (seq.start != null) props.push(`start: ${seq.start}`);
|
|
@@ -436,8 +432,9 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
|
|
|
436
432
|
|
|
437
433
|
// Triggers are held out of the topological pass and emitted LAST (see the trigger block
|
|
438
434
|
// at the end). A binding needs BOTH its target and its execute function to already
|
|
439
|
-
// exist
|
|
440
|
-
//
|
|
435
|
+
// exist. The catalog does record trigger→function (and trigger→owning view) edges, for
|
|
436
|
+
// the reconcile drop order, but this pass never sees triggers: file order — not the
|
|
437
|
+
// dependency sort — is what guarantees it here.
|
|
441
438
|
const renderable = catalog.objects.filter((o) => o.kind !== 'trigger');
|
|
442
439
|
const triggers = catalog.objects.filter((o) => o.kind === 'trigger');
|
|
443
440
|
const byIdentity = new Map(renderable.map((o) => [o.identity, o]));
|
|
@@ -450,7 +447,7 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
|
|
|
450
447
|
const varCounts = new Map<string, number>();
|
|
451
448
|
for (const o of renderable) varCounts.set(baseVar(o), (varCounts.get(baseVar(o)) ?? 0) + 1);
|
|
452
449
|
const varOf = new Map(renderable.map((o) =>
|
|
453
|
-
[o.identity, (varCounts.get(baseVar(o)) ?? 0) > 1 ? baseVar(o) + argSuffix(o.identity) : baseVar(o)]));
|
|
450
|
+
[o.identity, safeBinding((varCounts.get(baseVar(o)) ?? 0) > 1 ? baseVar(o) + argSuffix(o.identity) : baseVar(o), liveObject(o.kind, o.identity))]));
|
|
454
451
|
// The name a descriptor DECLARES: bare for public, qualified otherwise. parseQualified
|
|
455
452
|
// round-trips it, so the compiled identity matches the live object it was pulled from —
|
|
456
453
|
// a bare name would compile to public.<name>, baseline would never join, and reconcile
|
|
@@ -509,7 +506,7 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
|
|
|
509
506
|
} else if (derived) {
|
|
510
507
|
refs.push(derived);
|
|
511
508
|
derivedIdents.push(e.referenced);
|
|
512
|
-
} else fixmes.push(`// FIXME: dependsOn ${e.referenced} — not in the pulled set; declare it by hand.`);
|
|
509
|
+
} else fixmes.push(`// FIXME: dependsOn ${commentText(e.referenced)} — not in the pulled set; declare it by hand.`);
|
|
513
510
|
}
|
|
514
511
|
return { refs: refs.sort(), fixmes, identRefs: { model: modelIdents.sort(), derived: derivedIdents.sort() } };
|
|
515
512
|
};
|
|
@@ -618,7 +615,7 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
|
|
|
618
615
|
...(builders.length ? [` constraints: [${builders.join(', ')}],`] : []),
|
|
619
616
|
...(refs.length ? [` dependsOn: [${refs.join(', ')}],`] : []),
|
|
620
617
|
...(o.comment ? [` comment: ${tsString(o.comment)},`] : []),
|
|
621
|
-
` as: sql\`${
|
|
618
|
+
` as: sql\`${templateBody(o.definition.trim().replace(/;$/, ''))}\`,`,
|
|
622
619
|
'});',
|
|
623
620
|
'',
|
|
624
621
|
);
|
|
@@ -675,7 +672,7 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
|
|
|
675
672
|
if (o.populated === false) props.push(`populate: 'deferred',`);
|
|
676
673
|
}
|
|
677
674
|
if (o.comment) props.push(`comment: ${tsString(o.comment)},`);
|
|
678
|
-
props.push(`as: sql\`${
|
|
675
|
+
props.push(`as: sql\`${templateBody(o.definition.trim().replace(/;$/, ''))}\`,`);
|
|
679
676
|
need(o.kind === 'view' ? 'defineView' : 'defineMaterializedView');
|
|
680
677
|
need('sql');
|
|
681
678
|
lines.push(
|
|
@@ -711,14 +708,14 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
|
|
|
711
708
|
if (ownership.prop) {
|
|
712
709
|
const why = `${o.identity}: owned by '${o.owner}', but defineSql cannot declare an owner — the build will create it as the connecting role. Move it into defineFunction's vocabulary, or set the owner by hand after the build.`;
|
|
713
710
|
warnings.push(why);
|
|
714
|
-
lines.push(`// FIXME: ${why}`);
|
|
711
|
+
lines.push(`// FIXME: ${commentText(why)}`);
|
|
715
712
|
}
|
|
716
713
|
lines.push(
|
|
717
|
-
`// FIXME: ${o.identity} — signature beyond defineFunction's v1 vocabulary; kept verbatim as defineSql.`,
|
|
714
|
+
`// FIXME: ${commentText(o.identity)} — signature beyond defineFunction's v1 vocabulary; kept verbatim as defineSql.`,
|
|
718
715
|
`export const ${varName} = defineSql(${tsString(sqlName)}, {`,
|
|
719
716
|
` kind: 'function',`,
|
|
720
|
-
` drop: sql
|
|
721
|
-
` as: sql\`${
|
|
717
|
+
` drop: sql\`${templateBody(`DROP FUNCTION IF EXISTS ${o.schema === 'public' ? o.name : `${o.schema}.${o.name}`}${f?.identityArgs !== undefined ? `(${f.identityArgs})` : ''}`)}\`,`,
|
|
718
|
+
` as: sql\`${templateBody(o.definition.trim().replace(/;$/, ''))}\`,`,
|
|
722
719
|
'});',
|
|
723
720
|
'',
|
|
724
721
|
);
|
|
@@ -731,7 +728,7 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
|
|
|
731
728
|
const props: string[] = [];
|
|
732
729
|
if (args.length) {
|
|
733
730
|
const rendered = args.map((a) =>
|
|
734
|
-
`arg(${tsString(a.name)}, ${tsString(a.type)}${a.default ? `, { default: sql\`${
|
|
731
|
+
`arg(${tsString(a.name)}, ${tsString(a.type)}${a.default ? `, { default: sql\`${templateBody(a.default)}\` }` : ''})`);
|
|
735
732
|
props.push(`args: [${rendered.join(', ')}],`);
|
|
736
733
|
need('arg');
|
|
737
734
|
}
|
|
@@ -773,7 +770,7 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
|
|
|
773
770
|
if (publicExec) props.push(`privileges: { PUBLIC: ['EXECUTE'] },`);
|
|
774
771
|
if (refs.length) props.push(`dependsOn: [${refs.join(', ')}],`);
|
|
775
772
|
if (o.comment) props.push(`comment: ${tsString(o.comment)},`);
|
|
776
|
-
props.push(`body: sql\`${
|
|
773
|
+
props.push(`body: sql\`${templateBody(f.src.trim())}\`,`);
|
|
777
774
|
need('defineFunction');
|
|
778
775
|
need('sql');
|
|
779
776
|
lines.push(
|
|
@@ -782,7 +779,7 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
|
|
|
782
779
|
// adopting the descriptor no longer applies a revoke. It is still worth a look on a
|
|
783
780
|
// SECURITY DEFINER function, which is what db:check now WARNs about.
|
|
784
781
|
...(publicExec && f.secdef
|
|
785
|
-
? [`// NOTE: ${o.identity} is SECURITY DEFINER and live-granted EXECUTE to PUBLIC — recorded, not chosen. Narrow it to can('execute', { role }) once you know the intended callers.`]
|
|
782
|
+
? [`// NOTE: ${commentText(o.identity)} is SECURITY DEFINER and live-granted EXECUTE to PUBLIC — recorded, not chosen. Narrow it to can('execute', { role }) once you know the intended callers.`]
|
|
786
783
|
: []),
|
|
787
784
|
`export const ${varName} = defineFunction(${tsString(declaredName(o))}, {`,
|
|
788
785
|
...props.map((p) => ` ${p}`),
|
|
@@ -842,18 +839,18 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
|
|
|
842
839
|
// `export const X` — the file does not compile, and `execute: X` would bind the
|
|
843
840
|
// trigger to itself. Suffix only on collision, so an uncontended schema renders
|
|
844
841
|
// byte-identically to a name-per-object scheme.
|
|
845
|
-
const varName = uniqueVar(
|
|
842
|
+
const varName = safeBinding(uniqueVar(
|
|
846
843
|
toCamelCase(o.schema === 'public' ? o.name : `${o.schema}_${o.name}`),
|
|
847
844
|
o,
|
|
848
845
|
usedVars,
|
|
849
|
-
);
|
|
846
|
+
), liveObject('trigger', o.identity));
|
|
850
847
|
const props = [
|
|
851
848
|
`on: ${targetVar},`,
|
|
852
849
|
`timing: ${tsString(o.trg.shape.timing)},`,
|
|
853
850
|
`events: [${o.trg.shape.events.map(tsString).join(', ')}],`,
|
|
854
851
|
`forEach: ${tsString(o.trg.shape.forEach)},`,
|
|
855
852
|
...(o.trg.updateOf?.length ? [`of: [${o.trg.updateOf.map(tsString).join(', ')}],`] : []),
|
|
856
|
-
...(o.trg.when ? [`condition: sql\`${
|
|
853
|
+
...(o.trg.when ? [`condition: sql\`${templateBody(o.trg.when)}\`,`] : []),
|
|
857
854
|
`execute: ${fnVar},`,
|
|
858
855
|
];
|
|
859
856
|
if (o.trg.when) need('sql');
|
|
@@ -943,6 +940,8 @@ function derivedBucket(kind: DerivedRecord['kind'], schema: string): string {
|
|
|
943
940
|
if (kind === 'view' || kind === 'materialized view') return 'views';
|
|
944
941
|
if (kind === 'trigger') return 'triggers';
|
|
945
942
|
if (kind === 'sequence') return 'index';
|
|
943
|
+
// The schema names a split FILE, written under the derived directory.
|
|
944
|
+
safeFileStem(schema.replace(/_/g, '-'), liveObject('schema', schema));
|
|
946
945
|
return `functions-${schema}`;
|
|
947
946
|
}
|
|
948
947
|
|
package/src/cli/edge-plan.ts
CHANGED
|
@@ -30,14 +30,14 @@ import type { OwnerApplyMode } from './derived-apply';
|
|
|
30
30
|
import type { ModelDescriptor } from '@everystack/model';
|
|
31
31
|
import type { SchemaSnapshot, TableSchema } from './schema-introspect';
|
|
32
32
|
import type { AuthzContract, SequenceGrantContract, DefaultAclScope } from './authz-contract';
|
|
33
|
-
import { ownSequenceName, foreignKeyContentKey } from './schema-diff';
|
|
34
|
-
import {
|
|
33
|
+
import { ownSequenceName, foreignKeyContentKey, type SchemaChange } from './schema-diff';
|
|
34
|
+
import { generateMigration, unmodeledTables } from './migration-generate';
|
|
35
35
|
import { compileTableContract } from './authz-compile';
|
|
36
36
|
import { carryUngovernedAuthz, inheritedSequenceGrants, type GrantExemption } from './authz-reconcile';
|
|
37
37
|
import { BASELINE_FILE } from './authz-baseline';
|
|
38
38
|
import { classifyGeneratedStatements, classifyDestructive, partitionStatements, renderStatementHistogram } from './state-apply';
|
|
39
39
|
import { statementHead, statementHeadOrSelf } from './sql-statement';
|
|
40
|
-
import { fingerprintLive, fingerprintState, fingerprintModels, governedRolesForModels, stableStringify, compareStoredFingerprint, formatChangedMessage, schemaOf, FINGERPRINT_VERSION, type Fingerprint } from './schema-fingerprint';
|
|
40
|
+
import { fingerprintLive, fingerprintState, fingerprintModels, governedRolesForModels, stableStringify, compareStoredFingerprint, formatChangedMessage, schemaOf, divergentSurfaces, FINGERPRINT_VERSION, type Fingerprint } from './schema-fingerprint';
|
|
41
41
|
import { compileDeclaredState } from './declared-diff';
|
|
42
42
|
import { compileTableRenames, compileTableMoves, compileRenames } from './schema-compile';
|
|
43
43
|
|
|
@@ -408,7 +408,7 @@ export function predictLiveState(
|
|
|
408
408
|
// sets those explicitly and the declared contract already models them.
|
|
409
409
|
const defaultAclFor = (scope: DefaultAclScope | undefined, schema: string): Record<string, string[]> => {
|
|
410
410
|
if (!scope) return {};
|
|
411
|
-
const out: Record<string, string[]> =
|
|
411
|
+
const out: Record<string, string[]> = Object.create(null); // keyed by a live role name
|
|
412
412
|
for (const key of ['', schema]) {
|
|
413
413
|
for (const [grantee, privileges] of Object.entries(scope[key] ?? {})) {
|
|
414
414
|
if (foreignGrantee(grantee)) out[grantee] = [...new Set([...(out[grantee] ?? []), ...privileges])].sort();
|
|
@@ -461,7 +461,7 @@ export function predictLiveState(
|
|
|
461
461
|
const inherited = inheritedSequenceGrants(d);
|
|
462
462
|
const liveName = liveNameFor(d.table);
|
|
463
463
|
for (const s of liveSeqByTable.get(liveName) ?? []) {
|
|
464
|
-
const foreign: Record<string, string[]> =
|
|
464
|
+
const foreign: Record<string, string[]> = Object.create(null); // keyed by a live role name
|
|
465
465
|
for (const [role, privileges] of Object.entries(s.grants)) {
|
|
466
466
|
if (foreignGrantee(role)) foreign[role] = privileges;
|
|
467
467
|
}
|
|
@@ -540,13 +540,37 @@ export function declaredGranteesOf(models: ModelDescriptor[], opts: { schema?: s
|
|
|
540
540
|
return grantees;
|
|
541
541
|
}
|
|
542
542
|
|
|
543
|
+
/**
|
|
544
|
+
* A change the differ can only hold as a notice, but which the fingerprint SEES — so a plan
|
|
545
|
+
* that skips it predicts an endpoint its own apply can never reach: the verify after the apply
|
|
546
|
+
* fails with the rest of the edge already committed, and the next db:plan reads "Up to date" over
|
|
547
|
+
* a database db:fingerprint calls MISMATCH. The reason, or null for every other change (a notice
|
|
548
|
+
* the fingerprint does not see — a satisfied rename marker, a rename hint — mints as before).
|
|
549
|
+
*
|
|
550
|
+
* primaryKeyChange the key is hashed by its columns; without the live key's name the edge
|
|
551
|
+
* has nothing to drop it by.
|
|
552
|
+
* enumValuesChanged the enum's values are hashed; ADD VALUE is all the edge can do in place.
|
|
553
|
+
*/
|
|
554
|
+
function unplannableReason(change: SchemaChange): string | null {
|
|
555
|
+
switch (change.kind) {
|
|
556
|
+
case 'primaryKeyChange':
|
|
557
|
+
return `the primary key of ${change.table} changes (${change.from.join(', ')} → ${change.to.join(', ')}), ` +
|
|
558
|
+
'but the database read did not name the live key, so the plan has nothing to drop it by';
|
|
559
|
+
case 'enumValuesChanged':
|
|
560
|
+
return `enum ${change.name} changes its values ([${change.from.join(', ')}] → [${change.to.join(', ')}]) ` +
|
|
561
|
+
'in a way ALTER TYPE … ADD VALUE cannot make';
|
|
562
|
+
default:
|
|
563
|
+
return null;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
543
567
|
export function mintEdgePlan(
|
|
544
568
|
models: ModelDescriptor[],
|
|
545
569
|
snapshot: SchemaSnapshot,
|
|
546
570
|
contract: AuthzContract,
|
|
547
571
|
opts: MintOptions = {},
|
|
548
572
|
): EdgePlan {
|
|
549
|
-
const statements =
|
|
573
|
+
const { statements, changes } = generateMigration(models, snapshot, {
|
|
550
574
|
schema: opts.schema,
|
|
551
575
|
allowDrops: opts.allowDrops,
|
|
552
576
|
liveAuthz: contract,
|
|
@@ -571,6 +595,31 @@ export function mintEdgePlan(
|
|
|
571
595
|
const declaredGrantees = declaredGranteesOf(models, { schema: opts.schema });
|
|
572
596
|
const breakdown = partitionStatements(classified.executable, { declaredGrantees });
|
|
573
597
|
const destructive = breakdown.drops.length + breakdown.narrowings.length + breakdown.strips.length;
|
|
598
|
+
const governedRoles = governedRolesForModels(models, opts.governedRoles);
|
|
599
|
+
const from = fingerprintLive(snapshot, contract);
|
|
600
|
+
const to = predictLiveState(models, snapshot, contract, { schema: opts.schema, governedRoles, carryUngoverned: true });
|
|
601
|
+
|
|
602
|
+
// A plan must reach its own endpoint. Two ways it cannot, both refused here — the mint is the
|
|
603
|
+
// last point that holds the edge and both endpoints together.
|
|
604
|
+
//
|
|
605
|
+
// 1. A change held as a notice that the fingerprint sees. The rest of the edge would commit
|
|
606
|
+
// and the verify after it would fail. Checked FIRST: it names the precise reason.
|
|
607
|
+
const unplannable = changes.map(unplannableReason).filter((r): r is string => r !== null);
|
|
608
|
+
if (unplannable.length > 0) {
|
|
609
|
+
throw new Error(
|
|
610
|
+
`the planner cannot express ${unplannable.length} change(s), and the database would not match the models without them: ` +
|
|
611
|
+
`${unplannable.join('; ')}. Make the change by hand, then re-plan.`,
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
// 2. Nothing to run, yet the endpoints differ, whatever the cause. Minted, this is a plan that
|
|
615
|
+
// prints "Up to date" over a database db:fingerprint calls MISMATCH.
|
|
616
|
+
if (classified.executable.length === 0 && from.hash !== to.hash) {
|
|
617
|
+
const surfaces = divergentSurfaces(to.canonical, from.canonical);
|
|
618
|
+
throw new Error(
|
|
619
|
+
`the planner cannot express the difference between this database and the models (${surfaces.join('; ') || 'no surface named'}): ` +
|
|
620
|
+
'there is no statement to run, but the database would not match the models. Make the change by hand, then re-plan.',
|
|
621
|
+
);
|
|
622
|
+
}
|
|
574
623
|
|
|
575
624
|
// The plan's two endpoints are both FULL live hashes, deliberately:
|
|
576
625
|
// - `from` is the RAW live hash (everything introspected) — the concurrency lock and the
|
|
@@ -580,12 +629,11 @@ export function mintEdgePlan(
|
|
|
580
629
|
// empty plan means `from == to`, and db:apply verifies the full landed hash against it.
|
|
581
630
|
// The governed set still rides on the plan — the descent declares-test and db:check use it — but
|
|
582
631
|
// the endpoint itself is full.
|
|
583
|
-
const governedRoles = governedRolesForModels(models, opts.governedRoles);
|
|
584
632
|
return {
|
|
585
633
|
v: PLAN_VERSION,
|
|
586
|
-
fromFingerprint:
|
|
634
|
+
fromFingerprint: from.hash,
|
|
587
635
|
fpVersion: FINGERPRINT_VERSION,
|
|
588
|
-
toFingerprint:
|
|
636
|
+
toFingerprint: to.hash,
|
|
589
637
|
toFlavor: 'full',
|
|
590
638
|
defaultAclHash: defaultAclHash(contract),
|
|
591
639
|
declaredFingerprint: fingerprintModels(models, { schema: opts.schema }).hash,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The generated artifacts db:generate (and db:sync) refresh from the models on every run, before
|
|
3
|
+
* any database is read: the drizzle runtime schema, and beside it the model map (model-map.ts).
|
|
4
|
+
* One writer, so every verb that refreshes one refreshes both, and db:check's byte-for-byte gates
|
|
5
|
+
* judge exactly what was written. Writing them opens no connection.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from 'node:fs/promises';
|
|
9
|
+
import type { DerivedDescriptor, ModelDescriptor } from '@everystack/model';
|
|
10
|
+
import { compileDrizzleSource } from './schema-source';
|
|
11
|
+
import { modelMapPath, renderModelMap } from './model-map';
|
|
12
|
+
|
|
13
|
+
export async function writeGeneratedArtifacts(
|
|
14
|
+
schemaOut: string,
|
|
15
|
+
models: ModelDescriptor[],
|
|
16
|
+
derived: DerivedDescriptor[] | undefined,
|
|
17
|
+
): Promise<{ schemaOut: string; modelMapOut: string }> {
|
|
18
|
+
const modelMapOut = modelMapPath(schemaOut);
|
|
19
|
+
await fs.writeFile(schemaOut, compileDrizzleSource(models, { derived }), 'utf8');
|
|
20
|
+
await fs.writeFile(modelMapOut, renderModelMap(models), 'utf8');
|
|
21
|
+
return { schemaOut, modelMapOut };
|
|
22
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one check every db:pull renderer applies where a live PostgreSQL name becomes generated
|
|
3
|
+
* TypeScript that is NOT a string literal: a variable name, a bare object key, a file name or
|
|
4
|
+
* an import path.
|
|
5
|
+
*
|
|
6
|
+
* A quoted PostgreSQL name can hold any character. camelCase alone turned a view named
|
|
7
|
+
* `a=process.exit(1),b` into `export const a=process.exit(1),b = defineView(…)`, which ran on
|
|
8
|
+
* import, and a table named `/../../x` in a schema into a file written outside `--out`. So a
|
|
9
|
+
* name that cannot become a safe identifier or file name is REFUSED, naming the object and its
|
|
10
|
+
* live name. It is never renamed here: a rename would separate the generated symbol from the
|
|
11
|
+
* name it declares, and that is a design decision of its own.
|
|
12
|
+
*
|
|
13
|
+
* The live name itself keeps flowing as a string literal (`defineView('name', …)`), escaped by
|
|
14
|
+
* `tsString` in ts-template.ts.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
|
|
19
|
+
/** A db:pull refusal: a live name has no safe spelling in the generated source. */
|
|
20
|
+
export class UnsafeGeneratedNameError extends Error {
|
|
21
|
+
constructor(message: string) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = 'UnsafeGeneratedNameError';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The JavaScript IdentifierName grammar without escapes: a Unicode ID_Start (or `$`, `_`), then
|
|
29
|
+
* ID_Continue (or `$`). Unicode is kept because a name like `café` rendered and worked before.
|
|
30
|
+
* ZWJ/ZWNJ are left out on purpose: they are legal but invisible.
|
|
31
|
+
*/
|
|
32
|
+
const IDENTIFIER_NAME = /^[\p{ID_Start}$_][\p{ID_Continue}$]*$/u;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Words a binding cannot take in an ES module (strict mode): the reserved words, the strict
|
|
36
|
+
* future reserved words, and `eval`/`arguments`, which strict mode refuses as a binding.
|
|
37
|
+
* As an object key every one of these is legal, which is why a column named `class` renders.
|
|
38
|
+
*/
|
|
39
|
+
const RESERVED_BINDINGS = new Set([
|
|
40
|
+
'await', 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default', 'delete',
|
|
41
|
+
'do', 'else', 'enum', 'export', 'extends', 'false', 'finally', 'for', 'function', 'if', 'import',
|
|
42
|
+
'in', 'instanceof', 'new', 'null', 'return', 'super', 'switch', 'this', 'throw', 'true', 'try',
|
|
43
|
+
'typeof', 'var', 'void', 'while', 'with', 'yield', 'let', 'static', 'implements', 'interface',
|
|
44
|
+
'package', 'private', 'protected', 'public', 'eval', 'arguments',
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
/** A file stem: identifier characters and `-`. No `/`, `\`, `.`, so no `..` and no leading dot. */
|
|
48
|
+
const FILE_STEM = /^[\p{ID_Continue}$-]+$/u;
|
|
49
|
+
|
|
50
|
+
/** `view "public.v"` — how a refusal names the live object. */
|
|
51
|
+
export function liveObject(kind: string, liveName: string): string {
|
|
52
|
+
return `${kind} ${JSON.stringify(liveName)}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function refuse(what: string, role: string, rendered: string, why: string): never {
|
|
56
|
+
throw new UnsafeGeneratedNameError(
|
|
57
|
+
`db:pull refuses ${what}: it would render as the TypeScript ${role} ${JSON.stringify(rendered)}, which ${why}. `
|
|
58
|
+
+ 'Nothing was written. Rename it in the database, or pull a schema that does not hold it.',
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A generated `export const` / import binding. Returns `rendered` unchanged when it is safe. */
|
|
63
|
+
export function safeBinding(rendered: string, what: string): string {
|
|
64
|
+
if (!IDENTIFIER_NAME.test(rendered)) refuse(what, 'variable', rendered, 'is not a JavaScript identifier');
|
|
65
|
+
if (RESERVED_BINDINGS.has(rendered)) refuse(what, 'variable', rendered, 'is a reserved word');
|
|
66
|
+
return rendered;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A generated bare object key. Reserved words are legal keys, so a column named `class` renders. */
|
|
70
|
+
export function safeKey(rendered: string, what: string): string {
|
|
71
|
+
if (!IDENTIFIER_NAME.test(rendered)) refuse(what, 'object key', rendered, 'is not a JavaScript identifier');
|
|
72
|
+
refuseProtoKey(rendered, what);
|
|
73
|
+
return rendered;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* `__proto__` written as a key in an object literal, bare or quoted, SETS the object's
|
|
78
|
+
* prototype instead of adding the entry, so the entry silently vanishes. Every other name
|
|
79
|
+
* (`constructor`, `prototype`) is an ordinary key in a literal. Used by the renderers that
|
|
80
|
+
* quote a non-identifier key, too: quoting does not change this.
|
|
81
|
+
*/
|
|
82
|
+
export function refuseProtoKey(rendered: string, what: string): void {
|
|
83
|
+
if (rendered === '__proto__') refuse(what, 'object key', rendered, 'sets the object prototype instead of adding the entry');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A generated file name without its extension, also used as an import specifier. */
|
|
87
|
+
export function safeFileStem(rendered: string, what: string): string {
|
|
88
|
+
if (!FILE_STEM.test(rendered)) {
|
|
89
|
+
refuse(what, 'file name', rendered, 'is not a safe file name (letters, digits, _, $ and - only: no path separator, no dot)');
|
|
90
|
+
}
|
|
91
|
+
return rendered;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The path a generated file is written to, refused unless it resolves inside `dir`. The file
|
|
96
|
+
* names are already safe stems; this is the second wall at the write itself, so no future
|
|
97
|
+
* renderer can write outside the directory the operator named.
|
|
98
|
+
*/
|
|
99
|
+
export function containedPath(dir: string, file: string): string {
|
|
100
|
+
const root = path.resolve(dir);
|
|
101
|
+
const target = path.resolve(root, file);
|
|
102
|
+
if (path.dirname(target) !== root) {
|
|
103
|
+
throw new UnsafeGeneratedNameError(
|
|
104
|
+
`db:pull refuses to write ${JSON.stringify(file)}: it resolves to ${target}, outside ${root}. Nothing was written.`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
return target;
|
|
108
|
+
}
|