@everystack/cli 0.4.100 → 0.4.103
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 +112 -0
- package/package.json +3 -3
- package/src/cli/authz-baseline.ts +8 -4
- package/src/cli/authz-compile.ts +91 -14
- package/src/cli/authz-contract.ts +29 -14
- package/src/cli/authz-derive.ts +284 -30
- package/src/cli/authz-reconcile.ts +6 -4
- 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 +32 -13
- package/src/cli/commands/db-pull.ts +155 -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 +5 -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 +92 -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-lock.ts +65 -0
- package/src/cli/index.ts +5 -5
- package/src/cli/lifecycle-canon.ts +37 -0
- package/src/cli/lifecycle-derived.ts +787 -0
- package/src/cli/lifecycle-pull.ts +252 -0
- package/src/cli/lifecycle-verify.ts +238 -0
- package/src/cli/migration-compile.ts +11 -5
- package/src/cli/migration-generate.ts +24 -3
- package/src/cli/model-api.ts +9 -0
- package/src/cli/model-map.ts +255 -0
- package/src/cli/model-render.ts +232 -76
- package/src/cli/plain-output.ts +211 -10
- package/src/cli/runbook/model-section.ts +8 -5
- package/src/cli/schema-compile.ts +64 -18
- package/src/cli/schema-diff.ts +166 -31
- package/src/cli/schema-fingerprint.ts +40 -6
- package/src/cli/schema-introspect.ts +97 -4
- 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
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
|
@@ -24,20 +24,21 @@
|
|
|
24
24
|
* JSON — recorded as `plan_ref` on the schema_log row the apply writes.
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
+
import { indexLockWarnings, type LockWarning } from './index-lock';
|
|
27
28
|
import { createHash } from 'node:crypto';
|
|
28
29
|
import type { Attachment } from './derived-source';
|
|
29
30
|
import type { OwnerApplyMode } from './derived-apply';
|
|
30
31
|
import type { ModelDescriptor } from '@everystack/model';
|
|
31
32
|
import type { SchemaSnapshot, TableSchema } from './schema-introspect';
|
|
32
33
|
import type { AuthzContract, SequenceGrantContract, DefaultAclScope } from './authz-contract';
|
|
33
|
-
import { ownSequenceName, foreignKeyContentKey } from './schema-diff';
|
|
34
|
-
import {
|
|
34
|
+
import { ownSequenceName, foreignKeyContentKey, type SchemaChange } from './schema-diff';
|
|
35
|
+
import { generateMigration, unmodeledTables } from './migration-generate';
|
|
35
36
|
import { compileTableContract } from './authz-compile';
|
|
36
37
|
import { carryUngovernedAuthz, inheritedSequenceGrants, type GrantExemption } from './authz-reconcile';
|
|
37
38
|
import { BASELINE_FILE } from './authz-baseline';
|
|
38
39
|
import { classifyGeneratedStatements, classifyDestructive, partitionStatements, renderStatementHistogram } from './state-apply';
|
|
39
40
|
import { statementHead, statementHeadOrSelf } from './sql-statement';
|
|
40
|
-
import { fingerprintLive, fingerprintState, fingerprintModels, governedRolesForModels, stableStringify, compareStoredFingerprint, formatChangedMessage, schemaOf, FINGERPRINT_VERSION, type Fingerprint } from './schema-fingerprint';
|
|
41
|
+
import { fingerprintLive, fingerprintState, fingerprintModels, governedRolesForModels, stableStringify, compareStoredFingerprint, formatChangedMessage, schemaOf, divergentSurfaces, FINGERPRINT_VERSION, type Fingerprint } from './schema-fingerprint';
|
|
41
42
|
import { compileDeclaredState } from './declared-diff';
|
|
42
43
|
import { compileTableRenames, compileTableMoves, compileRenames } from './schema-compile';
|
|
43
44
|
|
|
@@ -253,6 +254,18 @@ export interface EdgePlan {
|
|
|
253
254
|
* plans minted before this field existed; the summary falls back to the text-only reading.
|
|
254
255
|
*/
|
|
255
256
|
dark?: string[];
|
|
257
|
+
/**
|
|
258
|
+
* The plain CREATE INDEX statements that build on a live table with rows, with the catalog's row
|
|
259
|
+
* estimate — db:plan's ⚠ LOCK lines. Read at mint, like `dark`; informational, never a gate, and
|
|
260
|
+
* outside both fingerprints. Absent on plans minted before it, or when there is nothing to warn.
|
|
261
|
+
*/
|
|
262
|
+
lockWarnings?: LockWarning[];
|
|
263
|
+
/**
|
|
264
|
+
* The read policies this plan creates or replaces for a can('read', { role, deleted }) ability:
|
|
265
|
+
* db:plan's `⚠ ACCESS — role <r> can read deleted rows of <table>` lines. Informational, outside
|
|
266
|
+
* both fingerprints. Absent when there is none, and on plans minted before it.
|
|
267
|
+
*/
|
|
268
|
+
deletedReads?: DeletedRead[];
|
|
256
269
|
/** Live tables no model declares — untouched by the edge, riding into `to` as-is. */
|
|
257
270
|
unmodeled: string[];
|
|
258
271
|
gitRef: string | null;
|
|
@@ -408,7 +421,7 @@ export function predictLiveState(
|
|
|
408
421
|
// sets those explicitly and the declared contract already models them.
|
|
409
422
|
const defaultAclFor = (scope: DefaultAclScope | undefined, schema: string): Record<string, string[]> => {
|
|
410
423
|
if (!scope) return {};
|
|
411
|
-
const out: Record<string, string[]> =
|
|
424
|
+
const out: Record<string, string[]> = Object.create(null); // keyed by a live role name
|
|
412
425
|
for (const key of ['', schema]) {
|
|
413
426
|
for (const [grantee, privileges] of Object.entries(scope[key] ?? {})) {
|
|
414
427
|
if (foreignGrantee(grantee)) out[grantee] = [...new Set([...(out[grantee] ?? []), ...privileges])].sort();
|
|
@@ -461,7 +474,7 @@ export function predictLiveState(
|
|
|
461
474
|
const inherited = inheritedSequenceGrants(d);
|
|
462
475
|
const liveName = liveNameFor(d.table);
|
|
463
476
|
for (const s of liveSeqByTable.get(liveName) ?? []) {
|
|
464
|
-
const foreign: Record<string, string[]> =
|
|
477
|
+
const foreign: Record<string, string[]> = Object.create(null); // keyed by a live role name
|
|
465
478
|
for (const [role, privileges] of Object.entries(s.grants)) {
|
|
466
479
|
if (foreignGrantee(role)) foreign[role] = privileges;
|
|
467
480
|
}
|
|
@@ -540,13 +553,57 @@ export function declaredGranteesOf(models: ModelDescriptor[], opts: { schema?: s
|
|
|
540
553
|
return grantees;
|
|
541
554
|
}
|
|
542
555
|
|
|
556
|
+
/**
|
|
557
|
+
* A change the differ can only hold as a notice, but which the fingerprint SEES — so a plan
|
|
558
|
+
* that skips it predicts an endpoint its own apply can never reach: the verify after the apply
|
|
559
|
+
* fails with the rest of the edge already committed, and the next db:plan reads "Up to date" over
|
|
560
|
+
* a database db:fingerprint calls MISMATCH. The reason, or null for every other change (a notice
|
|
561
|
+
* the fingerprint does not see — a satisfied rename marker, a rename hint — mints as before).
|
|
562
|
+
*
|
|
563
|
+
* primaryKeyChange the key is hashed by its columns; without the live key's name the edge
|
|
564
|
+
* has nothing to drop it by.
|
|
565
|
+
* enumValuesChanged the enum's values are hashed; ADD VALUE is all the edge can do in place.
|
|
566
|
+
*/
|
|
567
|
+
function unplannableReason(change: SchemaChange): string | null {
|
|
568
|
+
switch (change.kind) {
|
|
569
|
+
case 'primaryKeyChange':
|
|
570
|
+
return `the primary key of ${change.table} changes (${change.from.join(', ')} → ${change.to.join(', ')}), ` +
|
|
571
|
+
'but the database read did not name the live key, so the plan has nothing to drop it by';
|
|
572
|
+
case 'enumValuesChanged':
|
|
573
|
+
return `enum ${change.name} changes its values ([${change.from.join(', ')}] → [${change.to.join(', ')}]) ` +
|
|
574
|
+
'in a way ALTER TYPE … ADD VALUE cannot make';
|
|
575
|
+
default:
|
|
576
|
+
return null;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** A read policy, created or replaced by this plan, that lets a role see deleted lifecycle rows. */
|
|
581
|
+
export interface DeletedRead { table: string; role: string; deleted: 'include' | 'only' }
|
|
582
|
+
|
|
583
|
+
/** The can('read', { role, deleted }) policies whose CREATE POLICY this plan runs. */
|
|
584
|
+
export function deletedReadChanges(models: readonly ModelDescriptor[], statements: readonly string[]): DeletedRead[] {
|
|
585
|
+
const out: DeletedRead[] = [];
|
|
586
|
+
for (const m of models) {
|
|
587
|
+
if (m.lifecycle !== true) continue;
|
|
588
|
+
for (const a of m.abilities) {
|
|
589
|
+
if (a.action !== 'read' || !a.condition.deleted || !a.condition.role) continue;
|
|
590
|
+
const name = `${m.table}_select_${a.condition.role.toLowerCase()}`;
|
|
591
|
+
// The emitter writes the name bare when it is a plain identifier, quoted otherwise.
|
|
592
|
+
const created = new RegExp(`^\\s*CREATE POLICY (?:"${name}"|${name}) `, 'm');
|
|
593
|
+
if (!statements.some((st) => created.test(st))) continue;
|
|
594
|
+
out.push({ table: m.schema && m.schema !== 'public' ? `${m.schema}.${m.table}` : m.table, role: a.condition.role, deleted: a.condition.deleted });
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
return out;
|
|
598
|
+
}
|
|
599
|
+
|
|
543
600
|
export function mintEdgePlan(
|
|
544
601
|
models: ModelDescriptor[],
|
|
545
602
|
snapshot: SchemaSnapshot,
|
|
546
603
|
contract: AuthzContract,
|
|
547
604
|
opts: MintOptions = {},
|
|
548
605
|
): EdgePlan {
|
|
549
|
-
const statements =
|
|
606
|
+
const { statements, changes } = generateMigration(models, snapshot, {
|
|
550
607
|
schema: opts.schema,
|
|
551
608
|
allowDrops: opts.allowDrops,
|
|
552
609
|
liveAuthz: contract,
|
|
@@ -571,6 +628,31 @@ export function mintEdgePlan(
|
|
|
571
628
|
const declaredGrantees = declaredGranteesOf(models, { schema: opts.schema });
|
|
572
629
|
const breakdown = partitionStatements(classified.executable, { declaredGrantees });
|
|
573
630
|
const destructive = breakdown.drops.length + breakdown.narrowings.length + breakdown.strips.length;
|
|
631
|
+
const governedRoles = governedRolesForModels(models, opts.governedRoles);
|
|
632
|
+
const from = fingerprintLive(snapshot, contract);
|
|
633
|
+
const to = predictLiveState(models, snapshot, contract, { schema: opts.schema, governedRoles, carryUngoverned: true });
|
|
634
|
+
|
|
635
|
+
// A plan must reach its own endpoint. Two ways it cannot, both refused here — the mint is the
|
|
636
|
+
// last point that holds the edge and both endpoints together.
|
|
637
|
+
//
|
|
638
|
+
// 1. A change held as a notice that the fingerprint sees. The rest of the edge would commit
|
|
639
|
+
// and the verify after it would fail. Checked FIRST: it names the precise reason.
|
|
640
|
+
const unplannable = changes.map(unplannableReason).filter((r): r is string => r !== null);
|
|
641
|
+
if (unplannable.length > 0) {
|
|
642
|
+
throw new Error(
|
|
643
|
+
`the planner cannot express ${unplannable.length} change(s), and the database would not match the models without them: ` +
|
|
644
|
+
`${unplannable.join('; ')}. Make the change by hand, then re-plan.`,
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
// 2. Nothing to run, yet the endpoints differ, whatever the cause. Minted, this is a plan that
|
|
648
|
+
// prints "Up to date" over a database db:fingerprint calls MISMATCH.
|
|
649
|
+
if (classified.executable.length === 0 && from.hash !== to.hash) {
|
|
650
|
+
const surfaces = divergentSurfaces(to.canonical, from.canonical);
|
|
651
|
+
throw new Error(
|
|
652
|
+
`the planner cannot express the difference between this database and the models (${surfaces.join('; ') || 'no surface named'}): ` +
|
|
653
|
+
'there is no statement to run, but the database would not match the models. Make the change by hand, then re-plan.',
|
|
654
|
+
);
|
|
655
|
+
}
|
|
574
656
|
|
|
575
657
|
// The plan's two endpoints are both FULL live hashes, deliberately:
|
|
576
658
|
// - `from` is the RAW live hash (everything introspected) — the concurrency lock and the
|
|
@@ -580,12 +662,11 @@ export function mintEdgePlan(
|
|
|
580
662
|
// empty plan means `from == to`, and db:apply verifies the full landed hash against it.
|
|
581
663
|
// The governed set still rides on the plan — the descent declares-test and db:check use it — but
|
|
582
664
|
// the endpoint itself is full.
|
|
583
|
-
const governedRoles = governedRolesForModels(models, opts.governedRoles);
|
|
584
665
|
return {
|
|
585
666
|
v: PLAN_VERSION,
|
|
586
|
-
fromFingerprint:
|
|
667
|
+
fromFingerprint: from.hash,
|
|
587
668
|
fpVersion: FINGERPRINT_VERSION,
|
|
588
|
-
toFingerprint:
|
|
669
|
+
toFingerprint: to.hash,
|
|
589
670
|
toFlavor: 'full',
|
|
590
671
|
defaultAclHash: defaultAclHash(contract),
|
|
591
672
|
declaredFingerprint: fingerprintModels(models, { schema: opts.schema }).hash,
|
|
@@ -612,6 +693,8 @@ export function mintEdgePlan(
|
|
|
612
693
|
// contract. The summary is rendered later from the plan alone, and answering "does a read
|
|
613
694
|
// survive?" from statement text is what produced three false positives on a real plan.
|
|
614
695
|
dark: tablesLeftWithoutARead(statements, contract),
|
|
696
|
+
...(() => { const lockWarnings = indexLockWarnings(statements, snapshot); return lockWarnings.length ? { lockWarnings } : {}; })(),
|
|
697
|
+
...(() => { const deletedReads = deletedReadChanges(models, statements); return deletedReads.length ? { deletedReads } : {}; })(),
|
|
615
698
|
unmodeled: unmodeledTables(models, snapshot),
|
|
616
699
|
gitRef: opts.gitRef ?? null,
|
|
617
700
|
mintedBy: opts.actor ?? null,
|
|
@@ -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
|
+
}
|