@everystack/cli 0.4.44 → 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 +314 -0
- package/src/cli/authz-baseline.ts +25 -3
- package/src/cli/authz-canonical.ts +178 -0
- package/src/cli/authz-compile.ts +130 -40
- package/src/cli/authz-contract.ts +212 -44
- package/src/cli/authz-derive.ts +244 -34
- package/src/cli/authz-identity.ts +222 -0
- package/src/cli/authz-ownership.ts +193 -0
- package/src/cli/authz-reconcile.ts +61 -27
- 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 +59 -15
- package/src/cli/commands/db-plan.ts +89 -9
- package/src/cli/commands/db-pull.ts +36 -19
- 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 +125 -17
- package/src/cli/git-descent.ts +16 -9
- package/src/cli/index.ts +2 -18
- package/src/cli/model-render.ts +75 -52
- package/src/cli/output.ts +25 -3
- package/src/cli/parse-flags.ts +39 -0
- package/src/cli/schema-compile.ts +6 -1
- package/src/cli/schema-diff.ts +1 -1
- package/src/cli/schema-fingerprint.ts +154 -42
- 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
|
@@ -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) {
|
|
@@ -48,6 +48,7 @@ import { fingerprintModels } from '../schema-fingerprint.js';
|
|
|
48
48
|
import { compileDrizzleSource } from '../schema-source.js';
|
|
49
49
|
import type { SourceFile } from '../derived-source.js';
|
|
50
50
|
import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
|
|
51
|
+
import { borrowedSessionRunner, type SessionRunner } from '../session.js';
|
|
51
52
|
import { planBackfills, readBackfillLog } from '../backfill.js';
|
|
52
53
|
import { resolveModelsPath } from '../models-path.js';
|
|
53
54
|
import { executeReconcile, buildReconcileReport, type ReconcileRun } from './db-reconcile.js';
|
|
@@ -113,26 +114,27 @@ export interface SyncRun {
|
|
|
113
114
|
*/
|
|
114
115
|
export async function executeSync(
|
|
115
116
|
runner: QueryRunner,
|
|
117
|
+
session: SessionRunner,
|
|
116
118
|
models: ModelDescriptor[],
|
|
117
119
|
options: SyncOptions = {},
|
|
118
120
|
hooks: SyncHooks = {},
|
|
119
121
|
): Promise<SyncRun> {
|
|
120
|
-
const current = await introspectSchema(
|
|
121
|
-
const liveAuthz = await introspectContract(
|
|
122
|
+
const current = await introspectSchema(session);
|
|
123
|
+
const liveAuthz = await introspectContract(session, contractFunctionRow, FUNCTIONS_SQL);
|
|
122
124
|
const statements = generateMigrationSql(models, current, {
|
|
123
125
|
allowDrops: options.allowDrops, liveAuthz, sequences: options.sequences,
|
|
124
126
|
governedRoles: options.governedRoles,
|
|
125
127
|
});
|
|
126
128
|
hooks.onStatePlan?.(statements, classifyGeneratedStatements(statements));
|
|
127
129
|
|
|
128
|
-
const state = await applyStateAndVerify(runner, models, statements, current, liveAuthz, {
|
|
130
|
+
const state = await applyStateAndVerify(runner, session, models, statements, current, liveAuthz, {
|
|
129
131
|
allowDrops: options.allowDrops, sequences: options.sequences,
|
|
130
132
|
actor: options.actor, gitRef: options.gitRef, now: options.now,
|
|
131
133
|
});
|
|
132
134
|
hooks.onStateDone?.(state);
|
|
133
135
|
|
|
134
136
|
// The declared derived layer is the one compute stream; nothing declared = skipped.
|
|
135
|
-
const compute = !options.declared?.length ? null : await executeReconcile(runner, {
|
|
137
|
+
const compute = !options.declared?.length ? null : await executeReconcile(runner, session, {
|
|
136
138
|
apply: true,
|
|
137
139
|
baseline: options.baseline,
|
|
138
140
|
rebaseline: options.rebaseline,
|
|
@@ -303,11 +305,12 @@ export async function dbSyncCommand(flags: Record<string, string>): Promise<void
|
|
|
303
305
|
}
|
|
304
306
|
|
|
305
307
|
step(connectingVia(dbSource));
|
|
306
|
-
const { runner, end } = await createUrlRunner(dbSource.url);
|
|
308
|
+
const { runner, session, end } = await createUrlRunner(dbSource.url);
|
|
307
309
|
|
|
308
310
|
try {
|
|
309
311
|
const run = await executeSync(
|
|
310
312
|
runner,
|
|
313
|
+
session,
|
|
311
314
|
models,
|
|
312
315
|
{
|
|
313
316
|
declared: declaredDb?.objects,
|
package/src/cli/db-build.ts
CHANGED
|
@@ -107,10 +107,10 @@ export async function buildIntoDatabase(
|
|
|
107
107
|
models: ModelDescriptor[],
|
|
108
108
|
options: BuildOptions = {},
|
|
109
109
|
): Promise<BuildResult> {
|
|
110
|
-
const { runner, end } = await createUrlRunner(url);
|
|
110
|
+
const { runner, session, end } = await createUrlRunner(url);
|
|
111
111
|
try {
|
|
112
112
|
const createdRoles = await ensureContractRoles(runner, models);
|
|
113
|
-
const run = await executeSync(runner, models, {
|
|
113
|
+
const run = await executeSync(runner, session, models, {
|
|
114
114
|
declared: options.declared,
|
|
115
115
|
sequences: options.sequences,
|
|
116
116
|
actor: options.actor ?? 'db-build',
|
package/src/cli/db-source.ts
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
import type { QueryRunner } from './authz-contract.js';
|
|
28
|
+
import { buildSearchPathSql, type SessionRunner, type SessionResult } from './session.js';
|
|
28
29
|
|
|
29
30
|
export type DbSource =
|
|
30
31
|
| { kind: 'url'; url: string; from: 'flag' | 'env' | 'admin-env' | 'operator' }
|
|
@@ -65,10 +66,61 @@ export function connectingVia(source: Extract<DbSource, { kind: 'url' }>): strin
|
|
|
65
66
|
|
|
66
67
|
export interface UrlRunner {
|
|
67
68
|
runner: QueryRunner;
|
|
69
|
+
/**
|
|
70
|
+
* The direct lane's `SessionRunner` — N statements in ONE postgres.js transaction on
|
|
71
|
+
* the single connection. The stage lane's twin is `lambdaSessionRunner` (one invoke,
|
|
72
|
+
* one container); both honor the same ordering, `SET LOCAL` and `allowFailure` rules,
|
|
73
|
+
* so a caller typed against `SessionRunner` reads identically on either venue.
|
|
74
|
+
*/
|
|
75
|
+
session: SessionRunner;
|
|
68
76
|
/** Close the client so the process can exit cleanly. */
|
|
69
77
|
end: () => Promise<void>;
|
|
70
78
|
}
|
|
71
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Build a `SessionRunner` over a postgres.js client whose pool is a single connection.
|
|
82
|
+
*
|
|
83
|
+
* `sql.begin` holds that one connection for the whole callback, so every statement here
|
|
84
|
+
* shares a session by construction. The `searchPath` pin is `SET LOCAL` (it reverts with
|
|
85
|
+
* the transaction, never outliving its command) and consumes no result slot.
|
|
86
|
+
*
|
|
87
|
+
* An `allowFailure` statement rides `tx.savepoint`, the DRIVER's savepoint — not a
|
|
88
|
+
* hand-issued `SAVEPOINT` / `ROLLBACK TO SAVEPOINT` pair. postgres.js records any query
|
|
89
|
+
* error raised inside a transaction scope and re-throws it after the callback returns, so
|
|
90
|
+
* catching the error ourselves recovers the database and still loses the session. Its
|
|
91
|
+
* savepoint opens a nested scope with its own error bookkeeping, which is the only shape
|
|
92
|
+
* that survives. (Proven on a live PostgreSQL: the hand-rolled version passed against a
|
|
93
|
+
* fake and failed against the driver.)
|
|
94
|
+
*/
|
|
95
|
+
export function sessionRunnerOver(sql: any): SessionRunner {
|
|
96
|
+
return async (statements, opts) => {
|
|
97
|
+
const stmts = statements.map((s) => (typeof s === 'string' ? { sql: s } : s));
|
|
98
|
+
const results: SessionResult[] = [];
|
|
99
|
+
await sql.begin(async (tx: any) => {
|
|
100
|
+
if (opts?.isolation) await tx.unsafe(`SET TRANSACTION ISOLATION LEVEL ${opts.isolation}`);
|
|
101
|
+
if (opts?.readOnly) await tx.unsafe('SET TRANSACTION READ ONLY');
|
|
102
|
+
if (opts?.searchPath !== undefined) {
|
|
103
|
+
await tx.unsafe(buildSearchPathSql(String(opts.searchPath)));
|
|
104
|
+
}
|
|
105
|
+
for (const stmt of stmts) {
|
|
106
|
+
if (!stmt.allowFailure) {
|
|
107
|
+
results.push(Array.from(await tx.unsafe(stmt.sql)));
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
results.push(
|
|
112
|
+
await tx.savepoint(async (sp: any) => Array.from(await sp.unsafe(stmt.sql))),
|
|
113
|
+
);
|
|
114
|
+
} catch (err: any) {
|
|
115
|
+
results.push({ everystackSessionError: true, message: err?.message || String(err) });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
return results;
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
72
124
|
/**
|
|
73
125
|
* Load the postgres.js driver, or explain how to get it. Shared by every direct-connection
|
|
74
126
|
* runner below so the missing-driver instructions can never drift between them.
|
|
@@ -101,6 +153,7 @@ export async function createUrlRunner(
|
|
|
101
153
|
const sql = postgres(url, { max: 1, max_lifetime: null, onnotice: () => {}, ...sslDefaults(url) });
|
|
102
154
|
return {
|
|
103
155
|
runner: async (query: string) => Array.from(await sql.unsafe(query)),
|
|
156
|
+
session: sessionRunnerOver(sql),
|
|
104
157
|
end: () => sql.end({ timeout: 5 }),
|
|
105
158
|
};
|
|
106
159
|
}
|
|
@@ -135,6 +188,8 @@ export async function createUrlPipelineRunner(
|
|
|
135
188
|
export interface UrlProbeRunner {
|
|
136
189
|
/** Read-only introspection, for the contract pull/diff. */
|
|
137
190
|
runner: QueryRunner;
|
|
191
|
+
/** N statements on this one connection in one transaction — see sessionRunnerOver. */
|
|
192
|
+
session: SessionRunner;
|
|
138
193
|
/** The self-reverting red-team probe. See `probe` below. */
|
|
139
194
|
probe: (setup: string, read: string) => Promise<any[]>;
|
|
140
195
|
/** Close the client so the process can exit cleanly. */
|
|
@@ -166,6 +221,7 @@ export async function createUrlProbeRunner(
|
|
|
166
221
|
const sql = postgres(url, { max: 1, max_lifetime: null, onnotice: () => {}, ...sslDefaults(url) });
|
|
167
222
|
return {
|
|
168
223
|
runner: async (query: string) => Array.from(await sql.unsafe(query)),
|
|
224
|
+
session: sessionRunnerOver(sql),
|
|
169
225
|
probe: async (setup: string, read: string) => {
|
|
170
226
|
let rows: any[] = [];
|
|
171
227
|
try {
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
import { createHash } from 'node:crypto';
|
|
21
21
|
import { IGNORED_SCHEMAS, type QueryRunner } from './authz-contract.js';
|
|
22
22
|
import { normalizeSql, type DerivedKind } from './derived-source.js';
|
|
23
|
-
import {
|
|
23
|
+
import { INTROSPECTION_SESSION, rowsOrEmpty, type SessionRunner } from './session.js';
|
|
24
24
|
|
|
25
25
|
export interface LiveObject {
|
|
26
26
|
kind: DerivedKind;
|
|
@@ -591,30 +591,31 @@ export function assembleDerivedCatalog(rows: DerivedRows): DerivedCatalog {
|
|
|
591
591
|
* injected runner; a missing provenance table (first run against a database
|
|
592
592
|
* the reconciler has never touched) folds to an empty claims list.
|
|
593
593
|
*/
|
|
594
|
-
export async function introspectDerived(
|
|
595
|
-
//
|
|
596
|
-
// renders a ref bare while its schema is in scope, qualified once off)
|
|
597
|
-
//
|
|
598
|
-
//
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
594
|
+
export async function introspectDerived(session: SessionRunner): Promise<DerivedCatalog> {
|
|
595
|
+
// ONE session: view/matview/function bodies deparse relative to the search_path
|
|
596
|
+
// (`pg_get_viewdef` renders a ref bare while its schema is in scope, qualified once off),
|
|
597
|
+
// so the recorded def hash is only stable if every body is captured under one pinned path
|
|
598
|
+
// at one moment.
|
|
599
|
+
//
|
|
600
|
+
// The provenance read is `allowFailure`: `everystack.derived_provenance` does not exist
|
|
601
|
+
// until something has been reconciled, and its absence is an ANSWER (no claims), not a
|
|
602
|
+
// failure. Before the session it was a try/catch, which is the same intent expressed the
|
|
603
|
+
// only way a one-statement-at-a-time runner could — by being willing to lose the session.
|
|
604
|
+
const [relations, functions, indexes, depends, triggers, grants, provenance] = await session(
|
|
605
|
+
[
|
|
606
|
+
DERIVED_RELATIONS_SQL, DERIVED_FUNCTIONS_SQL, MATVIEW_INDEXES_SQL, DERIVED_DEPENDS_SQL,
|
|
607
|
+
DERIVED_TRIGGERS_SQL, DERIVED_GRANTS_SQL,
|
|
608
|
+
{ sql: PROVENANCE_SQL, allowFailure: true },
|
|
609
|
+
],
|
|
610
|
+
INTROSPECTION_SESSION,
|
|
611
|
+
);
|
|
612
|
+
return assembleDerivedCatalog({
|
|
613
|
+
relations: relations as RelationRow[],
|
|
614
|
+
functions: functions as FunctionRow[],
|
|
615
|
+
indexes: indexes as IndexDefRow[],
|
|
616
|
+
depends: depends as DependsRow[],
|
|
617
|
+
triggers: triggers as TriggerRow[],
|
|
618
|
+
grants: grants as GrantAclRow[],
|
|
619
|
+
provenance: rowsOrEmpty(provenance) as ProvenanceRawRow[],
|
|
619
620
|
});
|
|
620
621
|
}
|
package/src/cli/derived-lint.ts
CHANGED
|
@@ -26,9 +26,10 @@
|
|
|
26
26
|
* grant.
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
|
+
import { isColumnAbility } from '@everystack/model';
|
|
29
30
|
import type {
|
|
30
31
|
ModelDescriptor, DerivedDescriptor, ViewDescriptor, MaterializedViewDescriptor,
|
|
31
|
-
FunctionDescriptor, DependsOnRef,
|
|
32
|
+
FunctionDescriptor, DependsOnRef,
|
|
32
33
|
} from '@everystack/model';
|
|
33
34
|
import { parseQualified } from './derived-source.js';
|
|
34
35
|
|
|
@@ -47,11 +48,6 @@ function isModelRef(ref: DependsOnRef): ref is ModelDescriptor {
|
|
|
47
48
|
return 'table' in ref;
|
|
48
49
|
}
|
|
49
50
|
|
|
50
|
-
/** The column-scoped self read — same predicate as the table grant compiler. */
|
|
51
|
-
function isColumnRead(a: Ability): boolean {
|
|
52
|
-
return a.action === 'read' && Boolean(a.condition.owner) && Boolean(a.condition.columns?.length);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
51
|
/**
|
|
56
52
|
* Does the table's grant compile give `role` SELECT (full or column-scoped)?
|
|
57
53
|
*
|
|
@@ -65,8 +61,11 @@ function isColumnRead(a: Ability): boolean {
|
|
|
65
61
|
*/
|
|
66
62
|
export function tableReaches(m: ModelDescriptor, role: string): boolean {
|
|
67
63
|
for (const a of m.abilities) {
|
|
68
|
-
if (
|
|
69
|
-
|
|
64
|
+
if (isColumnAbility(a)) {
|
|
65
|
+
// Only the READ half reaches: a column-scoped update compiles to `UPDATE (cols)`
|
|
66
|
+
// and no SELECT, so counting it as reach would pass an invoker view the database
|
|
67
|
+
// then refuses at runtime — the false-negative this gate's docstring warns about.
|
|
68
|
+
if (a.action === 'read' && (a.condition.role ?? 'authenticated') === role) return true;
|
|
70
69
|
continue;
|
|
71
70
|
}
|
|
72
71
|
if (a.action !== 'read' && a.action !== 'manage') continue;
|
package/src/cli/edge-plan.ts
CHANGED
|
@@ -31,7 +31,7 @@ import type { AuthzContract } from './authz-contract.js';
|
|
|
31
31
|
import { generateMigrationSql, unmodeledTables } from './migration-generate.js';
|
|
32
32
|
import { compileTableContract } from './authz-compile.js';
|
|
33
33
|
import { classifyGeneratedStatements, classifyDestructive, partitionStatements, renderStatementHistogram } from './state-apply.js';
|
|
34
|
-
import { fingerprintLive, fingerprintState, fingerprintModels, stableStringify } from './schema-fingerprint.js';
|
|
34
|
+
import { fingerprintLive, fingerprintState, fingerprintModels, governedRolesForModels, stableStringify, compareStoredFingerprint, formatChangedMessage, FINGERPRINT_VERSION } from './schema-fingerprint.js';
|
|
35
35
|
import { compileDeclaredState } from './declared-diff.js';
|
|
36
36
|
import { compileTableRenames, compileTableMoves } from './schema-compile.js';
|
|
37
37
|
|
|
@@ -61,8 +61,27 @@ export interface EdgePlan {
|
|
|
61
61
|
v: number;
|
|
62
62
|
/** The target's exact live fingerprint at mint time — the lock. */
|
|
63
63
|
fromFingerprint: string;
|
|
64
|
-
/**
|
|
64
|
+
/**
|
|
65
|
+
* The FINGERPRINT_VERSION the endpoints were computed under. Stored beside the hashes so a
|
|
66
|
+
* plan minted under an older canonical form REFUSES as a format change rather than reporting
|
|
67
|
+
* drift that no edit could explain. Absent on plans minted before v4.
|
|
68
|
+
*/
|
|
69
|
+
fpVersion?: number;
|
|
70
|
+
/**
|
|
71
|
+
* The predicted live fingerprint after apply — exact, unmodeled-aware, and
|
|
72
|
+
* GOVERNED: hashed through the models' governed-role set (`governedRoles`
|
|
73
|
+
* below), because it is a declared-vs-live claim. Compare it only against
|
|
74
|
+
* `governedLiveFingerprint` computed with the same set — never against the
|
|
75
|
+
* raw `from` flavor.
|
|
76
|
+
*/
|
|
65
77
|
toFingerprint: string;
|
|
78
|
+
/**
|
|
79
|
+
* The governed-role set `to` was hashed under, sorted — carried on the plan
|
|
80
|
+
* so every apply venue verifies with the exact set the mint used. Absent on
|
|
81
|
+
* plans minted before this field existed (those predate the governed `to`
|
|
82
|
+
* and verify raw, as they were minted).
|
|
83
|
+
*/
|
|
84
|
+
governedRoles?: string[];
|
|
66
85
|
/** The models-only fingerprint — context for the strong claim. Equals `to` when nothing is unmodeled. */
|
|
67
86
|
declaredFingerprint: string;
|
|
68
87
|
/** The edge, in db:generate's statement grammar (no held drops — mint refuses them). */
|
|
@@ -72,6 +91,21 @@ export interface EdgePlan {
|
|
|
72
91
|
destructive: number;
|
|
73
92
|
classification: PlanClassification;
|
|
74
93
|
notices: number;
|
|
94
|
+
/**
|
|
95
|
+
* WHY each authorization statement exists, one entry per statement — the per-statement half
|
|
96
|
+
* of the aggregate the summary prints.
|
|
97
|
+
*
|
|
98
|
+
* Optional because it is re-derived against LIVE at mint time, so a plan minted without a
|
|
99
|
+
* reachable database (or before this field existed) simply has none. Never trusted as a
|
|
100
|
+
* stored claim: like the counts, it describes the target as it was when the plan was cut.
|
|
101
|
+
*/
|
|
102
|
+
adoption?: { table: string; subject: string; cls: string; why: string }[];
|
|
103
|
+
/**
|
|
104
|
+
* Tables this plan leaves with no surviving read path — resolved at mint against the LIVE
|
|
105
|
+
* contract, which is the only moment both halves of that question are in hand. Absent on
|
|
106
|
+
* plans minted before this field existed; the summary falls back to the text-only reading.
|
|
107
|
+
*/
|
|
108
|
+
dark?: string[];
|
|
75
109
|
/** Live tables no model declares — untouched by the edge, riding into `to` as-is. */
|
|
76
110
|
unmodeled: string[];
|
|
77
111
|
gitRef: string | null;
|
|
@@ -108,8 +142,14 @@ export function predictLiveFingerprint(
|
|
|
108
142
|
models: ModelDescriptor[],
|
|
109
143
|
snapshot: SchemaSnapshot,
|
|
110
144
|
contract: AuthzContract,
|
|
111
|
-
opts: { schema?: string } = {},
|
|
145
|
+
opts: { schema?: string; governedRoles?: ReadonlySet<string> } = {},
|
|
112
146
|
): string {
|
|
147
|
+
// The prediction is a DECLARED-vs-live comparison, so it must hash through the
|
|
148
|
+
// models' governed set — the live side of any comparison against it must use
|
|
149
|
+
// the same set (see governedLiveFingerprint). Unfiltered, every ride-through
|
|
150
|
+
// table's foreign grants poison the endpoint and verify-after can never pass
|
|
151
|
+
// on a brownfield target.
|
|
152
|
+
const governedRoles = opts.governedRoles ?? governedRolesForModels(models);
|
|
113
153
|
const declared = compileDeclaredState(models, { schema: opts.schema });
|
|
114
154
|
const declaredTables = new Set(declared.snapshot.tables.map((t) => t.table));
|
|
115
155
|
const currentNames = new Set(snapshot.tables.map((t) => t.table));
|
|
@@ -144,7 +184,21 @@ export function predictLiveFingerprint(
|
|
|
144
184
|
...declared.contract.tables,
|
|
145
185
|
...contract.tables.filter((t) => ridesThrough(t.table)),
|
|
146
186
|
];
|
|
147
|
-
return fingerprintState(mergedSnapshot, mergedAuthz).hash;
|
|
187
|
+
return fingerprintState(mergedSnapshot, mergedAuthz, { governedRoles }).hash;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The live half of every declared-vs-live comparison: the target's state hashed
|
|
192
|
+
* through the SAME governed set as the prediction. `predicted === governedLive`
|
|
193
|
+
* is the declares-test; comparing a prediction against an UNFILTERED live hash
|
|
194
|
+
* is a category error that can never converge on a brownfield database.
|
|
195
|
+
*/
|
|
196
|
+
export function governedLiveFingerprint(
|
|
197
|
+
snapshot: SchemaSnapshot,
|
|
198
|
+
contract: AuthzContract,
|
|
199
|
+
governedRoles: ReadonlySet<string>,
|
|
200
|
+
): string {
|
|
201
|
+
return fingerprintState(snapshot, contract.tables, { governedRoles }).hash;
|
|
148
202
|
}
|
|
149
203
|
|
|
150
204
|
/**
|
|
@@ -187,11 +241,21 @@ export function mintEdgePlan(
|
|
|
187
241
|
const breakdown = partitionStatements(classified.executable, { declaredGrantees });
|
|
188
242
|
const destructive = breakdown.drops.length + breakdown.narrowings.length + breakdown.strips.length;
|
|
189
243
|
|
|
244
|
+
// The plan's two endpoints are two different FLAVORS, deliberately:
|
|
245
|
+
// - `from` is the RAW live hash (everything introspected) — the concurrency
|
|
246
|
+
// lock and the backup-stamp chain (backup.fp == plan.from) compare
|
|
247
|
+
// live-vs-live and must stay computable with no models in hand.
|
|
248
|
+
// - `to` is the GOVERNED hash — a declared-vs-live claim, filtered through
|
|
249
|
+
// the models' governed set, which rides ON the plan so every venue
|
|
250
|
+
// (direct, ops Lambda) verifies with the set the mint used.
|
|
251
|
+
const governedRoles = governedRolesForModels(models, opts.governedRoles);
|
|
190
252
|
return {
|
|
191
253
|
v: PLAN_VERSION,
|
|
192
254
|
fromFingerprint: fingerprintLive(snapshot, contract).hash,
|
|
193
|
-
|
|
255
|
+
fpVersion: FINGERPRINT_VERSION,
|
|
256
|
+
toFingerprint: predictLiveFingerprint(models, snapshot, contract, { schema: opts.schema, governedRoles }),
|
|
194
257
|
declaredFingerprint: fingerprintModels(models, { schema: opts.schema }).hash,
|
|
258
|
+
governedRoles: [...governedRoles].sort(),
|
|
195
259
|
statements,
|
|
196
260
|
executable: classified.executable.length,
|
|
197
261
|
destructive,
|
|
@@ -206,6 +270,10 @@ export function mintEdgePlan(
|
|
|
206
270
|
...(breakdown.unclassified.length > 0 ? { unclassified: breakdown.unclassified.length } : {}),
|
|
207
271
|
},
|
|
208
272
|
notices: classified.notices.length,
|
|
273
|
+
// Resolved HERE because this is the only place that holds both the edge and the live
|
|
274
|
+
// contract. The summary is rendered later from the plan alone, and answering "does a read
|
|
275
|
+
// survive?" from statement text is what produced three false positives on a real plan.
|
|
276
|
+
dark: tablesLeftWithoutARead(statements, contract),
|
|
209
277
|
unmodeled: unmodeledTables(models, snapshot),
|
|
210
278
|
gitRef: opts.gitRef ?? null,
|
|
211
279
|
mintedBy: opts.actor ?? null,
|
|
@@ -220,25 +288,58 @@ export function mintEdgePlan(
|
|
|
220
288
|
* enabled and the grant is still there, so the table returns ZERO rows to that role. On a real
|
|
221
289
|
* adoption plan that was 11 tables, including the users table, and it printed no notice at all.
|
|
222
290
|
*
|
|
223
|
-
* Deliberately conservative: it
|
|
224
|
-
*
|
|
225
|
-
*
|
|
291
|
+
* Deliberately conservative: it names a table only when NO read path survives the plan for any
|
|
292
|
+
* role, so it under-reports rather than crying wolf. Pass the LIVE contract to get that
|
|
293
|
+
* question answered properly — without it, a `DROP POLICY` statement does not say which
|
|
294
|
+
* command it policed and the fallback can only count drops.
|
|
226
295
|
*/
|
|
227
|
-
export function tablesLeftWithoutARead(statements: readonly string[]): string[] {
|
|
228
|
-
const
|
|
229
|
-
const
|
|
296
|
+
export function tablesLeftWithoutARead(statements: readonly string[], live?: AuthzContract): string[] {
|
|
297
|
+
const droppedByTable = new Map<string, Set<string>>();
|
|
298
|
+
const createdRead = new Set<string>();
|
|
299
|
+
const touched = new Set<string>();
|
|
300
|
+
|
|
230
301
|
for (const statement of statements) {
|
|
231
302
|
const head = (statement.split('\n').find((l) => l.trim() !== '' && !l.trim().startsWith('--')) ?? '').trim();
|
|
232
|
-
let m = /^DROP\s+POLICY\s+(?:IF\s+EXISTS\s+)
|
|
303
|
+
let m = /^DROP\s+POLICY\s+(?:IF\s+EXISTS\s+)?(\S+)\s+ON\s+(\S+?);?$/i.exec(head);
|
|
233
304
|
if (m) {
|
|
234
|
-
|
|
305
|
+
touched.add(m[2]);
|
|
306
|
+
const names = droppedByTable.get(m[2]) ?? new Set<string>();
|
|
307
|
+
names.add(m[1]);
|
|
308
|
+
droppedByTable.set(m[2], names);
|
|
235
309
|
continue;
|
|
236
310
|
}
|
|
237
311
|
m = /^CREATE\s+POLICY\s+\S+\s+ON\s+(\S+)/i.exec(head);
|
|
238
312
|
// Only a SELECT-admitting policy restores a read; an INSERT-only policy does not.
|
|
239
|
-
if (m && /\bFOR\s+(SELECT|ALL)\b/i.test(head))
|
|
313
|
+
if (m && /\bFOR\s+(SELECT|ALL)\b/i.test(head)) createdRead.add(m[1]);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Without the live contract this can only count drops, and a DROP POLICY statement does not
|
|
317
|
+
// carry the command it policed. That is the old behaviour, kept for plans minted before the
|
|
318
|
+
// contract was threaded through: it OVER-reports, naming a table whose dropped policy was an
|
|
319
|
+
// UPDATE and whose reads were never touched.
|
|
320
|
+
if (!live) return [...droppedByTable.keys()].filter((t) => !createdRead.has(t)).sort();
|
|
321
|
+
|
|
322
|
+
// With it, ask the real question: does ANY policy admitting SELECT survive this plan? A
|
|
323
|
+
// table is dark only when none does — not merely because some policy on it was dropped.
|
|
324
|
+
//
|
|
325
|
+
// Three false positives on a real adoption plan came from the cheaper question: two tables
|
|
326
|
+
// lost only an UPDATE policy, and one lost a single role-scoped read while anon and
|
|
327
|
+
// authenticated reads survived untouched. A safety detector that cries wolf spends the
|
|
328
|
+
// credibility of its true positives, so this deliberately under-reports instead.
|
|
329
|
+
const byTable = new Map(live.tables.map((t) => [t.table, t]));
|
|
330
|
+
const dark: string[] = [];
|
|
331
|
+
for (const table of touched) {
|
|
332
|
+
if (createdRead.has(table)) continue;
|
|
333
|
+
const contract = byTable.get(table);
|
|
334
|
+
if (!contract) continue; // not a live table — nothing to go dark
|
|
335
|
+
if (!contract.rls?.enabled) continue; // RLS off: policies do not gate the read at all
|
|
336
|
+
const dropped = droppedByTable.get(table) ?? new Set<string>();
|
|
337
|
+
const readSurvives = contract.policies.some(
|
|
338
|
+
(p) => (p.command === 'SELECT' || p.command === 'ALL') && !dropped.has(p.name),
|
|
339
|
+
);
|
|
340
|
+
if (!readSurvives) dark.push(table);
|
|
240
341
|
}
|
|
241
|
-
return
|
|
342
|
+
return dark.sort();
|
|
242
343
|
}
|
|
243
344
|
|
|
244
345
|
/** The plan's content address — recorded as `plan_ref` on the schema_log row. */
|
|
@@ -250,7 +351,12 @@ export type PlanPrecondition = { ok: true } | { ok: false; reason: string };
|
|
|
250
351
|
|
|
251
352
|
/** The lock: apply only when the target is exactly where the plan started. */
|
|
252
353
|
export function checkPlanPrecondition(plan: EdgePlan, liveFingerprint: string): PlanPrecondition {
|
|
253
|
-
|
|
354
|
+
const cmp = compareStoredFingerprint({ hash: plan.fromFingerprint, v: plan.fpVersion }, liveFingerprint);
|
|
355
|
+
if (cmp.kind === 'match') return { ok: true };
|
|
356
|
+
// A plan minted under an older canonical form cannot be compared at all. Saying "the state
|
|
357
|
+
// moved (a concurrent apply or a hand edit)" would send the operator hunting a change that
|
|
358
|
+
// never happened — plans are ephemeral by design, so the honest answer is re-mint.
|
|
359
|
+
if (cmp.kind === 'format-changed') return { ok: false, reason: formatChangedMessage(cmp) };
|
|
254
360
|
return {
|
|
255
361
|
ok: false,
|
|
256
362
|
reason:
|
|
@@ -287,7 +393,9 @@ export function buildPlanSummary(plan: EdgePlan): string[] {
|
|
|
287
393
|
// rows, and a plan that drops a table's only read policy leaves that table returning nothing.
|
|
288
394
|
// 11 tables went dark in a real adoption plan that printed "0 notice(s)".
|
|
289
395
|
const { authzRemovals, unclassified } = partitionStatements(classifyGeneratedStatements(plan.statements).executable);
|
|
290
|
-
|
|
396
|
+
// Prefer what the mint resolved against live. Recomputing here would have only the statement
|
|
397
|
+
// text and would re-introduce the false positives the mint-time answer exists to avoid.
|
|
398
|
+
const dark = plan.dark ?? tablesLeftWithoutARead(plan.statements);
|
|
291
399
|
if (authzRemovals.length > 0) {
|
|
292
400
|
lines.push(`! ${authzRemovals.length} statement(s) REMOVE authorization (policies, grants, RLS). No data is lost; who can read it changes.`);
|
|
293
401
|
if (dark.length > 0) {
|