@everystack/cli 0.4.105 → 0.4.107
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 +34 -0
- package/package.json +3 -3
- package/src/cli/alter-type-dependents.ts +199 -0
- package/src/cli/apply-execute.ts +18 -0
- package/src/cli/commands/db-apply.ts +54 -7
- package/src/cli/commands/db-branch.ts +2 -0
- package/src/cli/commands/db-build.ts +2 -1
- package/src/cli/commands/db-check.ts +12 -1
- package/src/cli/commands/db-plan.ts +31 -12
- package/src/cli/commands/db-reconcile.ts +11 -2
- package/src/cli/commands/db-sync.ts +24 -8
- package/src/cli/db-build.ts +7 -4
- package/src/cli/declared-derived.ts +11 -1
- package/src/cli/derived-catchup.ts +11 -3
- package/src/cli/edge-plan.ts +34 -2
- package/src/cli/lifecycle-preflight.ts +422 -52
- package/src/cli/state-apply.ts +53 -21
|
@@ -63,7 +63,7 @@ import { resolveModelsPath } from '../models-path';
|
|
|
63
63
|
import { resolveConfig, opsFunction } from '../config';
|
|
64
64
|
import { resolveOperatorUrlViaStage } from '../direct-venue';
|
|
65
65
|
import { lambdaQueryRunner, lambdaSessionRunner } from '../aws';
|
|
66
|
-
import {
|
|
66
|
+
import { lifecycleApplyReport, lifecycleProvisionFirst, lifecycleRolesOf, unclassifiedGranteesWarning } from '../lifecycle-preflight';
|
|
67
67
|
import { loadModels } from './db-generate';
|
|
68
68
|
import { loadDeclaredDerived, type DeclaredDerived } from '../declared-derived';
|
|
69
69
|
import { introspectDerived } from '../derived-introspect';
|
|
@@ -71,7 +71,7 @@ import { planReconcile, type ReconcilePlan } from '../derived-plan';
|
|
|
71
71
|
import { checkFails, buildReconcileReport } from './db-reconcile';
|
|
72
72
|
import { governedRoleSet, ungovernedGrants } from '../authz-reconcile';
|
|
73
73
|
import { readBaselineFile, checkAgainstBaseline, baselineRefusal } from '../authz-baseline';
|
|
74
|
-
import { alterTypeStatementTargets,
|
|
74
|
+
import { alterTypeStatementTargets, mintStateDependents, renderStateDependentLine } from '../alter-type-dependents';
|
|
75
75
|
import { compileTableContract } from '../authz-compile';
|
|
76
76
|
import { reportPipelineLastRun } from './pipeline-run';
|
|
77
77
|
import { renderPlanReport } from '../plain-output';
|
|
@@ -238,9 +238,16 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
|
|
|
238
238
|
}
|
|
239
239
|
|
|
240
240
|
try {
|
|
241
|
-
|
|
242
|
-
//
|
|
243
|
-
|
|
241
|
+
// The modules' role declaration, as db:apply reads it. A barrel that does not compose leaves it
|
|
242
|
+
// undeclared here (the check as before); the paths that need the derived layer report the error.
|
|
243
|
+
const lifecycleRoles = lifecycleRolesOf(await loadDeclaredDerived(flags.models).catch(() => null));
|
|
244
|
+
// First, before anything else is said: what db:provision must create or fix. Nothing below
|
|
245
|
+
// can apply until it has run, and the fix is a verb, not SQL.
|
|
246
|
+
const provisionFirst = await lifecycleProvisionFirst(runner, models,
|
|
247
|
+
flags.stage ? `everystack db:provision --stage ${flags.stage}` : 'everystack db:provision --database-url <this database>');
|
|
248
|
+
if (provisionFirst) warn(provisionFirst);
|
|
249
|
+
const unclassified = unclassifiedGranteesWarning(models, lifecycleRoles);
|
|
250
|
+
if (unclassified) warn(unclassified);
|
|
244
251
|
let snapshot: SchemaSnapshot;
|
|
245
252
|
let contract: AuthzContract;
|
|
246
253
|
if (dbSource.kind === 'url') {
|
|
@@ -313,22 +320,34 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
|
|
|
313
320
|
process.exit(1);
|
|
314
321
|
}
|
|
315
322
|
|
|
323
|
+
// The lifecycle catalog check db:apply runs, over THIS plan: a finding the plan's own REVOKE
|
|
324
|
+
// removes is listed as resolved; the rest will block the apply. db:plan only reads, so it
|
|
325
|
+
// mints the plan anyway. Read after the mint because the plan is half of the answer.
|
|
326
|
+
const lifecycle = await lifecycleApplyReport(runner, models, lifecycleRoles, plan.statements);
|
|
327
|
+
if (lifecycle.resolved) info(lifecycle.resolved);
|
|
328
|
+
if (lifecycle.refusal) warn(`db:apply will refuse this plan until each lifecycle finding below is fixed.\n${lifecycle.refusal}`);
|
|
329
|
+
|
|
316
330
|
// The edge is pure, but this decision belongs to mint: only this command holds both the
|
|
317
331
|
// checkout's declared functions and the live catalog. Embedding the exact missing CREATEs
|
|
318
332
|
// lets every apply lane enact the reviewed answer without reading source again.
|
|
319
333
|
plan.statePrerequisites = await mintStatePrerequisites(runner, session, plan.statements, declaredDerived?.objects ?? []);
|
|
320
334
|
|
|
321
|
-
// The plan lane's contract: what it mints, it can apply. A SET DATA TYPE on a
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
//
|
|
335
|
+
// The plan lane's contract: what it mints, it can apply. A SET DATA TYPE on a column a view
|
|
336
|
+
// or matview binds fails at apply ("cannot alter type of a column used by a view or rule").
|
|
337
|
+
// The declared ones ride on the plan: the state transaction drops them, changes the type and
|
|
338
|
+
// re-creates them from this checkout before COMMIT. Anything the platform cannot re-create
|
|
339
|
+
// refuses here, by name, while there is still a live catalog to ask.
|
|
325
340
|
const alterTargets = alterTypeStatementTargets(plan.statements);
|
|
326
341
|
if (alterTargets.length > 0) {
|
|
327
|
-
const
|
|
328
|
-
if (
|
|
329
|
-
fail(`Mint refused: ${
|
|
342
|
+
const dependents = await mintStateDependents(runner, session, alterTargets, declaredDerived?.objects ?? []);
|
|
343
|
+
if ('refusal' in dependents) {
|
|
344
|
+
fail(`Mint refused: ${dependents.refusal}`);
|
|
330
345
|
process.exit(1);
|
|
331
346
|
}
|
|
347
|
+
if (dependents.dependents.length > 0) {
|
|
348
|
+
plan.stateDependents = dependents.dependents;
|
|
349
|
+
for (const dependent of dependents.dependents) info(renderStateDependentLine(dependent));
|
|
350
|
+
}
|
|
332
351
|
}
|
|
333
352
|
|
|
334
353
|
// The class rides on the ARTIFACT, not only the terminal — a reviewer reading
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
import type { QueryRunner } from '../authz-contract';
|
|
33
33
|
import type { SourceObject } from '../derived-source';
|
|
34
34
|
import { introspectDerived } from '../derived-introspect';
|
|
35
|
-
import { catalogInputsFromModels, lifecycleReconcileRefusal } from '../lifecycle-preflight';
|
|
35
|
+
import { catalogInputsFromModels, lifecycleReconcileRefusal, lifecycleRolesOf } from '../lifecycle-preflight';
|
|
36
36
|
import { planReconcile, objectGrantDelta, TRIGGER_ENABLED_STATE, type ReconcilePlan, type ReconcileOptions } from '../derived-plan';
|
|
37
37
|
import {
|
|
38
38
|
renderReconcileSql,
|
|
@@ -84,6 +84,12 @@ export interface ExecuteOptions extends ReconcileOptions {
|
|
|
84
84
|
* the check. `unknown` because the ops Lambda passes it straight off the wire.
|
|
85
85
|
*/
|
|
86
86
|
applicationRoles?: unknown;
|
|
87
|
+
/**
|
|
88
|
+
* The modules' declared request roles and declared owners (LifecycleRoleDeclaration), beside
|
|
89
|
+
* applicationRoles. Optional: absent, the catalog check runs as before. `unknown` for the wire.
|
|
90
|
+
*/
|
|
91
|
+
requestRoles?: unknown;
|
|
92
|
+
declaredOwners?: unknown;
|
|
87
93
|
/**
|
|
88
94
|
* The caller already ran lifecycleApplyRefusal with the full models, on this runner, in this
|
|
89
95
|
* process (executeSync does, first thing), so the reconcile does not run the catalog check again.
|
|
@@ -159,7 +165,9 @@ export async function executeReconcile(
|
|
|
159
165
|
// --rebaseline, --only, --overwrite-drift or none) refuses on it below; a plan-only run reports
|
|
160
166
|
// it as a finding. See lifecycleCatalogRefusal for what it defends and from whom.
|
|
161
167
|
const lifecycleGate = (): Promise<string | null> => (options.lifecyclePreflighted ? Promise.resolve(null)
|
|
162
|
-
: lifecycleReconcileRefusal(runner, parsed.objects, {
|
|
168
|
+
: lifecycleReconcileRefusal(runner, parsed.objects, {
|
|
169
|
+
applicationRoles: options.applicationRoles, requestRoles: options.requestRoles, declaredOwners: options.declaredOwners,
|
|
170
|
+
}));
|
|
163
171
|
|
|
164
172
|
if (!options.apply) {
|
|
165
173
|
const lifecycleFindings = await lifecycleGate();
|
|
@@ -768,6 +776,7 @@ export async function dbReconcileCommand(flags: Record<string, string>): Promise
|
|
|
768
776
|
// The lifecycle catalog check's roles, from the barrel's models. The tables come from the
|
|
769
777
|
// declared transition triggers inside the reconcile, the same derivation the ops Lambda runs.
|
|
770
778
|
applicationRoles: declared?.models ? catalogInputsFromModels(declared.models).applicationRoles : undefined,
|
|
779
|
+
...lifecycleRolesOf(declared),
|
|
771
780
|
renamedTables: declared?.renamedTables,
|
|
772
781
|
baseline: flags.baseline === 'true',
|
|
773
782
|
rebaseline: flags.rebaseline === 'true',
|
|
@@ -31,7 +31,7 @@ import fs from 'node:fs/promises';
|
|
|
31
31
|
import path from 'node:path';
|
|
32
32
|
import type { ModelDescriptor } from '@everystack/model';
|
|
33
33
|
import { introspectContract, type QueryRunner } from '../authz-contract';
|
|
34
|
-
import { catalogInputsFromModels,
|
|
34
|
+
import { catalogInputsFromModels, lifecycleApplyReport, lifecycleDeclaredOwnerRefusal, LifecycleFindingsError, lifecycleRolesOf, type LifecycleRoleDeclaration } from '../lifecycle-preflight';
|
|
35
35
|
import { introspectSchema, type SchemaSnapshot } from '../schema-introspect';
|
|
36
36
|
import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog';
|
|
37
37
|
import { generateMigrationSql, unmodeledTables } from '../migration-generate';
|
|
@@ -86,6 +86,8 @@ export interface SyncOptions {
|
|
|
86
86
|
governedRoles?: string[];
|
|
87
87
|
/** Pending table renames — trigger provenance migrates instead of drop+create. */
|
|
88
88
|
renamedTables?: Record<string, string>;
|
|
89
|
+
/** The modules' role declaration (lifecycleRolesOf): what the lifecycle gates read beside the models. */
|
|
90
|
+
lifecycleRoles?: LifecycleRoleDeclaration;
|
|
89
91
|
/** Injectable clock for tests. */
|
|
90
92
|
now?: () => number;
|
|
91
93
|
}
|
|
@@ -96,6 +98,8 @@ export interface SyncHooks {
|
|
|
96
98
|
onStatePlan?: (statements: string[], classified: ClassifiedStatements, pass: number) => void;
|
|
97
99
|
/** Fires after the state layer applied and verified, before the compute reconcile. */
|
|
98
100
|
onStateDone?: (state: StateSyncOutcome) => void;
|
|
101
|
+
/** Fires before anything executes when the state diff's own REVOKEs resolve lifecycle findings (one line). */
|
|
102
|
+
onLifecycleResolved?: (line: string) => void;
|
|
99
103
|
}
|
|
100
104
|
|
|
101
105
|
export interface SyncRun {
|
|
@@ -138,6 +142,7 @@ async function syncOnce(
|
|
|
138
142
|
options: SyncOptions,
|
|
139
143
|
hooks: SyncHooks,
|
|
140
144
|
pass: number,
|
|
145
|
+
preflight?: (statements: readonly string[]) => Promise<void>,
|
|
141
146
|
): Promise<SyncRun> {
|
|
142
147
|
const current = await introspectSchema(session);
|
|
143
148
|
const liveAuthz = await introspectContract(session, contractFunctionRow, FUNCTIONS_SQL);
|
|
@@ -145,6 +150,8 @@ async function syncOnce(
|
|
|
145
150
|
allowDrops: options.allowDrops, liveAuthz, sequences: options.sequences,
|
|
146
151
|
governedRoles: options.governedRoles, extensions: options.extensions,
|
|
147
152
|
});
|
|
153
|
+
// Read-only, before anything is written: it needs the statements this pass will run.
|
|
154
|
+
await preflight?.(statements);
|
|
148
155
|
const classified = classifyGeneratedStatements(statements);
|
|
149
156
|
hooks.onStatePlan?.(statements, classified, pass);
|
|
150
157
|
// A held drop of a key into a lifecycle table leaves a key the after-state catalog check refuses,
|
|
@@ -183,6 +190,8 @@ async function syncOnce(
|
|
|
183
190
|
// The catalog check runs again here, after the state apply: a table this sync just created can
|
|
184
191
|
// carry a finding (a default privilege, its owner) the check before the state apply could not see.
|
|
185
192
|
applicationRoles: catalogInputsFromModels(models).applicationRoles,
|
|
193
|
+
requestRoles: options.lifecycleRoles?.requestRoles,
|
|
194
|
+
declaredOwners: options.lifecycleRoles?.declaredOwners,
|
|
186
195
|
});
|
|
187
196
|
|
|
188
197
|
const declaredFingerprint = fingerprintModels(models, { sequences: options.sequences }).hash;
|
|
@@ -240,13 +249,18 @@ export async function executeSync(
|
|
|
240
249
|
hooks: SyncHooks = {},
|
|
241
250
|
): Promise<SyncRun> {
|
|
242
251
|
// State runs before compute, so without this a lifecycle table would land with no transition trigger.
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
252
|
+
// It reads the first pass's statements: a finding their own REVOKE removes is resolved, not refused
|
|
253
|
+
// (the state batch runs before the reconcile creates any lifecycle function, which re-checks).
|
|
254
|
+
const preflight = async (statements: readonly string[]): Promise<void> => {
|
|
255
|
+
const lifecycle = await lifecycleApplyReport(runner, models, options.lifecycleRoles, statements);
|
|
256
|
+
if (lifecycle.refusal) throw new Error(lifecycle.refusal);
|
|
257
|
+
if (lifecycle.resolved) hooks.onLifecycleResolved?.(lifecycle.resolved);
|
|
258
|
+
// Before anything is written, so before both places a pass creates derived objects (the
|
|
259
|
+
// functions the state batch calls, then the reconcile).
|
|
260
|
+
const ownerRefusal = await lifecycleDeclaredOwnerRefusal(runner, session, options.declared ?? [], models, { stateApplied: false, roles: options.lifecycleRoles });
|
|
261
|
+
if (ownerRefusal) throw new LifecycleFindingsError(ownerRefusal);
|
|
262
|
+
};
|
|
263
|
+
const first = await syncOnce(runner, session, models, options, hooks, 1, preflight);
|
|
250
264
|
// The trigger is the STATE bar, not the combined verdict. The cause above is state-layer
|
|
251
265
|
// only, so a compute refusal or an unadopted object cannot be settled by looking again —
|
|
252
266
|
// retrying on those would make every brownfield sync pay a second full read (and print its
|
|
@@ -451,6 +465,7 @@ export async function dbSyncCommand(flags: Record<string, string>): Promise<void
|
|
|
451
465
|
sequences: declaredDb?.sequences,
|
|
452
466
|
extensions: declaredDb?.extensions,
|
|
453
467
|
renamedTables: declaredDb?.renamedTables,
|
|
468
|
+
lifecycleRoles: lifecycleRolesOf(declaredDb),
|
|
454
469
|
allowDrops: flags['allow-drops'] === 'true',
|
|
455
470
|
overwriteDrift: flags['overwrite-drift'] === 'true',
|
|
456
471
|
baseline: flags.baseline === 'true',
|
|
@@ -482,6 +497,7 @@ export async function dbSyncCommand(flags: Record<string, string>): Promise<void
|
|
|
482
497
|
onStateDone: () => {
|
|
483
498
|
if (declaredDb?.objects.length) step('Reconciling the declared derived layer...');
|
|
484
499
|
},
|
|
500
|
+
onLifecycleResolved: (line) => info(line),
|
|
485
501
|
},
|
|
486
502
|
);
|
|
487
503
|
|
package/src/cli/db-build.ts
CHANGED
|
@@ -18,7 +18,7 @@ import { compileDeclaredState } from './declared-diff';
|
|
|
18
18
|
import { createUrlRunner } from './db-source';
|
|
19
19
|
import { executeSync, buildSyncReport } from './commands/db-sync';
|
|
20
20
|
import { executeReconcile } from './commands/db-reconcile';
|
|
21
|
-
import { lifecycleApplyRefusal, lifecycleDeclaredOwnerRefusal, LifecycleFindingsError, lifecyclePostApplyRefusal } from './lifecycle-preflight';
|
|
21
|
+
import { lifecycleApplyRefusal, lifecycleDeclaredOwnerRefusal, LifecycleFindingsError, lifecyclePostApplyRefusal, type LifecycleRoleDeclaration } from './lifecycle-preflight';
|
|
22
22
|
|
|
23
23
|
const SAFE_NAME = /^[a-z_][a-z0-9_$]*$/;
|
|
24
24
|
|
|
@@ -101,6 +101,8 @@ export interface BuildOptions {
|
|
|
101
101
|
extensions?: string[];
|
|
102
102
|
actor?: string | null;
|
|
103
103
|
gitRef?: string | null;
|
|
104
|
+
/** The modules' role declaration (lifecycleRolesOf): what the lifecycle gates read beside the models. */
|
|
105
|
+
lifecycleRoles?: LifecycleRoleDeclaration;
|
|
104
106
|
}
|
|
105
107
|
|
|
106
108
|
/**
|
|
@@ -184,7 +186,7 @@ export async function buildIntoDatabase(
|
|
|
184
186
|
// Before the first derived write: the owners the reconcile will give the declared trigger
|
|
185
187
|
// functions on lifecycle tables. This pass runs before executeSync, which judges them again
|
|
186
188
|
// first thing, before its own state apply.
|
|
187
|
-
const ownerRefusal = await lifecycleDeclaredOwnerRefusal(runner, session, options.declared ?? [], models, { stateApplied: false });
|
|
189
|
+
const ownerRefusal = await lifecycleDeclaredOwnerRefusal(runner, session, options.declared ?? [], models, { stateApplied: false, roles: options.lifecycleRoles });
|
|
188
190
|
if (ownerRefusal) throw new LifecycleFindingsError(ownerRefusal);
|
|
189
191
|
const declaredFunctions = (options.declared ?? [])
|
|
190
192
|
.filter((o) => o.kind === 'function' && !o.signatureDeps?.length);
|
|
@@ -192,7 +194,7 @@ export async function buildIntoDatabase(
|
|
|
192
194
|
// The lifecycle catalog check, with the full models, before any lifecycle-owned function
|
|
193
195
|
// lands: this pass declares functions only, so it carries no transition trigger for the
|
|
194
196
|
// reconcile to read the lifecycle tables from. executeSync runs the same check again below.
|
|
195
|
-
const lifecycleRefusal = await lifecycleApplyRefusal(runner, models);
|
|
197
|
+
const lifecycleRefusal = await lifecycleApplyRefusal(runner, models, options.lifecycleRoles);
|
|
196
198
|
if (lifecycleRefusal) throw new Error(lifecycleRefusal);
|
|
197
199
|
await runner('SET check_function_bodies = off');
|
|
198
200
|
try {
|
|
@@ -216,12 +218,13 @@ export async function buildIntoDatabase(
|
|
|
216
218
|
const run = await executeSync(runner, session, models, {
|
|
217
219
|
declared: options.declared,
|
|
218
220
|
sequences: options.sequences,
|
|
221
|
+
lifecycleRoles: options.lifecycleRoles,
|
|
219
222
|
actor: options.actor ?? 'db-build',
|
|
220
223
|
gitRef: options.gitRef ?? null,
|
|
221
224
|
});
|
|
222
225
|
// Verification: the live catalog check again, over what the derived layer just made live. A
|
|
223
226
|
// throw, so no caller (db:check's compose ring, the dev template, a test database) can miss it.
|
|
224
|
-
const post = await lifecyclePostApplyRefusal(session, models);
|
|
227
|
+
const post = await lifecyclePostApplyRefusal(session, models, options.lifecycleRoles);
|
|
225
228
|
if (post) throw new LifecycleFindingsError(post);
|
|
226
229
|
return {
|
|
227
230
|
converged: run.converged,
|
|
@@ -12,7 +12,7 @@ import path from 'node:path';
|
|
|
12
12
|
import fs from 'node:fs/promises';
|
|
13
13
|
import { pathToFileURL } from 'node:url';
|
|
14
14
|
import type { Module, ModelDescriptor, SequenceDescriptor, DerivedDescriptor } from '@everystack/model';
|
|
15
|
-
import { linkModels, ModelLinkError, moduleGovernedRoles } from '@everystack/model';
|
|
15
|
+
import { linkModels, ModelLinkError, moduleGovernedRoles, moduleRequestRoles } from '@everystack/model';
|
|
16
16
|
import { compileDerived } from './derived-compile';
|
|
17
17
|
import { lifecycleDerived } from './lifecycle-derived';
|
|
18
18
|
import { compileTableRenames, compileTableMoves } from './schema-compile';
|
|
@@ -35,6 +35,13 @@ export interface DeclaredDerived {
|
|
|
35
35
|
/** Roles the modules declare as governed beyond the ones the models name. A live grantee
|
|
36
36
|
* outside the governed set is exempted from reconciliation and ENUMERATED instead. */
|
|
37
37
|
governedRoles: string[];
|
|
38
|
+
/**
|
|
39
|
+
* The roles a request can become (`roles: { request }`, the union across modules). Undefined
|
|
40
|
+
* when no module declares them: the lifecycle catalog check then runs exactly as before.
|
|
41
|
+
*/
|
|
42
|
+
requestRoles?: string[];
|
|
43
|
+
/** Every `owner` a declared derived object names, across modules, sorted. */
|
|
44
|
+
declaredOwners: string[];
|
|
38
45
|
/**
|
|
39
46
|
* Postgres extensions the modules declare — the bootstrap the state layer needs before
|
|
40
47
|
* anything that uses their types. Only the from-scratch compiler consumed these; the DIFF
|
|
@@ -194,6 +201,9 @@ export function composeDeclaredDerived(modules: Module[], modelsPath: string): D
|
|
|
194
201
|
renamedTables: { ...compileTableRenames(models, {}), ...compileTableMoves(models, {}) },
|
|
195
202
|
models,
|
|
196
203
|
governedRoles: moduleGovernedRoles(modules),
|
|
204
|
+
requestRoles: moduleRequestRoles(modules),
|
|
205
|
+
declaredOwners: [...new Set(modules.flatMap((m) => m.derived)
|
|
206
|
+
.flatMap((d) => ('owner' in d && typeof d.owner === 'string' ? [d.owner] : [])))].sort(),
|
|
197
207
|
extensions: [...new Set(modules.flatMap((m) => m.extensions ?? []))].sort(),
|
|
198
208
|
};
|
|
199
209
|
} catch (err) {
|
|
@@ -35,7 +35,7 @@ import { MODEL_WIRE_CONTRACT, type ReconcileWirePayload } from '../wire';
|
|
|
35
35
|
import { resolveConfig, opsFunction } from './config';
|
|
36
36
|
import { invokeAction, lambdaQueryRunner, lambdaSessionRunner } from './aws';
|
|
37
37
|
import { currentGitRef } from './state-apply';
|
|
38
|
-
import { catalogInputsFromModels, lifecycleDeclaredOwnerRefusal, LifecycleFindingsError, lifecyclePostApplyRefusal } from './lifecycle-preflight';
|
|
38
|
+
import { catalogInputsFromModels, lifecycleDeclaredOwnerRefusal, LifecycleFindingsError, lifecyclePostApplyRefusal, lifecycleRolesOf } from './lifecycle-preflight';
|
|
39
39
|
|
|
40
40
|
/** Where the catch-up runs. The two lanes db:apply itself already has. */
|
|
41
41
|
export type CatchUpVenue =
|
|
@@ -83,6 +83,8 @@ export interface CatchUpOptions {
|
|
|
83
83
|
* Required for the same reason as `onRefusal`: only the caller knows what it wrote.
|
|
84
84
|
*/
|
|
85
85
|
stateApplied: boolean;
|
|
86
|
+
/** Wall time, in ms, of each phase that ran: the owner gate and reconcile, then the post-check. */
|
|
87
|
+
onPhase?: (phase: 'derived' | 'post-checks', ms: number) => void;
|
|
86
88
|
}
|
|
87
89
|
|
|
88
90
|
export interface CatchUpOutcome {
|
|
@@ -118,10 +120,13 @@ export async function catchUpDerived(
|
|
|
118
120
|
// The lifecycle catalog check's roles; the reconcile, here or in the ops Lambda, refuses
|
|
119
121
|
// lifecycle-owned objects without them.
|
|
120
122
|
applicationRoles: declared?.models ? catalogInputsFromModels(declared.models).applicationRoles : undefined,
|
|
123
|
+
// What the modules declare about the roles; optional on the wire (absent = the check as before).
|
|
124
|
+
...lifecycleRolesOf(declared),
|
|
121
125
|
actor: process.env.USER ?? null,
|
|
122
126
|
gitRef: currentGitRef(),
|
|
123
127
|
};
|
|
124
128
|
|
|
129
|
+
const derivedStarted = Date.now();
|
|
125
130
|
// Before the first derived write, judge the owners the reconcile will give the declared trigger
|
|
126
131
|
// functions on lifecycle tables.
|
|
127
132
|
const config = venue.kind === 'stage' ? await resolveConfig(venue.stage) : undefined;
|
|
@@ -130,7 +135,7 @@ export async function catchUpDerived(
|
|
|
130
135
|
// operator connection, while db:query may run on a reader.
|
|
131
136
|
const operator: SessionRunner = (statements, opts) =>
|
|
132
137
|
(venue.kind === 'direct' ? venue.session : lambdaSessionRunner(config!.region, opsFunction(config!)))(statements, opts);
|
|
133
|
-
const gate = await lifecycleDeclaredOwnerRefusal(reader, operator, objects, declared?.models ?? [], { stateApplied: opts.stateApplied });
|
|
138
|
+
const gate = await lifecycleDeclaredOwnerRefusal(reader, operator, objects, declared?.models ?? [], { stateApplied: opts.stateApplied, roles: lifecycleRolesOf(declared) });
|
|
134
139
|
if (gate) throw new LifecycleFindingsError(gate);
|
|
135
140
|
|
|
136
141
|
let run: ReconcileRun;
|
|
@@ -169,11 +174,14 @@ export async function catchUpDerived(
|
|
|
169
174
|
// A refusal is not a soft outcome here. db:reconcile prints it and exits 1; the catch-up did
|
|
170
175
|
// not land, so db:apply must say so and exit the same way.
|
|
171
176
|
if (run.refusal) throw new CatchUpError(run.refusal);
|
|
177
|
+
opts.onPhase?.('derived', Date.now() - derivedStarted);
|
|
172
178
|
|
|
173
179
|
// Verification: the live catalog check again, over what the reconcile just made live. On the
|
|
174
180
|
// operator session, where the commit is: db:query may read a replica that has not caught up.
|
|
175
|
-
const
|
|
181
|
+
const postStarted = Date.now();
|
|
182
|
+
const post = await lifecyclePostApplyRefusal(operator, declared?.models ?? [], lifecycleRolesOf(declared));
|
|
176
183
|
if (post) throw new LifecycleFindingsError(post);
|
|
184
|
+
opts.onPhase?.('post-checks', Date.now() - postStarted);
|
|
177
185
|
|
|
178
186
|
return { declared: objects.length, run, statements: run.applied ? (run.statements ?? []) : [] };
|
|
179
187
|
}
|
package/src/cli/edge-plan.ts
CHANGED
|
@@ -53,8 +53,10 @@ import { compileTableRenames, compileTableMoves, compileRenames } from './schema
|
|
|
53
53
|
* A v3 reader does not know how to enact or record those prerequisites, so the file formats are
|
|
54
54
|
* not compatible. The existing db:apply version refusal makes the mismatch a re-mint before any
|
|
55
55
|
* write, rather than letting a peer silently omit part of the reviewed artifact.
|
|
56
|
+
* v7 adds `stateDependents`: declared views and matviews a column type change binds, dropped and
|
|
57
|
+
* re-created inside the state transaction. A v6 reader would skip them and the ALTER would fail.
|
|
56
58
|
*/
|
|
57
|
-
export const PLAN_VERSION =
|
|
59
|
+
export const PLAN_VERSION = 7;
|
|
58
60
|
|
|
59
61
|
/**
|
|
60
62
|
* The db:apply WIRE CONTRACT between the CLI and the ops Lambda — distinct from PLAN_VERSION,
|
|
@@ -119,8 +121,12 @@ export const PLAN_VERSION = 6;
|
|
|
119
121
|
*
|
|
120
122
|
* v10 applies the reviewed prerequisite search path and restores the transaction's original
|
|
121
123
|
* path before state DDL and grant verification. A v9 stage could bind defaults to other schemas.
|
|
124
|
+
*
|
|
125
|
+
* v11 (milestone upgrade-no-hand-sql): the plan gained `stateDependents`, which the server drops
|
|
126
|
+
* before the state edge and re-creates after it, in the same transaction, with provenance. A v10
|
|
127
|
+
* stage would skip them, and its ALTER COLUMN TYPE would fail on the view that binds the column.
|
|
122
128
|
*/
|
|
123
|
-
export const APPLY_CONTRACT_VERSION =
|
|
129
|
+
export const APPLY_CONTRACT_VERSION = 11;
|
|
124
130
|
|
|
125
131
|
/** Classification counts (brick 9, decision 11): destructive = drops + narrowings + strips. */
|
|
126
132
|
export interface PlanClassification {
|
|
@@ -167,6 +173,30 @@ export type StatePrerequisite = {
|
|
|
167
173
|
};
|
|
168
174
|
};
|
|
169
175
|
|
|
176
|
+
/**
|
|
177
|
+
* A declared view or matview that binds a column this plan changes the type of. PostgreSQL refuses
|
|
178
|
+
* `ALTER COLUMN ... TYPE` while it exists, so the state transaction drops it (dependents first),
|
|
179
|
+
* runs the edge, and re-creates it from the reviewed checkout SQL before COMMIT: it is never absent.
|
|
180
|
+
* Listed in creation (dependency) order; the drops run in reverse.
|
|
181
|
+
*/
|
|
182
|
+
export type StateDependent = {
|
|
183
|
+
/** `schema.name`. */
|
|
184
|
+
identity: string;
|
|
185
|
+
kind: 'view' | 'materialized view';
|
|
186
|
+
/** The live definition hash read at mint. db:apply refuses, before any write, unless it still holds. */
|
|
187
|
+
expectedDefHash: string;
|
|
188
|
+
/** The exact DROP, without CASCADE: anything the mint did not see that depends on it fails the transaction. */
|
|
189
|
+
dropSql: string;
|
|
190
|
+
/** The exact schema preparation, CREATE, ownership, grant, comment and index SQL db:apply runs. */
|
|
191
|
+
sql: string;
|
|
192
|
+
/** As StatePrerequisite.searchPath. */
|
|
193
|
+
searchPath: string | null;
|
|
194
|
+
grantContract: StatePrerequisite['grantContract'];
|
|
195
|
+
provenance: { srcHash: string; bodyHash?: string; kind: 'view' | 'materialized view'; dropSql?: string };
|
|
196
|
+
/** Matview only: pg_total_relation_size at mint. The re-create repopulates it, inside the transaction. */
|
|
197
|
+
bytes?: number;
|
|
198
|
+
};
|
|
199
|
+
|
|
170
200
|
export interface EdgePlan {
|
|
171
201
|
v: number;
|
|
172
202
|
/** The target's exact live fingerprint at mint time — the lock. */
|
|
@@ -234,6 +264,8 @@ export interface EdgePlan {
|
|
|
234
264
|
* before this field existed have none; an absent field means no prerequisite was recorded.
|
|
235
265
|
*/
|
|
236
266
|
statePrerequisites?: readonly StatePrerequisite[];
|
|
267
|
+
/** Declared views and matviews the state transaction drops and re-creates around a type change. */
|
|
268
|
+
stateDependents?: readonly StateDependent[];
|
|
237
269
|
executable: number;
|
|
238
270
|
/** Executable statements that LOSE DATA (drops + narrowing type changes) — red, gated at apply. */
|
|
239
271
|
destructive: number;
|