@everystack/cli 0.4.45 → 0.4.46
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/package.json +2 -2
- package/src/cli/alter-type-dependents.ts +96 -0
- package/src/cli/apply-execute.ts +22 -8
- package/src/cli/authz-adoption-class.ts +75 -26
- package/src/cli/authz-canonical.ts +37 -5
- package/src/cli/authz-compile.ts +87 -37
- package/src/cli/authz-contract.ts +92 -19
- package/src/cli/authz-derive.ts +158 -33
- package/src/cli/authz-reconcile.ts +48 -6
- package/src/cli/aws.ts +32 -0
- package/src/cli/commands/db-apply.ts +60 -14
- package/src/cli/commands/db-authz.ts +9 -14
- package/src/cli/commands/db-fingerprint.ts +54 -18
- package/src/cli/commands/db-generate.ts +11 -17
- package/src/cli/commands/db-plan.ts +56 -8
- package/src/cli/commands/db-pull.ts +16 -18
- package/src/cli/commands/db-reconcile.ts +18 -20
- package/src/cli/commands/db-swap.ts +5 -4
- package/src/cli/commands/db-sync.ts +8 -5
- package/src/cli/db-build.ts +2 -2
- package/src/cli/db-source.ts +56 -0
- package/src/cli/derived-introspect.ts +27 -26
- package/src/cli/derived-lint.ts +7 -8
- package/src/cli/edge-plan.ts +112 -16
- package/src/cli/git-descent.ts +16 -9
- package/src/cli/index.ts +1 -1
- package/src/cli/model-render.ts +56 -50
- package/src/cli/schema-compile.ts +6 -1
- package/src/cli/schema-diff.ts +1 -1
- package/src/cli/schema-fingerprint.ts +67 -7
- package/src/cli/schema-introspect.ts +44 -17
- package/src/cli/schema-source.ts +9 -0
- package/src/cli/session.ts +184 -0
- package/src/cli/stage-read-consistency.ts +128 -0
- package/src/cli/state-apply.ts +4 -2
- package/src/cli/swap-execute.ts +4 -3
- package/src/cli/search-path.ts +0 -51
|
@@ -57,22 +57,14 @@ import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
|
|
|
57
57
|
import { resolveConfig, opsFunction } from '../config.js';
|
|
58
58
|
import { resolveModelsPath } from '../models-path.js';
|
|
59
59
|
import { resolveDbSource, connectingVia, createUrlProbeRunner } from '../db-source.js';
|
|
60
|
-
import {
|
|
60
|
+
import { borrowedSessionRunner, type SessionRunner } from '../session.js';
|
|
61
|
+
import { invokeAction, lambdaQueryRunner, lambdaSessionRunner } from '../aws.js';
|
|
61
62
|
import { step, success, fail, info, warn } from '../output.js';
|
|
62
63
|
import { opsAdviceLines, IAM_ADVICE } from '../ops-advice.js';
|
|
63
64
|
import { loadModels } from './db-generate.js';
|
|
64
65
|
|
|
65
66
|
const DEFAULT_DIR = 'authz';
|
|
66
67
|
|
|
67
|
-
/** A QueryRunner backed by the ops Lambda `db:query` action (read-only). */
|
|
68
|
-
function lambdaRunner(region: string, fn: string): QueryRunner {
|
|
69
|
-
return async (sql: string) => {
|
|
70
|
-
const result: any = await invokeAction(region, fn, 'db:query', { sql });
|
|
71
|
-
if (result?.error) throw new Error(`Introspection query failed: ${result.error}`);
|
|
72
|
-
return result?.rows ?? [];
|
|
73
|
-
};
|
|
74
|
-
}
|
|
75
|
-
|
|
76
68
|
/**
|
|
77
69
|
* Where an authz command runs. Both venues expose the same two capabilities, so the
|
|
78
70
|
* commands never branch on venue after this point — that is what keeps the local
|
|
@@ -81,6 +73,8 @@ function lambdaRunner(region: string, fn: string): QueryRunner {
|
|
|
81
73
|
interface AuthzVenue {
|
|
82
74
|
/** Read-only introspection. */
|
|
83
75
|
runner: QueryRunner;
|
|
76
|
+
/** The consistent multi-statement read — what every introspection takes. */
|
|
77
|
+
session: SessionRunner;
|
|
84
78
|
/** Self-reverting red-team probe (writes, always rolled back). */
|
|
85
79
|
probe: (setup: string, read: string) => Promise<any[]>;
|
|
86
80
|
/** Human-readable venue, printed with every verdict. */
|
|
@@ -97,15 +91,16 @@ async function resolveVenue(flags: Record<string, string>): Promise<AuthzVenue>
|
|
|
97
91
|
const source = resolveDbSource(flags);
|
|
98
92
|
if (source.kind === 'url') {
|
|
99
93
|
step(connectingVia(source));
|
|
100
|
-
const { runner, probe, end } = await createUrlProbeRunner(source.url);
|
|
101
|
-
return { runner, probe, end, label: 'direct connection' };
|
|
94
|
+
const { runner, session, probe, end } = await createUrlProbeRunner(source.url);
|
|
95
|
+
return { runner, session, probe, end, label: 'direct connection' };
|
|
102
96
|
}
|
|
103
97
|
step('Resolving deployed config...');
|
|
104
98
|
const config = await resolveConfig(flags.stage);
|
|
105
99
|
const fn = opsFunction(config);
|
|
106
100
|
info(`Region: ${config.region}, Function: ${fn}`);
|
|
107
101
|
return {
|
|
108
|
-
runner:
|
|
102
|
+
runner: lambdaQueryRunner(config.region, fn),
|
|
103
|
+
session: lambdaSessionRunner(config.region, fn),
|
|
109
104
|
probe: async (setup: string, read: string) => {
|
|
110
105
|
const result: any = await invokeAction(config.region, fn, 'db:authz:probe', { setup, read });
|
|
111
106
|
if (result?.error) throw new Error(result.error);
|
|
@@ -120,7 +115,7 @@ async function introspectVenue(flags: Record<string, string>): Promise<{ contrac
|
|
|
120
115
|
const venue = await resolveVenue(flags);
|
|
121
116
|
try {
|
|
122
117
|
step('Introspecting authorization (rls + grants + policies + secdef)...');
|
|
123
|
-
return { contract: await introspectContract(venue.
|
|
118
|
+
return { contract: await introspectContract(venue.session, contractFunctionRow, FUNCTIONS_SQL), label: venue.label };
|
|
124
119
|
} finally {
|
|
125
120
|
await venue.end();
|
|
126
121
|
}
|
|
@@ -21,46 +21,70 @@ import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
|
|
|
21
21
|
import {
|
|
22
22
|
fingerprintLive,
|
|
23
23
|
fingerprintModels,
|
|
24
|
+
governedRolesForModels,
|
|
24
25
|
mapUnfingerprintedRows,
|
|
25
26
|
UNFINGERPRINTED_SQL,
|
|
26
27
|
type UnfingerprintedObject,
|
|
27
28
|
} from '../schema-fingerprint.js';
|
|
29
|
+
import { predictLiveFingerprint, governedLiveFingerprint } from '../edge-plan.js';
|
|
28
30
|
import { resolveModelsPath } from '../models-path.js';
|
|
29
31
|
import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
|
|
32
|
+
import { borrowedSessionRunner, INTROSPECTION_SESSION, type SessionRunner } from '../session.js';
|
|
30
33
|
import { resolveConfig, opsFunction } from '../config.js';
|
|
31
|
-
import { invokeAction } from '../aws.js';
|
|
34
|
+
import { invokeAction, lambdaQueryRunner, lambdaSessionRunner } from '../aws.js';
|
|
32
35
|
import { step, success, fail, info, warn } from '../output.js';
|
|
33
36
|
import { loadModels } from './db-generate.js';
|
|
34
37
|
import { loadDeclaredDerived } from '../declared-derived.js';
|
|
35
38
|
import type { SequenceDescriptor } from '@everystack/model';
|
|
36
39
|
|
|
37
40
|
export interface FingerprintStatus {
|
|
41
|
+
/** The RAW live hash — everything introspected. The flavor plan.from and the backup chain use. */
|
|
38
42
|
live: string;
|
|
43
|
+
/**
|
|
44
|
+
* The GOVERNED live hash — the live state filtered through the models' governed-role set,
|
|
45
|
+
* i.e. "the state the models describe". Present only when models loaded; equals `live` when
|
|
46
|
+
* no foreign grantee holds anything. THIS is the half MATCH compares — a live grant nothing
|
|
47
|
+
* will ever reconcile must not keep MATCH unreachable (the v4 rule, wired to the verdict).
|
|
48
|
+
*/
|
|
49
|
+
governedLive?: string;
|
|
39
50
|
declared: string | null;
|
|
51
|
+
/**
|
|
52
|
+
* The predicted endpoint: models + unmodeled live tables riding through, governed —
|
|
53
|
+
* `db:generate`'s no-op state. Equals `declared` when nothing is unmodeled. MATCH is
|
|
54
|
+
* `predicted === governedLive` (the declares-test): the identity is with what generate
|
|
55
|
+
* MANAGES, and generate manages only declared tables.
|
|
56
|
+
*/
|
|
57
|
+
predicted?: string;
|
|
40
58
|
match: boolean | null;
|
|
41
59
|
unfingerprinted: UnfingerprintedObject[];
|
|
42
60
|
}
|
|
43
61
|
|
|
44
62
|
/** The testable core: live fingerprint, declared fingerprint, verdict, honesty report. */
|
|
45
63
|
export async function computeFingerprintStatus(
|
|
46
|
-
|
|
64
|
+
session: SessionRunner,
|
|
47
65
|
models: ModelDescriptor[] | null,
|
|
48
66
|
sequences?: SequenceDescriptor[],
|
|
67
|
+
governedExtras?: readonly string[],
|
|
49
68
|
): Promise<FingerprintStatus> {
|
|
50
|
-
const snapshot = await introspectSchema(
|
|
51
|
-
const contract = await introspectContract(
|
|
69
|
+
const snapshot = await introspectSchema(session);
|
|
70
|
+
const contract = await introspectContract(session, contractFunctionRow, FUNCTIONS_SQL);
|
|
71
|
+
// Rides the SAME session policy as the two introspections above. It used to be issued
|
|
72
|
+
// bare, outside any search_path pin — the one read in this command that was not
|
|
73
|
+
// canonicalized, and on the stage lane its own separate invoke.
|
|
74
|
+
const [unfingerprintedRows] = await session([UNFINGERPRINTED_SQL], INTROSPECTION_SESSION);
|
|
52
75
|
const live = fingerprintLive(snapshot, contract).hash;
|
|
53
76
|
const declared = models ? fingerprintModels(models, { sequences }).hash : null;
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
77
|
+
const governedRoles = models ? governedRolesForModels(models, governedExtras) : undefined;
|
|
78
|
+
const governedLive = governedRoles ? governedLiveFingerprint(snapshot, contract, governedRoles) : undefined;
|
|
79
|
+
const predicted = models ? predictLiveFingerprint(models, snapshot, contract, { governedRoles }) : undefined;
|
|
80
|
+
const unfingerprinted = mapUnfingerprintedRows(unfingerprintedRows as any[]);
|
|
81
|
+
return {
|
|
82
|
+
live,
|
|
83
|
+
...(governedLive !== undefined ? { governedLive } : {}),
|
|
84
|
+
declared,
|
|
85
|
+
...(predicted !== undefined ? { predicted } : {}),
|
|
86
|
+
match: declared === null ? null : predicted === governedLive,
|
|
87
|
+
unfingerprinted,
|
|
64
88
|
};
|
|
65
89
|
}
|
|
66
90
|
|
|
@@ -76,9 +100,12 @@ export async function dbFingerprintCommand(flags: Record<string, string>): Promi
|
|
|
76
100
|
const modelsPath = resolveModelsPath(flags.models);
|
|
77
101
|
let models: ModelDescriptor[] | null = null;
|
|
78
102
|
let sequences: SequenceDescriptor[] | undefined;
|
|
103
|
+
let governedExtras: string[] | undefined;
|
|
79
104
|
try {
|
|
80
105
|
models = await loadModels(modelsPath);
|
|
81
|
-
|
|
106
|
+
const declared = await loadDeclaredDerived(flags.models);
|
|
107
|
+
sequences = declared?.sequences;
|
|
108
|
+
governedExtras = declared?.governedRoles;
|
|
82
109
|
} catch (err: any) {
|
|
83
110
|
// Two very different situations used to land here identically.
|
|
84
111
|
//
|
|
@@ -100,18 +127,21 @@ export async function dbFingerprintCommand(flags: Record<string, string>): Promi
|
|
|
100
127
|
}
|
|
101
128
|
|
|
102
129
|
let runner: QueryRunner;
|
|
130
|
+
|
|
131
|
+
let session: SessionRunner;
|
|
103
132
|
let end: (() => Promise<void>) | undefined;
|
|
104
133
|
if (dbSource.kind === 'url') {
|
|
105
134
|
step(connectingVia(dbSource));
|
|
106
|
-
({ runner, end } = await createUrlRunner(dbSource.url));
|
|
135
|
+
({ runner, session, end } = await createUrlRunner(dbSource.url));
|
|
107
136
|
} else {
|
|
108
137
|
step('Resolving deployed config...');
|
|
109
138
|
const config = await resolveConfig(flags.stage);
|
|
110
|
-
runner =
|
|
139
|
+
runner = lambdaQueryRunner(config.region, opsFunction(config));
|
|
140
|
+
session = lambdaSessionRunner(config.region, opsFunction(config));
|
|
111
141
|
}
|
|
112
142
|
|
|
113
143
|
try {
|
|
114
|
-
const status = await computeFingerprintStatus(
|
|
144
|
+
const status = await computeFingerprintStatus(session, models, sequences, governedExtras);
|
|
115
145
|
|
|
116
146
|
if (flags.json === 'true') {
|
|
117
147
|
console.log(JSON.stringify(status, null, 2));
|
|
@@ -120,7 +150,13 @@ export async function dbFingerprintCommand(flags: Record<string, string>): Promi
|
|
|
120
150
|
if (status.declared === null) {
|
|
121
151
|
warn(`declared: (no models barrel at ${modelsPath} — live-only)`);
|
|
122
152
|
} else {
|
|
153
|
+
if (status.governedLive !== undefined && status.governedLive !== status.live) {
|
|
154
|
+
info(`governed: ${status.governedLive} (live state minus ungoverned grantees — the half MATCH compares)`);
|
|
155
|
+
}
|
|
123
156
|
info(`declared: ${status.declared}`);
|
|
157
|
+
if (status.predicted !== undefined && status.predicted !== status.declared) {
|
|
158
|
+
info(`predicted: ${status.predicted} (declared + unmodeled tables riding through — generate's no-op state)`);
|
|
159
|
+
}
|
|
124
160
|
if (status.match) success('MATCH — the database is the state the models declare.');
|
|
125
161
|
else fail('MISMATCH — run `everystack db:generate` to see the difference as SQL.');
|
|
126
162
|
}
|
|
@@ -34,26 +34,18 @@ import { classifyAdoption, renderAdoptionReport } from '../authz-adoption-class.
|
|
|
34
34
|
import { introspectTableOwners, buildOwnershipReport, renderOwnershipReport, type TableOwner } from '../authz-ownership.js';
|
|
35
35
|
import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
|
|
36
36
|
import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
|
|
37
|
+
import { borrowedSessionRunner, type SessionRunner } from '../session.js';
|
|
37
38
|
import { resolveModelsPath } from '../models-path.js';
|
|
38
39
|
import { loadDeclaredDerived, assertNoHoles, type DeclaredDerived } from '../declared-derived.js';
|
|
39
40
|
import { asModelComposeError, opsAdviceLines } from '../ops-advice.js';
|
|
40
41
|
import { applyStateAndVerify, classifyGeneratedStatements, renderStatementHistogram, currentGitRef, type StateSyncOutcome } from '../state-apply.js';
|
|
41
42
|
import { resolveConfig, opsFunction } from '../config.js';
|
|
42
|
-
import { invokeAction } from '../aws.js';
|
|
43
|
+
import { invokeAction, lambdaQueryRunner, lambdaSessionRunner } from '../aws.js';
|
|
43
44
|
import { step, success, fail, info, warn } from '../output.js';
|
|
44
45
|
|
|
45
46
|
const DEFAULT_MIGRATIONS = 'drizzle';
|
|
46
47
|
const DEFAULT_SCHEMA_OUT = 'db/schema.generated.ts';
|
|
47
48
|
|
|
48
|
-
/** A QueryRunner backed by the ops Lambda `db:query` action (read-only). */
|
|
49
|
-
function lambdaRunner(region: string, fn: string): QueryRunner {
|
|
50
|
-
return async (sql: string) => {
|
|
51
|
-
const result: any = await invokeAction(region, fn, 'db:query', { sql });
|
|
52
|
-
if (result?.error) throw new Error(`Introspection query failed: ${result.error}`);
|
|
53
|
-
return result?.rows ?? [];
|
|
54
|
-
};
|
|
55
|
-
}
|
|
56
|
-
|
|
57
49
|
/** Import the app's Model barrel and return its `models` array (runs under tsx, so TS imports work).
|
|
58
50
|
* Every failure is the operator's own barrel — ModelComposeError, never dressed as IAM. */
|
|
59
51
|
export async function loadModels(modelsPath: string): Promise<ModelDescriptor[]> {
|
|
@@ -164,11 +156,11 @@ function reportGrantExemptions(liveAuthz: AuthzContract | undefined, models: Mod
|
|
|
164
156
|
function reportAdoptionClasses(liveAuthz: AuthzContract | undefined, models: ModelDescriptor[]): void {
|
|
165
157
|
if (!liveAuthz) return; // greenfield — every statement is additive, nothing to adjudicate
|
|
166
158
|
const declared: AuthzContract = { tables: models.map((m) => compileTableContract(m, {})), functions: [] };
|
|
167
|
-
const { counts } = classifyAdoption(declared, liveAuthz);
|
|
159
|
+
const { counts, statements } = classifyAdoption(declared, liveAuthz);
|
|
168
160
|
const total = Object.values(counts).reduce((a, b) => a + b, 0);
|
|
169
161
|
if (total === 0) return;
|
|
170
162
|
info(`${total} authorization statement(s), by why they exist:`);
|
|
171
|
-
for (const line of renderAdoptionReport(counts)) info(line);
|
|
163
|
+
for (const line of renderAdoptionReport(counts, statements)) info(line);
|
|
172
164
|
}
|
|
173
165
|
|
|
174
166
|
/**
|
|
@@ -249,6 +241,7 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
|
|
|
249
241
|
let liveAuthz;
|
|
250
242
|
let liveOwners: TableOwner[] = [];
|
|
251
243
|
let runner!: QueryRunner;
|
|
244
|
+
let session!: SessionRunner;
|
|
252
245
|
let end: (() => Promise<void>) | undefined;
|
|
253
246
|
try {
|
|
254
247
|
step(`Loading models from ${modelsPath}...`);
|
|
@@ -270,17 +263,18 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
|
|
|
270
263
|
}
|
|
271
264
|
if (dbSource.kind === 'url') {
|
|
272
265
|
step(connectingVia(dbSource));
|
|
273
|
-
({ runner, end } = await createUrlRunner(dbSource.url));
|
|
266
|
+
({ runner, session, end } = await createUrlRunner(dbSource.url));
|
|
274
267
|
} else {
|
|
275
268
|
step('Resolving deployed config...');
|
|
276
269
|
const config = await resolveConfig(flags.stage);
|
|
277
270
|
info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
|
|
278
|
-
runner =
|
|
271
|
+
runner = lambdaQueryRunner(config.region, opsFunction(config));
|
|
272
|
+
session = lambdaSessionRunner(config.region, opsFunction(config));
|
|
279
273
|
}
|
|
280
274
|
step('Introspecting live database (columns + constraints)...');
|
|
281
|
-
current = await introspectSchema(
|
|
275
|
+
current = await introspectSchema(session);
|
|
282
276
|
step('Introspecting authorization (rls + grants + policies)...');
|
|
283
|
-
liveAuthz = await introspectContract(
|
|
277
|
+
liveAuthz = await introspectContract(session, contractFunctionRow, FUNCTIONS_SQL);
|
|
284
278
|
// Ownership rides the same connection: it is not part of the contract (it never
|
|
285
279
|
// enters the fingerprint), but it decides whether the contract is actually in force.
|
|
286
280
|
liveOwners = await introspectTableOwners(runner);
|
|
@@ -352,7 +346,7 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
|
|
|
352
346
|
step(`Applying ${classified.executable.length} statement(s) as one transaction, then verifying by re-diff...`);
|
|
353
347
|
let outcome: StateSyncOutcome;
|
|
354
348
|
try {
|
|
355
|
-
outcome = await applyStateAndVerify(runner, models, statements, current, liveAuthz, {
|
|
349
|
+
outcome = await applyStateAndVerify(runner, session, models, statements, current, liveAuthz, {
|
|
356
350
|
allowDrops, sequences: declaredDb?.sequences, actor: process.env.USER ?? null, gitRef: currentGitRef(),
|
|
357
351
|
});
|
|
358
352
|
} catch (err: any) {
|
|
@@ -16,15 +16,23 @@
|
|
|
16
16
|
* Plans are EPHEMERAL — attach them to the release/PR run, never commit
|
|
17
17
|
* them (committing plans would rebuild the tape). Minting is read-only, so
|
|
18
18
|
* it works over the ops Lambda (--stage) as well as a direct connection.
|
|
19
|
+
*
|
|
20
|
+
* On the STAGE lane the read is not atomic: every catalog query is its own
|
|
21
|
+
* Lambda invoke, so one introspection can be assembled from several containers
|
|
22
|
+
* holding connections to different databases, and the minted `from` would then
|
|
23
|
+
* describe no real state. That lane reads TWICE and refuses a disagreement.
|
|
24
|
+
* Agreement is a non-detection, not a verification — see
|
|
25
|
+
* stage-read-consistency.ts. The direct lane reads once, over one session.
|
|
19
26
|
*/
|
|
20
27
|
|
|
21
28
|
import fs from 'node:fs/promises';
|
|
22
29
|
import { spawnSync } from 'node:child_process';
|
|
23
30
|
import type { ModelDescriptor } from '@everystack/model';
|
|
24
|
-
import { introspectContract, type QueryRunner } from '../authz-contract.js';
|
|
31
|
+
import { introspectContract, type QueryRunner, type AuthzContract } from '../authz-contract.js';
|
|
25
32
|
import { classifyAdoption, renderAdoptionReport } from '../authz-adoption-class.js';
|
|
26
33
|
import { introspectTableOwners, buildOwnershipReport, renderOwnershipReport } from '../authz-ownership.js';
|
|
27
|
-
import { introspectSchema } from '../schema-introspect.js';
|
|
34
|
+
import { introspectSchema, type SchemaSnapshot } from '../schema-introspect.js';
|
|
35
|
+
import { readStageStateTwice } from '../stage-read-consistency.js';
|
|
28
36
|
import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
|
|
29
37
|
import { mintEdgePlan, planHash, buildPlanSummary } from '../edge-plan.js';
|
|
30
38
|
import { verifyDescent } from '../git-descent.js';
|
|
@@ -32,13 +40,15 @@ import { planBackfills, readBackfillLog } from '../backfill.js';
|
|
|
32
40
|
import { readSqlDirIfPresent } from './db-sync.js';
|
|
33
41
|
import { currentGitRef } from '../state-apply.js';
|
|
34
42
|
import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
|
|
43
|
+
import { borrowedSessionRunner, type SessionRunner } from '../session.js';
|
|
35
44
|
import { resolveModelsPath } from '../models-path.js';
|
|
36
45
|
import { resolveConfig, opsFunction } from '../config.js';
|
|
37
|
-
import { lambdaQueryRunner } from '../aws.js';
|
|
46
|
+
import { lambdaQueryRunner, lambdaSessionRunner } from '../aws.js';
|
|
38
47
|
import { loadModels } from './db-generate.js';
|
|
39
48
|
import { loadDeclaredDerived } from '../declared-derived.js';
|
|
40
49
|
import { governedRoleSet, ungovernedGrants } from '../authz-reconcile.js';
|
|
41
50
|
import { readBaselineFile, checkAgainstBaseline, baselineRefusal } from '../authz-baseline.js';
|
|
51
|
+
import { alterTypeStatementTargets, findAlterTypeDependents, renderAlterTypeRefusal } from '../alter-type-dependents.js';
|
|
42
52
|
import { compileTableContract } from '../authz-compile.js';
|
|
43
53
|
import { reportPipelineLastRun } from './pipeline-run.js';
|
|
44
54
|
import { step, success, fail, info, warn, reserveStdoutForData } from '../output.js';
|
|
@@ -89,20 +99,41 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
|
|
|
89
99
|
}
|
|
90
100
|
|
|
91
101
|
let runner: QueryRunner;
|
|
102
|
+
|
|
103
|
+
let session: SessionRunner;
|
|
92
104
|
let end: (() => Promise<void>) | undefined;
|
|
93
105
|
if (dbSource.kind === 'url') {
|
|
94
106
|
step(connectingVia(dbSource));
|
|
95
|
-
({ runner, end } = await createUrlRunner(dbSource.url));
|
|
107
|
+
({ runner, session, end } = await createUrlRunner(dbSource.url));
|
|
96
108
|
} else {
|
|
97
109
|
step('Resolving deployed config...');
|
|
98
110
|
const config = await resolveConfig(flags.stage);
|
|
99
111
|
runner = lambdaQueryRunner(config.region, opsFunction(config));
|
|
112
|
+
session = lambdaSessionRunner(config.region, opsFunction(config));
|
|
100
113
|
}
|
|
101
114
|
|
|
102
115
|
try {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
116
|
+
let snapshot: SchemaSnapshot;
|
|
117
|
+
let contract: AuthzContract;
|
|
118
|
+
if (dbSource.kind === 'url') {
|
|
119
|
+
// One session, one database — the read is atomic enough to trust.
|
|
120
|
+
step('Asking the target its fingerprint (introspecting state + authz)...');
|
|
121
|
+
snapshot = await introspectSchema(session);
|
|
122
|
+
contract = await introspectContract(session, contractFunctionRow, FUNCTIONS_SQL);
|
|
123
|
+
} else {
|
|
124
|
+
// Each introspection is one session now, but a full read is TWO of them (state,
|
|
125
|
+
// then authz) and they can land on two containers at two moments. Read twice and
|
|
126
|
+
// refuse a disagreement — see stage-read-consistency.ts for why agreement is a
|
|
127
|
+
// non-detection and not a verification.
|
|
128
|
+
step('Asking the target its fingerprint TWICE (the stage lane reads state and authz in separate ops-Lambda invokes)...');
|
|
129
|
+
const pair = await readStageStateTwice(session);
|
|
130
|
+
if (!pair.ok) {
|
|
131
|
+
fail(pair.reason);
|
|
132
|
+
process.exit(1);
|
|
133
|
+
}
|
|
134
|
+
warn(pair.warning);
|
|
135
|
+
({ snapshot, contract } = pair.state);
|
|
136
|
+
}
|
|
106
137
|
// Not part of the plan and not part of either fingerprint — the owner is
|
|
107
138
|
// environment-specific. It is read here so the REVIEW surface can name it.
|
|
108
139
|
const owners = await introspectTableOwners(runner);
|
|
@@ -145,6 +176,19 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
|
|
|
145
176
|
process.exit(1);
|
|
146
177
|
}
|
|
147
178
|
|
|
179
|
+
// The plan lane's contract: what it mints, it can apply. A SET DATA TYPE on a
|
|
180
|
+
// column a view/matview/rule binds would fail MID-TRANSACTION at apply
|
|
181
|
+
// ("cannot alter type of a column used by a view or rule") — refuse at mint,
|
|
182
|
+
// with the dependents named, while there is still a live catalog to ask.
|
|
183
|
+
const alterTargets = alterTypeStatementTargets(plan.statements);
|
|
184
|
+
if (alterTargets.length > 0) {
|
|
185
|
+
const blockedAlters = await findAlterTypeDependents(runner, alterTargets);
|
|
186
|
+
if (blockedAlters.length > 0) {
|
|
187
|
+
fail(`Mint refused: ${renderAlterTypeRefusal(blockedAlters)}`);
|
|
188
|
+
process.exit(1);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
148
192
|
const body = JSON.stringify(plan, null, 2) + '\n';
|
|
149
193
|
if (out === '-') {
|
|
150
194
|
console.log(body);
|
|
@@ -166,8 +210,12 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
|
|
|
166
210
|
const adoptionTotal = Object.values(adoption.counts).reduce((a, b) => a + b, 0);
|
|
167
211
|
if (adoptionTotal > 0) {
|
|
168
212
|
info(`${adoptionTotal} authorization statement(s), by why they exist:`);
|
|
169
|
-
for (const line of renderAdoptionReport(adoption.counts)) info(line);
|
|
213
|
+
for (const line of renderAdoptionReport(adoption.counts, adoption.statements)) info(line);
|
|
170
214
|
}
|
|
215
|
+
// The class rides on the ARTIFACT too, not only the terminal. A reviewer reading
|
|
216
|
+
// db.plan.json had the aggregate and no way to reach the statements behind it, so
|
|
217
|
+
// `unclassified: 5` was unreadable by the only person positioned to catch what it meant.
|
|
218
|
+
plan.adoption = adoption.statements;
|
|
171
219
|
|
|
172
220
|
// WHO owns the tables this plan authorizes, and does that owner obey the policies it
|
|
173
221
|
// is about to write? Re-derived live every mint, never stored — the owner is a fact
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `everystack db:pull` — generate `field()` Models from a live database (the brownfield on-ramp).
|
|
3
3
|
*
|
|
4
|
-
* db:pull [--stage <name>
|
|
4
|
+
* db:pull [--stage <name>] [--database-url <url>] [--schema public] [--out <dir | file.ts>]
|
|
5
5
|
* [--derived-out <file.ts>] [--abilities public-read]
|
|
6
6
|
*
|
|
7
|
+
* `--stage` and `--database-url` COMPOSE: the URL picks the connection, the stage names the
|
|
8
|
+
* baseline entry (`db/authz-baseline.json` is per-stage — a local adoption pulls with
|
|
9
|
+
* `--database-url … --stage local`, which is the stage db:plan defaults to).
|
|
10
|
+
*
|
|
7
11
|
* The reverse of db:generate: that writes migrations FROM Models; this writes Models FROM the
|
|
8
12
|
* database. It introspects the deployed schema through the read-only ops Lambda `db:query`
|
|
9
13
|
* action — or, for a database no Lambda can reach (the brownfield case where the target
|
|
@@ -41,8 +45,9 @@ import { renderDerivedSource, renderDerivedFile, type DerivedRenderResult } from
|
|
|
41
45
|
import { renderModelSource, renderModelFiles, pullableTables, modelVarName, ABILITY_PRESETS } from '../model-render.js';
|
|
42
46
|
import type { QueryRunner } from '../authz-contract.js';
|
|
43
47
|
import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
|
|
48
|
+
import { borrowedSessionRunner, type SessionRunner } from '../session.js';
|
|
44
49
|
import { resolveConfig, opsFunction } from '../config.js';
|
|
45
|
-
import { invokeAction } from '../aws.js';
|
|
50
|
+
import { invokeAction, lambdaQueryRunner, lambdaSessionRunner } from '../aws.js';
|
|
46
51
|
import { fail } from '../output.js';
|
|
47
52
|
import { opsAdviceLines } from '../ops-advice.js';
|
|
48
53
|
|
|
@@ -102,15 +107,6 @@ export function derivedImportSpecifier(out: string, derivedOut: string): string
|
|
|
102
107
|
return rel.startsWith('.') ? rel : `./${rel}`;
|
|
103
108
|
}
|
|
104
109
|
|
|
105
|
-
/** A QueryRunner backed by the ops Lambda `db:query` action (read-only). */
|
|
106
|
-
function lambdaRunner(region: string, fn: string): QueryRunner {
|
|
107
|
-
return async (sql: string) => {
|
|
108
|
-
const result: any = await invokeAction(region, fn, 'db:query', { sql });
|
|
109
|
-
if (result?.error) throw new Error(`Introspection query failed: ${result.error}`);
|
|
110
|
-
return result?.rows ?? [];
|
|
111
|
-
};
|
|
112
|
-
}
|
|
113
|
-
|
|
114
110
|
export async function dbPullCommand(flags: Record<string, string>): Promise<void> {
|
|
115
111
|
const schema = flags.schema || 'public';
|
|
116
112
|
|
|
@@ -159,27 +155,29 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
159
155
|
let candidatesByIdentity: Map<string, string[]> | undefined;
|
|
160
156
|
try {
|
|
161
157
|
let runner: QueryRunner;
|
|
158
|
+
let session: SessionRunner;
|
|
162
159
|
let end: (() => Promise<void>) | undefined;
|
|
163
160
|
if (dbSource.kind === 'url') {
|
|
164
161
|
note(connectingVia(dbSource));
|
|
165
|
-
({ runner, end } = await createUrlRunner(dbSource.url));
|
|
162
|
+
({ runner, session, end } = await createUrlRunner(dbSource.url));
|
|
166
163
|
} else {
|
|
167
164
|
note('Resolving deployed config...');
|
|
168
165
|
const config = await resolveConfig(flags.stage);
|
|
169
166
|
detail(`Region: ${config.region}, Function: ${opsFunction(config)}`);
|
|
170
|
-
runner =
|
|
167
|
+
runner = lambdaQueryRunner(config.region, opsFunction(config));
|
|
168
|
+
session = lambdaSessionRunner(config.region, opsFunction(config));
|
|
171
169
|
}
|
|
172
170
|
note(`Introspecting live database (schema: ${schema})...`);
|
|
173
|
-
current = await introspectSchema(
|
|
171
|
+
current = await introspectSchema(session);
|
|
174
172
|
// The derived layer rides the same pull (B5) — views/matviews/functions/sequences
|
|
175
173
|
// render as descriptors; adoption is pull → commit → --baseline → clean reconcile.
|
|
176
|
-
derivedCatalog = await introspectDerived(
|
|
174
|
+
derivedCatalog = await introspectDerived(session);
|
|
177
175
|
// --abilities live: the authz half of the on-ramp. Same connection, one more read —
|
|
178
176
|
// the grants and policies that already exist become the models' declared abilities,
|
|
179
177
|
// instead of a human transcribing them by hand.
|
|
180
178
|
if (abilities === 'live') {
|
|
181
179
|
note('Introspecting live authorization (grants + policies)...');
|
|
182
|
-
const contract = await introspectContract(
|
|
180
|
+
const contract = await introspectContract(session, contractFunctionRow, FUNCTIONS_SQL);
|
|
183
181
|
liveContract = contract;
|
|
184
182
|
// The owner is NOT rendered into the models — it is the dev user here and the
|
|
185
183
|
// operator role on a stage, so a model that declared it would drift everywhere.
|
|
@@ -357,8 +355,8 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
357
355
|
ok(`Rendered ${pulled.length} model(s) from schema "${schema}" (stdout — redirect or pass --out to save).`);
|
|
358
356
|
}
|
|
359
357
|
|
|
360
|
-
const flagged = (source.match(/\/\/ (FIXME|TODO|composite|CHECK|FK
|
|
361
|
-
if (flagged) caution(`${flagged} inline comment(s) flag things to review (
|
|
358
|
+
const flagged = (source.match(/\/\/ (FIXME|TODO|composite|CHECK|FK →|verbatim:)/g) ?? []).length;
|
|
359
|
+
if (flagged) caution(`${flagged} inline comment(s) flag things to review (verbatim types, checks, cross-schema FKs).`);
|
|
362
360
|
if (abilities === 'commented') {
|
|
363
361
|
note(`Each model scaffolds its authz decision as comments — author them (db:check fails until every model declares), or stamp the common case: db:pull --abilities public-read.`);
|
|
364
362
|
} else {
|
|
@@ -46,12 +46,13 @@ import {
|
|
|
46
46
|
ENSURE_RECONCILER_SQL,
|
|
47
47
|
} from '../derived-apply.js';
|
|
48
48
|
import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
|
|
49
|
+
import { borrowedSessionRunner, type SessionRunner } from '../session.js';
|
|
49
50
|
import { resolveOperatorUrlViaStage } from '../direct-venue.js';
|
|
50
51
|
import { withMutationLease, MutationLeaseError } from '../mutation-lease.js';
|
|
51
52
|
import { loadDeclaredDerived, retiredSqlDirAnywhere, retiredSqlDirFlagRefusal, type DeclaredDerived } from '../declared-derived.js';
|
|
52
53
|
import { currentGitRef } from '../state-apply.js';
|
|
53
54
|
import { resolveConfig, opsFunction } from '../config.js';
|
|
54
|
-
import { invokeAction } from '../aws.js';
|
|
55
|
+
import { invokeAction, lambdaQueryRunner, lambdaSessionRunner } from '../aws.js';
|
|
55
56
|
import { step, success, fail, info, warn } from '../output.js';
|
|
56
57
|
|
|
57
58
|
// ---------------------------------------------------------------------------
|
|
@@ -111,9 +112,10 @@ export function isTransactionalBatch(statements: string[]): boolean {
|
|
|
111
112
|
|
|
112
113
|
export async function executeReconcile(
|
|
113
114
|
runner: QueryRunner,
|
|
115
|
+
session: SessionRunner,
|
|
114
116
|
options: ExecuteOptions = {},
|
|
115
117
|
): Promise<ReconcileRun> {
|
|
116
|
-
const live = await introspectDerived(
|
|
118
|
+
const live = await introspectDerived(session);
|
|
117
119
|
const parsed = { objects: options.declared ?? [], warnings: [] as string[] };
|
|
118
120
|
const plan = planReconcile(parsed, live, options);
|
|
119
121
|
const rendered = renderReconcileSql(plan, parsed.objects);
|
|
@@ -189,11 +191,14 @@ export async function executeReconcile(
|
|
|
189
191
|
}
|
|
190
192
|
}
|
|
191
193
|
|
|
192
|
-
// Re-read the catalog so provenance records the def hashes of what NOW exists, not what we
|
|
193
|
-
// would exist. Inside the transaction this reads the batch's own not-yet-committed
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
|
|
194
|
+
// Re-read the catalog so provenance records the def hashes of what NOW exists, not what we
|
|
195
|
+
// hoped would exist. Inside the transaction this reads the batch's own not-yet-committed
|
|
196
|
+
// writes, so the read must BORROW that transaction: a session of its own would open a nested
|
|
197
|
+
// BEGIN and COMMIT this one out from under the bookkeeping below. The borrowed session pins
|
|
198
|
+
// the canonical search_path inside a savepoint it always rolls back, so the def hashes are
|
|
199
|
+
// stable regardless of the create-time wide path above — and that wide path survives the read.
|
|
200
|
+
// The unwrapped path has no transaction to borrow, so it takes a session of its own.
|
|
201
|
+
const after = await introspectDerived(atomic ? borrowedSessionRunner(runner) : session);
|
|
197
202
|
const liveById = new Map(after.objects.map((o) => [o.identity, o]));
|
|
198
203
|
const srcById = new Map(parsed.objects.map((o) => [o.identity, o]));
|
|
199
204
|
|
|
@@ -347,15 +352,6 @@ export function checkFails(plan: ReconcilePlan): boolean {
|
|
|
347
352
|
// The CLI shell.
|
|
348
353
|
// ---------------------------------------------------------------------------
|
|
349
354
|
|
|
350
|
-
/** A QueryRunner backed by the ops Lambda `db:query` action (read-only). */
|
|
351
|
-
function lambdaRunner(region: string, fn: string): QueryRunner {
|
|
352
|
-
return async (sql: string) => {
|
|
353
|
-
const result: any = await invokeAction(region, fn, 'db:query', { sql });
|
|
354
|
-
if (result?.error) throw new Error(`Query failed: ${result.error}`);
|
|
355
|
-
return result?.rows ?? [];
|
|
356
|
-
};
|
|
357
|
-
}
|
|
358
|
-
|
|
359
355
|
export async function dbReconcileCommand(flags: Record<string, string>): Promise<void> {
|
|
360
356
|
const apply = flags.apply === 'true';
|
|
361
357
|
const check = flags.check === 'true';
|
|
@@ -493,25 +489,27 @@ export async function dbReconcileCommand(flags: Record<string, string>): Promise
|
|
|
493
489
|
run = { plan: result.plan, applied: result.applied, statements: result.statements ?? [], refusal: result.refusal ?? undefined };
|
|
494
490
|
} else {
|
|
495
491
|
let runner: QueryRunner;
|
|
492
|
+
let session: SessionRunner;
|
|
496
493
|
// A write over a direct connection (--database-url or --direct) takes the mutation
|
|
497
494
|
// lease; a read-only stage dry-run over the ops Lambda does not (reads never lease).
|
|
498
495
|
let leased = false;
|
|
499
496
|
if (dbSource.kind === 'url') {
|
|
500
497
|
step(connectingVia(dbSource));
|
|
501
|
-
({ runner, end } = await createUrlRunner(dbSource.url));
|
|
498
|
+
({ runner, session, end } = await createUrlRunner(dbSource.url));
|
|
502
499
|
leased = apply;
|
|
503
500
|
} else {
|
|
504
501
|
step('Resolving deployed config...');
|
|
505
502
|
const config = await resolveConfig(flags.stage);
|
|
506
|
-
runner =
|
|
503
|
+
runner = lambdaQueryRunner(config.region, opsFunction(config));
|
|
504
|
+
session = lambdaSessionRunner(config.region, opsFunction(config));
|
|
507
505
|
}
|
|
508
506
|
run = leased
|
|
509
507
|
? await withMutationLease(
|
|
510
508
|
runner,
|
|
511
509
|
{ verb: 'db:reconcile', actor: process.env.USER ?? 'unknown' },
|
|
512
|
-
() => executeReconcile(runner, reconcileOptions),
|
|
510
|
+
() => executeReconcile(runner, session, reconcileOptions),
|
|
513
511
|
)
|
|
514
|
-
: await executeReconcile(runner, reconcileOptions);
|
|
512
|
+
: await executeReconcile(runner, session, reconcileOptions);
|
|
515
513
|
}
|
|
516
514
|
|
|
517
515
|
if (flags.json === 'true') {
|
|
@@ -31,6 +31,7 @@ import { pairedDerivedSchemas, renderPairedDerivedBuild, renderSwapSchemaUsage,
|
|
|
31
31
|
import { introspectDerived } from '../derived-introspect.js';
|
|
32
32
|
import { legacyFunctionIdentity } from '../pg-argtypes.js';
|
|
33
33
|
import { createUrlRunner } from '../db-source.js';
|
|
34
|
+
import { borrowedSessionRunner, type SessionRunner } from '../session.js';
|
|
34
35
|
import type { QueryRunner } from '../authz-contract.js';
|
|
35
36
|
import { executeSwap, type SwapVerdict } from '../swap-execute.js';
|
|
36
37
|
import { startHeartbeat, humanElapsed } from '../swap-heartbeat.js';
|
|
@@ -626,14 +627,14 @@ export async function dbSwapCommand(flags: Record<string, string>): Promise<void
|
|
|
626
627
|
}
|
|
627
628
|
const artifactFingerprint = artifact.fingerprint;
|
|
628
629
|
|
|
629
|
-
const { runner, end } = await createUrlRunner(url);
|
|
630
|
+
const { runner, session, end } = await createUrlRunner(url);
|
|
630
631
|
try {
|
|
631
632
|
step(`Swapping ${schema} — gate, land incoming, atomic swap, verify...`);
|
|
632
633
|
// One operator mutates a database at a time — the swap is a whole-schema replacement.
|
|
633
634
|
const res = await withMutationLease(
|
|
634
635
|
runner,
|
|
635
636
|
{ verb: 'db:swap', actor: process.env.USER ?? 'unknown' },
|
|
636
|
-
() => executeSwap(runner, {
|
|
637
|
+
() => executeSwap(runner, session, {
|
|
637
638
|
models, schema,
|
|
638
639
|
artifactFingerprint,
|
|
639
640
|
declaredFingerprint,
|
|
@@ -659,8 +660,8 @@ export async function dbSwapCommand(flags: Record<string, string>): Promise<void
|
|
|
659
660
|
// later, for an unrelated edit — sees the whole layer as drift and rebuilds it under
|
|
660
661
|
// ACCESS EXCLUSIVE. The objects are correct; only the bookkeeping was missing.
|
|
661
662
|
recordProvenance: paired.length
|
|
662
|
-
? async (r) => {
|
|
663
|
-
const live = await introspectDerived(
|
|
663
|
+
? async (r, s) => {
|
|
664
|
+
const live = await introspectDerived(s);
|
|
664
665
|
const prov = renderPairedProvenance(declaredDerivedObjects, live.objects, schema, paired);
|
|
665
666
|
for (const st of prov.statements) await r(st);
|
|
666
667
|
if (prov.recorded.length === 0) {
|