@everystack/cli 0.4.97 → 0.4.99
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 +43 -0
- package/package.json +13 -3
- package/src/cli/apply-execute.ts +80 -23
- package/src/cli/authz-manifest.ts +93 -0
- package/src/cli/authz-redteam.ts +19 -10
- package/src/cli/backfill.ts +10 -4
- package/src/cli/backup.ts +21 -0
- package/src/cli/commands/db-apply.ts +4 -14
- package/src/cli/commands/db-backup.ts +56 -17
- package/src/cli/commands/db-check.ts +16 -3
- package/src/cli/commands/db-fork.ts +12 -4
- package/src/cli/commands/db-generate.ts +7 -3
- package/src/cli/commands/db-plan.ts +6 -0
- package/src/cli/commands/db.ts +3 -2
- package/src/cli/commands/runbook.ts +3 -0
- package/src/cli/connection-fence.ts +351 -0
- package/src/cli/db-source.ts +13 -6
- package/src/cli/declared-derived.ts +6 -1
- package/src/cli/derived-apply.ts +20 -5
- package/src/cli/derived-plan.ts +2 -2
- package/src/cli/edge-plan.ts +66 -2
- package/src/cli/index.ts +1 -1
- package/src/cli/mutation-lease.ts +5 -0
- package/src/cli/plain-output.ts +32 -10
- package/src/cli/runbook/model-section.ts +5 -2
- package/src/cli/select-grant-advisory.ts +132 -0
- package/src/cli/stage-backup.ts +57 -16
- package/src/cli/state-apply.ts +122 -6
- package/src/cli/state-function-deps.ts +91 -0
package/src/cli/state-apply.ts
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
import { execSync, execFileSync } from 'node:child_process';
|
|
21
21
|
import type { ModelDescriptor } from '@everystack/model';
|
|
22
22
|
import { introspectContract, type AuthzContract, type QueryRunner } from './authz-contract';
|
|
23
|
-
import type
|
|
23
|
+
import { borrowedSessionRunner, type SessionRunner } from './session';
|
|
24
24
|
import { introspectSchema, type SchemaSnapshot } from './schema-introspect';
|
|
25
25
|
import { FUNCTIONS_SQL, contractFunctionRow } from './security-catalog';
|
|
26
26
|
import { generateMigrationSql, HELD_DROP_PREFIX } from './migration-generate';
|
|
@@ -28,13 +28,19 @@ import type { SequenceDescriptor } from '@everystack/model';
|
|
|
28
28
|
import { fingerprintLive, FINGERPRINT_VERSION } from './schema-fingerprint';
|
|
29
29
|
import {
|
|
30
30
|
ENSURE_RECONCILER_SQL,
|
|
31
|
+
renderProvenanceUpsert,
|
|
31
32
|
renderSchemaLogInsert,
|
|
32
33
|
renderSchemaLogFingerprintUpdate,
|
|
33
34
|
renderSchemaLogCompletionUpdate,
|
|
34
35
|
} from './derived-apply';
|
|
36
|
+
import { introspectDerived } from './derived-introspect';
|
|
37
|
+
import { objectGrantDelta } from './derived-plan';
|
|
35
38
|
import { isPureComment, statementBody, statementHead } from './sql-statement';
|
|
39
|
+
import { splitFunctionIdentity } from './pg-argtypes';
|
|
40
|
+
import type { StatePrerequisite } from './edge-plan';
|
|
36
41
|
|
|
37
42
|
const NOTICE_PREFIX = '-- NOTICE';
|
|
43
|
+
export const STATE_PREREQUISITE_DRIFT_PREFIX = 'state prerequisite drift:';
|
|
38
44
|
|
|
39
45
|
export interface ClassifiedStatements {
|
|
40
46
|
/** Real DDL (possibly carrying leading -- WARNING lines) — what apply runs. */
|
|
@@ -354,6 +360,18 @@ export function worktreeDirty(modelsPath: string, cwd: string = process.cwd()):
|
|
|
354
360
|
}
|
|
355
361
|
|
|
356
362
|
export interface StateApplyOptions {
|
|
363
|
+
/**
|
|
364
|
+
* Reviewed functions that must be absent and then created before this state batch. They join
|
|
365
|
+
* the same SQL request as the policy that needs them: a collision or a later DDL error rolls
|
|
366
|
+
* back both, rather than leaving a derived object ahead of the state it serves.
|
|
367
|
+
*/
|
|
368
|
+
statePrerequisites?: readonly StatePrerequisite[];
|
|
369
|
+
/**
|
|
370
|
+
* An explicit write transaction over one pinned connection. Prerequisites need this because
|
|
371
|
+
* their DDL, the state edge, and their catalog-derived provenance must commit or roll back
|
|
372
|
+
* together. A session-only or pooled QueryRunner cannot make that promise.
|
|
373
|
+
*/
|
|
374
|
+
transaction?: <T>(work: (runner: QueryRunner) => Promise<T>) => Promise<T>;
|
|
357
375
|
actor?: string | null;
|
|
358
376
|
gitRef?: string | null;
|
|
359
377
|
planRef?: string | null;
|
|
@@ -406,6 +424,18 @@ export interface StateApplyOptions {
|
|
|
406
424
|
skippedValidations?: readonly PendingValidation[];
|
|
407
425
|
}
|
|
408
426
|
|
|
427
|
+
const sqlLiteral = (value: string): string => `'${value.replace(/'/g, "''")}'`;
|
|
428
|
+
|
|
429
|
+
/** The catalog guard belongs in the batch, not in a preceding read: a concurrent creator then
|
|
430
|
+
* causes the whole batch to roll back instead of racing past an out-of-date plan. */
|
|
431
|
+
export function renderStatePrerequisiteGuard(prerequisite: StatePrerequisite): string {
|
|
432
|
+
const { qualified, args } = splitFunctionIdentity(prerequisite.identity);
|
|
433
|
+
const dot = qualified.lastIndexOf('.');
|
|
434
|
+
const schema = dot === -1 ? 'public' : qualified.slice(0, dot);
|
|
435
|
+
const name = dot === -1 ? qualified : qualified.slice(dot + 1);
|
|
436
|
+
return `DO $everystack_prerequisite$\nBEGIN\n IF EXISTS (\n SELECT 1 FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace\n WHERE n.nspname = ${sqlLiteral(schema)}\n AND p.proname = ${sqlLiteral(name)}\n AND (SELECT COALESCE(string_agg(pg_catalog.format_type(t.oid, NULL), ', ' ORDER BY t.ord), '')\n FROM unnest(p.proargtypes::oid[]) WITH ORDINALITY AS t(oid, ord)) = ${sqlLiteral(args ?? '')}\n ) THEN\n RAISE EXCEPTION ${sqlLiteral(`${STATE_PREREQUISITE_DRIFT_PREFIX} ${prerequisite.identity} is already present; the target changed since minting. Re-mint with db:plan.`)};\n END IF;\nEND\n$everystack_prerequisite$`;
|
|
437
|
+
}
|
|
438
|
+
|
|
409
439
|
/** A `VALIDATE CONSTRAINT` that did not pass — the key is installed but still unproven. */
|
|
410
440
|
export interface PendingValidation {
|
|
411
441
|
/** The exact statement to re-run once the data satisfies the key. */
|
|
@@ -475,17 +505,24 @@ async function recordPendingValidations(
|
|
|
475
505
|
}
|
|
476
506
|
}
|
|
477
507
|
|
|
508
|
+
/** Escape strings independently of standard_conforming_strings, including captured session values. */
|
|
509
|
+
function renderLocalSearchPath(path: string): string {
|
|
510
|
+
const literal = "E'" + path.replace(/\\/g, '\\\\').replace(/'/g, "''") + "'";
|
|
511
|
+
return `SELECT pg_catalog.set_config('search_path', ${literal}, true)`;
|
|
512
|
+
}
|
|
513
|
+
|
|
478
514
|
export async function applyGeneratedStatements(
|
|
479
515
|
runner: QueryRunner,
|
|
480
516
|
statements: string[],
|
|
481
517
|
options: StateApplyOptions = {},
|
|
482
518
|
): Promise<StateApplyResult> {
|
|
483
519
|
const classified = classifyGeneratedStatements(statements);
|
|
520
|
+
const prerequisites = options.statePrerequisites ?? [];
|
|
484
521
|
// Checks the caller withheld before calling. They are outstanding from the first line of this
|
|
485
522
|
// function: the outcome written before the batch already counts them, and they come back in the
|
|
486
523
|
// result whether or not any check actually ran here.
|
|
487
524
|
const skipped: PendingValidation[] = [...(options.skippedValidations ?? [])];
|
|
488
|
-
if (classified.executable.length === 0) {
|
|
525
|
+
if (classified.executable.length === 0 && prerequisites.length === 0) {
|
|
489
526
|
// No DDL to run — but a re-plan against keys a PREVIOUS apply already withheld still owes
|
|
490
527
|
// the audit trail a row: the promise (docs/cli.md) is every apply lands in schema_log, and
|
|
491
528
|
// an apply whose only content is "these keys are still unproven" is a real apply, not a
|
|
@@ -533,12 +570,91 @@ export async function applyGeneratedStatements(
|
|
|
533
570
|
// `batchSql` is the batch that RAN IN THIS TRANSACTION — the checks are not in it. The record
|
|
534
571
|
// written below is a record of what committed, and the checks had not been attempted at that
|
|
535
572
|
// point; they join the row on the amendment, where their outcome is known.
|
|
536
|
-
const
|
|
573
|
+
const prerequisiteSql = prerequisites
|
|
574
|
+
.flatMap((prerequisite) => [renderStatePrerequisiteGuard(prerequisite), prerequisite.sql]);
|
|
575
|
+
// A SQL-language prerequisite may read a table this same state edge creates. PostgreSQL
|
|
576
|
+
// validates such bodies at CREATE time unless this transaction-local setting defers it.
|
|
577
|
+
let batchSql = [...(prerequisites.length > 0 ? ['SET LOCAL check_function_bodies = off'] : []), ...prerequisiteSql, ...batch].join('\n;\n');
|
|
537
578
|
const validationSql = validations.join('\n;\n');
|
|
538
|
-
const fullSql = [batchSql, validationSql].filter((s) => s.length > 0).join('\n;\n');
|
|
539
579
|
const started = now();
|
|
540
580
|
try {
|
|
541
|
-
if (
|
|
581
|
+
if (prerequisites.length > 0) {
|
|
582
|
+
if (!options.transaction) {
|
|
583
|
+
throw new Error('state prerequisites require a pinned write transaction; this apply lane cannot prove atomic provenance.');
|
|
584
|
+
}
|
|
585
|
+
await options.transaction(async (transactionRunner) => {
|
|
586
|
+
const [settings] = await transactionRunner("SELECT pg_catalog.current_setting('search_path') AS search_path");
|
|
587
|
+
const originalPath = settings?.search_path;
|
|
588
|
+
if (typeof originalPath !== 'string') throw new Error('could not capture prerequisite search_path; no state was applied.');
|
|
589
|
+
// Defaults and SQL-standard bodies bind names at CREATE time. Use the full declared
|
|
590
|
+
// path recorded at mint, then restore the exact connection setting before state DDL.
|
|
591
|
+
batchSql = ['SET LOCAL check_function_bodies = off', ...prerequisites.flatMap((prerequisite) => [
|
|
592
|
+
renderStatePrerequisiteGuard(prerequisite),
|
|
593
|
+
...(prerequisite.searchPath === null ? [] : [renderLocalSearchPath(prerequisite.searchPath)]),
|
|
594
|
+
prerequisite.sql,
|
|
595
|
+
...(prerequisite.searchPath === null ? [] : [renderLocalSearchPath(originalPath)]),
|
|
596
|
+
]), ...batch].join('\n;\n');
|
|
597
|
+
await transactionRunner(batchSql);
|
|
598
|
+
// `defHash` is PostgreSQL's canonical deparse, so source text cannot predict it. Read it
|
|
599
|
+
// after the CREATE through the OPEN transaction, then write provenance before COMMIT.
|
|
600
|
+
const reread = async () => new Map((await introspectDerived(borrowedSessionRunner(transactionRunner)))
|
|
601
|
+
.objects.map((object) => [object.identity, object]));
|
|
602
|
+
let liveByIdentity = await reread();
|
|
603
|
+
const grantDelta = (prerequisite: StatePrerequisite) => {
|
|
604
|
+
const live = liveByIdentity.get(prerequisite.identity);
|
|
605
|
+
if (!live || live.grants === undefined) {
|
|
606
|
+
throw new Error(`state prerequisite ${prerequisite.identity} has no readable grant catalog; no provenance was written.`);
|
|
607
|
+
}
|
|
608
|
+
const contract = prerequisite.grantContract;
|
|
609
|
+
if (contract.owner && live.owner !== contract.owner) {
|
|
610
|
+
throw new Error(`state prerequisite ${prerequisite.identity} owner did not converge to ${contract.owner}; no provenance was written.`);
|
|
611
|
+
}
|
|
612
|
+
const delta = objectGrantDelta({ ...contract, identity: prerequisite.identity, kind: 'function' }, live);
|
|
613
|
+
if (!delta || delta.columnScoped || delta.suppressed.length > 0) {
|
|
614
|
+
throw new Error(`state prerequisite ${prerequisite.identity} grants cannot be verified; no provenance was written.`);
|
|
615
|
+
}
|
|
616
|
+
return delta.statements;
|
|
617
|
+
};
|
|
618
|
+
// CREATE inherits default ACLs, and ALTER OWNER can remove a declared builder grant.
|
|
619
|
+
// Settle from the actual catalog using the same grant diff as derived reconcile.
|
|
620
|
+
const settle = prerequisites.flatMap((prerequisite) => {
|
|
621
|
+
const statements = grantDelta(prerequisite);
|
|
622
|
+
const { owner, ownerMode } = prerequisite.grantContract;
|
|
623
|
+
return statements.length > 0 && owner && ownerMode === 'set-role'
|
|
624
|
+
? [`SET ROLE ${owner}`, ...statements, 'RESET ROLE'] : statements;
|
|
625
|
+
});
|
|
626
|
+
if (settle.length > 0) {
|
|
627
|
+
const settleSql = settle.join('\n;\n');
|
|
628
|
+
batchSql += `\n;\n${settleSql}`;
|
|
629
|
+
await transactionRunner(settleSql);
|
|
630
|
+
liveByIdentity = await reread();
|
|
631
|
+
}
|
|
632
|
+
// A successful REVOKE can be a warning-only no-op. Only a second catalog read proves it.
|
|
633
|
+
for (const prerequisite of prerequisites) {
|
|
634
|
+
const remaining = grantDelta(prerequisite);
|
|
635
|
+
if (remaining.length > 0) {
|
|
636
|
+
throw new Error(`state prerequisite ${prerequisite.identity} grants did not converge: still needs ${remaining.join('; ')}. No provenance was written.`);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
const provenanceSql = prerequisites.map((prerequisite) => {
|
|
640
|
+
const liveObject = liveByIdentity.get(prerequisite.identity);
|
|
641
|
+
if (!liveObject) {
|
|
642
|
+
throw new Error(`state prerequisite ${prerequisite.identity} was created but is not introspectable before commit; provenance was not recorded.`);
|
|
643
|
+
}
|
|
644
|
+
return renderProvenanceUpsert(
|
|
645
|
+
prerequisite.identity,
|
|
646
|
+
prerequisite.provenance.srcHash,
|
|
647
|
+
liveObject.defHash,
|
|
648
|
+
prerequisite.provenance.kind,
|
|
649
|
+
prerequisite.provenance.dropSql,
|
|
650
|
+
prerequisite.provenance.bodyHash,
|
|
651
|
+
);
|
|
652
|
+
});
|
|
653
|
+
await transactionRunner(provenanceSql.join('\n;\n'));
|
|
654
|
+
});
|
|
655
|
+
} else if (batchSql.length > 0) {
|
|
656
|
+
await runner(batchSql);
|
|
657
|
+
}
|
|
542
658
|
} catch (err: any) {
|
|
543
659
|
try {
|
|
544
660
|
await runner(renderSchemaLogInsert({
|
|
@@ -606,7 +722,7 @@ export async function applyGeneratedStatements(
|
|
|
606
722
|
try {
|
|
607
723
|
await runner(renderSchemaLogCompletionUpdate(logId, {
|
|
608
724
|
outcome: outcomeWith(pendingValidations.length),
|
|
609
|
-
sql:
|
|
725
|
+
sql: [batchSql, validationSql].filter((s) => s.length > 0).join('\n;\n'),
|
|
610
726
|
durationMs: now() - started,
|
|
611
727
|
}));
|
|
612
728
|
} catch { /* the memoir is best-effort; the row already says what committed */ }
|
|
@@ -25,7 +25,20 @@ import type { QueryRunner } from './authz-contract';
|
|
|
25
25
|
import type { SessionRunner } from './session';
|
|
26
26
|
import type { SourceObject } from './derived-source';
|
|
27
27
|
import { executeReconcile } from './commands/db-reconcile';
|
|
28
|
+
import { introspectDerived } from './derived-introspect';
|
|
29
|
+
import { planReconcile } from './derived-plan';
|
|
30
|
+
import {
|
|
31
|
+
builderOwnerMode,
|
|
32
|
+
derivedSearchPath,
|
|
33
|
+
ownerPreflightRefusal,
|
|
34
|
+
ownerPreflightSql,
|
|
35
|
+
ownerRequirements,
|
|
36
|
+
renderReconcileSql,
|
|
37
|
+
renderEnsureObjectSchemas,
|
|
38
|
+
} from './derived-apply';
|
|
28
39
|
import { functionIdentity } from './pg-argtypes';
|
|
40
|
+
import { quoteIdent } from './pg-ident';
|
|
41
|
+
import type { StatePrerequisite } from './edge-plan';
|
|
29
42
|
|
|
30
43
|
const escapeRe = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
31
44
|
|
|
@@ -48,6 +61,84 @@ export function stateReferencedFunctions(statements: readonly string[], declared
|
|
|
48
61
|
&& callPattern(o.schema, o.name).test(text));
|
|
49
62
|
}
|
|
50
63
|
|
|
64
|
+
/**
|
|
65
|
+
* The plan artifact's complete prerequisite list. Liveness is supplied by the command shell so
|
|
66
|
+
* this stays pure alongside `stateReferencedFunctions`: db:diff and unit tests can make the same
|
|
67
|
+
* decision without opening a database, while db:plan provides the target's exact identities.
|
|
68
|
+
*/
|
|
69
|
+
export function planStatePrerequisites(
|
|
70
|
+
statements: readonly string[],
|
|
71
|
+
declared: readonly SourceObject[],
|
|
72
|
+
present: ReadonlySet<string>,
|
|
73
|
+
): StatePrerequisite[] {
|
|
74
|
+
const schemas = derivedSearchPath([...declared]);
|
|
75
|
+
const searchPath = schemas.length === 1 && schemas[0] === 'public'
|
|
76
|
+
? null : schemas.map(quoteIdent).join(', ');
|
|
77
|
+
return stateReferencedFunctions(statements, declared)
|
|
78
|
+
.filter((functionObject) => !present.has(functionObject.identity))
|
|
79
|
+
.map((functionObject) => ({
|
|
80
|
+
identity: functionObject.identity,
|
|
81
|
+
sql: functionObject.sql,
|
|
82
|
+
expected: 'absent',
|
|
83
|
+
searchPath,
|
|
84
|
+
grantContract: {
|
|
85
|
+
attachments: functionObject.attachments.filter((a) => a.kind === 'grant'),
|
|
86
|
+
...(functionObject.owner ? { owner: functionObject.owner } : {}),
|
|
87
|
+
ownerMode: 'set-role',
|
|
88
|
+
},
|
|
89
|
+
provenance: {
|
|
90
|
+
srcHash: functionObject.hash,
|
|
91
|
+
...(functionObject.bodyHash ? { bodyHash: functionObject.bodyHash } : {}),
|
|
92
|
+
kind: 'function' as const,
|
|
93
|
+
...(functionObject.drop ? { dropSql: functionObject.drop } : {}),
|
|
94
|
+
},
|
|
95
|
+
}));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Read the target's function identities for the plan's referenced declarations only. */
|
|
99
|
+
export async function mintStatePrerequisites(
|
|
100
|
+
runner: QueryRunner,
|
|
101
|
+
session: SessionRunner,
|
|
102
|
+
statements: readonly string[],
|
|
103
|
+
declared: readonly SourceObject[],
|
|
104
|
+
): Promise<StatePrerequisite[]> {
|
|
105
|
+
const referenced = stateReferencedFunctions(statements, declared);
|
|
106
|
+
if (referenced.length === 0) return [];
|
|
107
|
+
const live = await introspectDerived(session);
|
|
108
|
+
const present = new Set(live.objects.filter((o) => o.kind === 'function').map((o) => o.identity));
|
|
109
|
+
const missing = planStatePrerequisites(statements, declared, present);
|
|
110
|
+
if (missing.length === 0) return [];
|
|
111
|
+
|
|
112
|
+
// The artifact must carry the same owner and attachment SQL as a normal derived apply. The
|
|
113
|
+
// preflight chooses the safe mechanism from the target builder while minting, so apply only
|
|
114
|
+
// executes reviewed text and never re-decides a privilege boundary.
|
|
115
|
+
const parsed = { objects: [...declared], warnings: [] as string[] };
|
|
116
|
+
const identities = missing.map((p) => p.identity);
|
|
117
|
+
const combined = planReconcile(parsed, live, { only: identities });
|
|
118
|
+
const requirements = ownerRequirements(combined, parsed.objects);
|
|
119
|
+
let ownerMode = undefined;
|
|
120
|
+
if (requirements.length > 0) {
|
|
121
|
+
const rows = await runner(ownerPreflightSql(requirements));
|
|
122
|
+
const refusal = ownerPreflightRefusal(rows as any[]);
|
|
123
|
+
if (refusal) throw new Error(`could not mint state prerequisites: ${refusal}`);
|
|
124
|
+
ownerMode = builderOwnerMode(rows as any[]);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return missing.map((prerequisite) => {
|
|
128
|
+
const one = planReconcile(parsed, live, { only: [prerequisite.identity] });
|
|
129
|
+
const rendered = renderReconcileSql(one, parsed.objects, ownerMode);
|
|
130
|
+
if (rendered.record.length !== 1 || rendered.record[0] !== prerequisite.identity) {
|
|
131
|
+
throw new Error(`could not mint state prerequisite ${prerequisite.identity}: target changed while minting; re-run db:plan.`);
|
|
132
|
+
}
|
|
133
|
+
const source = referenced.find((o) => o.identity === prerequisite.identity)!;
|
|
134
|
+
return {
|
|
135
|
+
...prerequisite,
|
|
136
|
+
sql: [...renderEnsureObjectSchemas([source]), ...rendered.statements].join('\n;\n'),
|
|
137
|
+
grantContract: { ...prerequisite.grantContract, ownerMode: ownerMode ?? 'set-role' },
|
|
138
|
+
};
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
51
142
|
const literal = (s: string): string => `'${s.replace(/'/g, "''")}'`;
|
|
52
143
|
|
|
53
144
|
/**
|