@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
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,40 @@ All notable changes to `@everystack/cli` are documented here.
|
|
|
4
4
|
|
|
5
5
|
Format based on [Keep a Changelog](https://keepachangelog.com/).
|
|
6
6
|
|
|
7
|
+
## [0.4.107] - 2026-10-31
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- db:plan names db:provision first when a lifecycle plan needs it
|
|
12
|
+
- db:apply prints a wall time per phase
|
|
13
|
+
- a type change under a declared view or matview mints and applies
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- the lifecycle preflight accepts the grants the reviewed plan revokes
|
|
18
|
+
- a re-created view's apply errors name it as a view
|
|
19
|
+
- a grant the plan's REVOKE cannot remove stays a lifecycle finding
|
|
20
|
+
- a destroy_log grant from another role names db:provision's REVOKE
|
|
21
|
+
|
|
22
|
+
## [0.4.106] - 2026-10-31
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- declare request roles with defineModule({ roles: { request } })
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- a granted definer role is not a request role in the lifecycle check
|
|
31
|
+
- lifecycle rule 2 judges schema owners and the deploy history tables
|
|
32
|
+
- lifecycle reach counts mixed-option membership chains
|
|
33
|
+
- move the database when an app role owns public through pg_database_owner
|
|
34
|
+
- refuse an empty request-role list on the reconcile wire
|
|
35
|
+
|
|
36
|
+
### Documentation
|
|
37
|
+
|
|
38
|
+
- say why a granted owner counts, and document roles.request
|
|
39
|
+
- state what an older server skips under the role declaration
|
|
40
|
+
|
|
7
41
|
## [0.4.105] - 2026-10-31
|
|
8
42
|
|
|
9
43
|
### Fixed
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.107",
|
|
4
4
|
"description": "CLI and OTA updates for Expo apps on everystack",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"author": "Scalable Technology, Inc. <licensing@scalable.technology>",
|
|
@@ -145,7 +145,7 @@
|
|
|
145
145
|
"structured-headers": "1.0.1",
|
|
146
146
|
"tsx": "4.21.0",
|
|
147
147
|
"typescript": "5.9.3",
|
|
148
|
-
"@everystack/model": "0.4.
|
|
148
|
+
"@everystack/model": "0.4.26"
|
|
149
149
|
},
|
|
150
150
|
"peerDependencies": {
|
|
151
151
|
"@everystack/server": ">=0.4.0",
|
|
@@ -192,7 +192,7 @@
|
|
|
192
192
|
"react": "19.2.0",
|
|
193
193
|
"ts-jest": "29.4.9",
|
|
194
194
|
"zod": "3.25.67",
|
|
195
|
-
"@everystack/server": "0.4.
|
|
195
|
+
"@everystack/server": "0.4.42"
|
|
196
196
|
},
|
|
197
197
|
"scripts": {
|
|
198
198
|
"test": "jest",
|
|
@@ -13,6 +13,16 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import type { QueryRunner } from './authz-contract';
|
|
16
|
+
import type { SessionRunner } from './session';
|
|
17
|
+
import type { SourceObject } from './derived-source';
|
|
18
|
+
import type { StateDependent } from './edge-plan';
|
|
19
|
+
import { introspectDerived } from './derived-introspect';
|
|
20
|
+
import { planReconcile } from './derived-plan';
|
|
21
|
+
import {
|
|
22
|
+
builderOwnerMode, derivedSearchPath, ownerPreflightRefusal, ownerPreflightSql, ownerRequirements,
|
|
23
|
+
renderEnsureObjectSchemas, renderReconcileSql, type OwnerApplyMode,
|
|
24
|
+
} from './derived-apply';
|
|
25
|
+
import { quoteIdent, quoteQualified } from './pg-ident';
|
|
16
26
|
|
|
17
27
|
export interface AlterTypeTarget {
|
|
18
28
|
table: string;
|
|
@@ -94,3 +104,192 @@ export function renderAlterTypeRefusal(blocked: readonly BlockedAlterType[]): st
|
|
|
94
104
|
`Route the type change through db:swap (rebuild + atomic rename), or drop and re-create the dependent derived objects around it (db:reconcile) — deliberately, not as an apply-time surprise.`,
|
|
95
105
|
].join('\n');
|
|
96
106
|
}
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// Declared dependents: dropped and re-created inside the state transaction.
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Every view, matview or rule the targets' type changes force out: the ones that bind a target
|
|
114
|
+
* column, and, transitively, every view or matview whose rule reads one of those (a view on a
|
|
115
|
+
* view). PostgreSQL cannot drop the first without the second.
|
|
116
|
+
*/
|
|
117
|
+
export function dependentClosureSql(targets: readonly AlterTypeTarget[]): string {
|
|
118
|
+
const direct = targets.map((t) => {
|
|
119
|
+
const rel = quoteLiteral(t.table);
|
|
120
|
+
return `(d.refobjid = ${rel}::regclass AND d.refobjsubid = (SELECT attnum FROM pg_catalog.pg_attribute
|
|
121
|
+
WHERE attrelid = ${rel}::regclass AND attname = ${quoteLiteral(t.column)}))`;
|
|
122
|
+
}).join(' OR ');
|
|
123
|
+
return `WITH RECURSIVE closure(oid) AS (
|
|
124
|
+
SELECT r.ev_class FROM pg_catalog.pg_depend d JOIN pg_catalog.pg_rewrite r ON r.oid = d.objid
|
|
125
|
+
WHERE d.classid = 'pg_catalog.pg_rewrite'::regclass AND d.refclassid = 'pg_catalog.pg_class'::regclass
|
|
126
|
+
AND r.ev_class <> d.refobjid AND (${direct})
|
|
127
|
+
UNION
|
|
128
|
+
SELECT r.ev_class FROM closure c JOIN pg_catalog.pg_depend d ON d.refobjid = c.oid
|
|
129
|
+
AND d.refclassid = 'pg_catalog.pg_class'::regclass AND d.classid = 'pg_catalog.pg_rewrite'::regclass
|
|
130
|
+
JOIN pg_catalog.pg_rewrite r ON r.oid = d.objid
|
|
131
|
+
WHERE r.ev_class <> c.oid
|
|
132
|
+
)
|
|
133
|
+
SELECT DISTINCT cl.oid::bigint AS oid,
|
|
134
|
+
CASE cl.relkind WHEN 'v' THEN 'view' WHEN 'm' THEN 'materialized view' ELSE 'rule' END AS kind,
|
|
135
|
+
n.nspname::text || '.' || cl.relname::text AS identity,
|
|
136
|
+
pg_catalog.pg_total_relation_size(cl.oid)::bigint AS bytes
|
|
137
|
+
FROM closure JOIN pg_catalog.pg_class cl ON cl.oid = closure.oid JOIN pg_catalog.pg_namespace n ON n.oid = cl.relnamespace
|
|
138
|
+
ORDER BY 3`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* What else depends on the closure, which a plain DROP would refuse over (a function returning
|
|
143
|
+
* the view's row type) or remove silently (a trigger on a view). The closure's own rules and
|
|
144
|
+
* members are not counted: they are dropped and re-created with it.
|
|
145
|
+
*/
|
|
146
|
+
export function closureBlockersSql(oids: readonly number[]): string {
|
|
147
|
+
const list = `ARRAY[${oids.map((oid) => String(Math.trunc(oid))).join(', ')}]::oid[]`;
|
|
148
|
+
return `SELECT DISTINCT pg_catalog.pg_describe_object(d.classid, d.objid, d.objsubid) AS what,
|
|
149
|
+
pg_catalog.pg_describe_object(d.refclassid, d.refobjid, 0) AS on_what
|
|
150
|
+
FROM pg_catalog.pg_depend d
|
|
151
|
+
WHERE ((d.refclassid = 'pg_catalog.pg_class'::regclass AND d.refobjid = ANY(${list}))
|
|
152
|
+
OR (d.refclassid = 'pg_catalog.pg_type'::regclass AND d.refobjid IN (SELECT reltype FROM pg_catalog.pg_class WHERE oid = ANY(${list}))))
|
|
153
|
+
AND ((d.deptype = 'n'
|
|
154
|
+
AND NOT (d.classid = 'pg_catalog.pg_rewrite'::regclass AND (SELECT ev_class FROM pg_catalog.pg_rewrite WHERE oid = d.objid) = ANY(${list}))
|
|
155
|
+
AND NOT (d.classid = 'pg_catalog.pg_class'::regclass AND d.objid = ANY(${list})))
|
|
156
|
+
OR d.classid = 'pg_catalog.pg_trigger'::regclass)
|
|
157
|
+
ORDER BY 1`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** `1.2 MB`, for the plan's matview line. */
|
|
161
|
+
export function formatBytes(bytes: number): string {
|
|
162
|
+
const units = ['B', 'kB', 'MB', 'GB', 'TB'];
|
|
163
|
+
let value = bytes;
|
|
164
|
+
let unit = 0;
|
|
165
|
+
while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit += 1; }
|
|
166
|
+
return `${unit === 0 ? value : value.toFixed(1)} ${units[unit]}`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The plan's line for one dependent. */
|
|
170
|
+
export function renderStateDependentLine(d: Pick<StateDependent, 'identity' | 'kind' | 'bytes'>): string {
|
|
171
|
+
const cost = d.kind === 'materialized view' ? ` (repopulates ${formatBytes(d.bytes ?? 0)})` : '';
|
|
172
|
+
return `~ ${d.kind} ${d.identity}: dropped before the type change and re-created from the checkout in the same transaction${cost}`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export type StateDependentsMint = { dependents: StateDependent[] } | { refusal: string };
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The declared views and matviews the targets' type changes force out, rendered for the state
|
|
179
|
+
* transaction, or a refusal naming what the platform cannot re-create:
|
|
180
|
+
*
|
|
181
|
+
* - a dependent the models do not declare (nothing can re-create it from the checkout):
|
|
182
|
+
* declare it in the models, which db:pull renders;
|
|
183
|
+
* - a declared one whose live definition the reconciler cannot vouch for (drift, or never
|
|
184
|
+
* reconciled): db:reconcile has the verb for each;
|
|
185
|
+
* - something else depending on the closure (a function over a view's row type, a trigger on a
|
|
186
|
+
* view), which a plain DROP would refuse over or silently remove.
|
|
187
|
+
*
|
|
188
|
+
* Read-only. Each create is the SQL a derived apply renders for that object.
|
|
189
|
+
*/
|
|
190
|
+
export async function mintStateDependents(
|
|
191
|
+
runner: QueryRunner,
|
|
192
|
+
session: SessionRunner,
|
|
193
|
+
targets: readonly AlterTypeTarget[],
|
|
194
|
+
declared: readonly SourceObject[],
|
|
195
|
+
): Promise<StateDependentsMint> {
|
|
196
|
+
if (targets.length === 0) return { dependents: [] };
|
|
197
|
+
const rows = await runner(dependentClosureSql(targets));
|
|
198
|
+
if (rows.length === 0) return { dependents: [] };
|
|
199
|
+
const byIdentity = new Map(declared.map((o) => [o.identity, o]));
|
|
200
|
+
const where = targets.map((t) => `${t.table}."${t.column}"`).join(', ');
|
|
201
|
+
const undeclared = rows.filter((r: any) => r.kind === 'rule' || byIdentity.get(String(r.identity))?.kind !== r.kind);
|
|
202
|
+
if (undeclared.length > 0) {
|
|
203
|
+
return {
|
|
204
|
+
refusal: `This plan changes the type of ${where}, and PostgreSQL refuses that while a view or rule binds the column. `
|
|
205
|
+
+ 'db:apply drops and re-creates the declared ones in the same transaction, but these are not declared, so nothing can re-create them:\n'
|
|
206
|
+
+ undeclared.map((r: any) => ` ${r.kind} ${r.identity} is not declared`).join('\n')
|
|
207
|
+
+ '\nFix: declare it in the models (db:pull renders it), then re-run db:plan.',
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
const blockers = await runner(closureBlockersSql(rows.map((r: any) => Number(r.oid))));
|
|
211
|
+
if (blockers.length > 0) {
|
|
212
|
+
return {
|
|
213
|
+
refusal: `This plan changes the type of ${where}. db:apply would drop and re-create ${rows.map((r: any) => `${r.kind} ${r.identity}`).join(', ')} around it, `
|
|
214
|
+
+ 'but other objects depend on them, and a plain DROP would refuse over them or remove them:\n'
|
|
215
|
+
+ blockers.map((b: any) => ` ${b.what} depends on ${b.on_what}`).join('\n')
|
|
216
|
+
+ '\nRoute this type change through db:swap, or change the models so nothing else depends on these views first.',
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Creation order is the declared layer's dependency order; the drops run in reverse.
|
|
221
|
+
const order = new Map(declared.map((o, i) => [o.identity, i]));
|
|
222
|
+
const closure = rows
|
|
223
|
+
.map((r: any) => ({ identity: String(r.identity), kind: r.kind as StateDependent['kind'], bytes: Number(r.bytes) }))
|
|
224
|
+
.sort((a, b) => order.get(a.identity)! - order.get(b.identity)!);
|
|
225
|
+
const ids = new Set(closure.map((c) => c.identity));
|
|
226
|
+
|
|
227
|
+
const live = await introspectDerived(session);
|
|
228
|
+
const parsed = { objects: [...declared], warnings: [] as string[] };
|
|
229
|
+
// The reconciler's own verdict first: a hand-edited or never-reconciled view is not ours to
|
|
230
|
+
// re-create silently, and db:reconcile already has the verb for each.
|
|
231
|
+
const verdict = planReconcile(parsed, live, { only: [...ids] });
|
|
232
|
+
const drifted = verdict.drift.filter((d) => ids.has(d.identity)).map((d) => d.identity);
|
|
233
|
+
const unbaselined = verdict.needsBaseline.filter((id) => ids.has(id));
|
|
234
|
+
if (drifted.length + unbaselined.length > 0) {
|
|
235
|
+
return {
|
|
236
|
+
refusal: `This plan changes the type of ${where}, which db:apply handles by re-creating the declared views and matviews that bind it, `
|
|
237
|
+
+ 'but the reconciler cannot vouch for some of them:\n'
|
|
238
|
+
+ [...drifted.map((id) => ` ${id} differs from its record (hand-edited). Fix: db:reconcile --apply --overwrite-drift, or declare the live definition.`),
|
|
239
|
+
...unbaselined.map((id) => ` ${id} was never reconciled. Fix: db:reconcile --baseline once you have verified it matches the models.`)].join('\n')
|
|
240
|
+
+ '\nThen re-run db:plan.',
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const liveById = new Map(live.objects.map((o) => [o.identity, o]));
|
|
245
|
+
// Rendered as the creates a derived apply would run against the catalog as it is after the
|
|
246
|
+
// drops: the closure absent.
|
|
247
|
+
const absent = {
|
|
248
|
+
...live,
|
|
249
|
+
objects: live.objects.filter((o) => !ids.has(o.identity)),
|
|
250
|
+
edges: live.edges.filter((e) => !ids.has(e.dependent) && !ids.has(e.referenced)),
|
|
251
|
+
};
|
|
252
|
+
const combined = planReconcile(parsed, absent, { only: [...ids] });
|
|
253
|
+
const requirements = ownerRequirements(combined, parsed.objects);
|
|
254
|
+
let ownerMode: OwnerApplyMode = 'set-role';
|
|
255
|
+
if (requirements.length > 0) {
|
|
256
|
+
const preflight = await runner(ownerPreflightSql(requirements));
|
|
257
|
+
const refusal = ownerPreflightRefusal(preflight as any[]);
|
|
258
|
+
if (refusal) return { refusal: `could not plan the re-create of the views a type change binds: ${refusal}` };
|
|
259
|
+
ownerMode = builderOwnerMode(preflight as any[]);
|
|
260
|
+
}
|
|
261
|
+
const schemas = derivedSearchPath([...declared]);
|
|
262
|
+
const searchPath = schemas.length === 1 && schemas[0] === 'public' ? null : schemas.map(quoteIdent).join(', ');
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
dependents: closure.map((c) => {
|
|
266
|
+
const source = byIdentity.get(c.identity)!;
|
|
267
|
+
const rendered = renderReconcileSql(planReconcile(parsed, absent, { only: [c.identity] }), parsed.objects, ownerMode);
|
|
268
|
+
if (rendered.record.length !== 1 || rendered.record[0] !== c.identity) {
|
|
269
|
+
throw new Error(`could not render the re-create of ${c.identity}: the target changed while minting; re-run db:plan.`);
|
|
270
|
+
}
|
|
271
|
+
const drop = `DROP ${c.kind === 'view' ? 'VIEW' : 'MATERIALIZED VIEW'} ${quoteQualified(c.identity)}`;
|
|
272
|
+
const owner = ownerMode === 'set-role' ? source.owner : undefined;
|
|
273
|
+
return {
|
|
274
|
+
identity: c.identity,
|
|
275
|
+
kind: c.kind,
|
|
276
|
+
expectedDefHash: liveById.get(c.identity)!.defHash,
|
|
277
|
+
dropSql: owner ? [`SET ROLE ${owner}`, drop, 'RESET ROLE'].join('\n;\n') : drop,
|
|
278
|
+
sql: [...renderEnsureObjectSchemas([source]), ...rendered.statements].join('\n;\n'),
|
|
279
|
+
searchPath,
|
|
280
|
+
grantContract: {
|
|
281
|
+
attachments: source.attachments.filter((a) => a.kind === 'grant'),
|
|
282
|
+
...(source.owner ? { owner: source.owner } : {}),
|
|
283
|
+
ownerMode,
|
|
284
|
+
},
|
|
285
|
+
provenance: {
|
|
286
|
+
srcHash: source.hash,
|
|
287
|
+
...(source.bodyHash ? { bodyHash: source.bodyHash } : {}),
|
|
288
|
+
kind: c.kind,
|
|
289
|
+
...(source.drop ? { dropSql: source.drop } : {}),
|
|
290
|
+
},
|
|
291
|
+
...(c.kind === 'materialized view' ? { bytes: c.bytes } : {}),
|
|
292
|
+
};
|
|
293
|
+
}),
|
|
294
|
+
};
|
|
295
|
+
}
|
package/src/cli/apply-execute.ts
CHANGED
|
@@ -189,6 +189,12 @@ export interface ApplyPlanResult {
|
|
|
189
189
|
liveFingerprint: string;
|
|
190
190
|
reason?: string;
|
|
191
191
|
logId?: number;
|
|
192
|
+
/**
|
|
193
|
+
* Wall time, in ms, of the two phases this function runs, on the statuses that reached the
|
|
194
|
+
* state transaction: everything before it (the lock, the gates, descent; `verifyMs`), and the
|
|
195
|
+
* transaction plus the landed-fingerprint read (`stateMs`). Optional on the wire.
|
|
196
|
+
*/
|
|
197
|
+
phases?: { verifyMs: number; stateMs: number };
|
|
192
198
|
/**
|
|
193
199
|
* The keys the row check found orphans on, if any — data, for the caller's advisory (see
|
|
194
200
|
* fk-orphan-advisory). Present on the paths that got as far as running the check, empty
|
|
@@ -465,6 +471,7 @@ export async function executeApplyPlan(
|
|
|
465
471
|
plan: EdgePlan,
|
|
466
472
|
options: ApplyPlanOptions = {},
|
|
467
473
|
): Promise<ApplyPlanResult> {
|
|
474
|
+
const started = Date.now();
|
|
468
475
|
const { hash: live, snapshot: before, contract: beforeAuthz, ledger, ledgerRead } = await liveBaseAndLedger(session);
|
|
469
476
|
|
|
470
477
|
// The endpoint is FULL (it carries the ungoverned live authz through), so it is compared against
|
|
@@ -596,16 +603,23 @@ export async function executeApplyPlan(
|
|
|
596
603
|
// rewritten — the plan is an artifact under a content hash, and `planHash(plan)` below has to
|
|
597
604
|
// keep naming the file the operator reviewed.
|
|
598
605
|
const { statements: toRun, withheld } = withheldValidations(plan.statements, orphans);
|
|
606
|
+
if (options.stateOnly && plan.stateDependents?.length) {
|
|
607
|
+
const reason = `this plan re-creates ${plan.stateDependents.map((d) => `${d.kind} ${d.identity}`).join(', ')} around a column type change, but --state-only skips derived writes. Apply the reviewed plan without --state-only.`;
|
|
608
|
+
await recordRefusal(runner, plan, live, 'state dependents', reason, options);
|
|
609
|
+
return { status: 'refused', liveFingerprint: live, reason };
|
|
610
|
+
}
|
|
599
611
|
if (options.stateOnly && plan.statePrerequisites?.length) {
|
|
600
612
|
const reason = 'this plan needs state prerequisites, but --state-only skips function writes. Apply the reviewed plan without --state-only, or satisfy the prerequisites and re-mint.';
|
|
601
613
|
await recordRefusal(runner, plan, live, 'state prerequisites', reason, options);
|
|
602
614
|
return { status: 'refused', liveFingerprint: live, reason };
|
|
603
615
|
}
|
|
604
616
|
|
|
617
|
+
const stateStarted = Date.now();
|
|
605
618
|
let result: StateApplyResult;
|
|
606
619
|
try {
|
|
607
620
|
result = await applyGeneratedStatements(runner, toRun, {
|
|
608
621
|
statePrerequisites: plan.statePrerequisites,
|
|
622
|
+
stateDependents: plan.stateDependents,
|
|
609
623
|
transaction: options.transaction,
|
|
610
624
|
...(withheld.length > 0 ? { skippedValidations: withheld } : {}),
|
|
611
625
|
actor: options.actor,
|
|
@@ -630,6 +644,7 @@ export async function executeApplyPlan(
|
|
|
630
644
|
}
|
|
631
645
|
|
|
632
646
|
const { hash: landed } = await liveBaseFingerprint(session);
|
|
647
|
+
const phases = { verifyMs: stateStarted - started, stateMs: Date.now() - stateStarted };
|
|
633
648
|
if (result.logId !== undefined) {
|
|
634
649
|
// The log stamps the RAW landed hash — the same flavor as `from` AND as the full `to`, so the
|
|
635
650
|
// schema_log chain reads as one consistent series of live states.
|
|
@@ -645,6 +660,7 @@ export async function executeApplyPlan(
|
|
|
645
660
|
return {
|
|
646
661
|
status: 'validation-pending',
|
|
647
662
|
liveFingerprint: landed,
|
|
663
|
+
phases,
|
|
648
664
|
// The next move follows the CAUSE: bad rows are a data fix, a check that was cut off by a
|
|
649
665
|
// clock is a lane change. See validationPendingReason.
|
|
650
666
|
reason: validationPendingReason(result.pendingValidations),
|
|
@@ -660,6 +676,7 @@ export async function executeApplyPlan(
|
|
|
660
676
|
return {
|
|
661
677
|
status: 'verify-failed',
|
|
662
678
|
liveFingerprint: landed,
|
|
679
|
+
phases,
|
|
663
680
|
reason: `applied, but the target landed on ${landed.slice(0, 12)} — the plan predicted ${plan.toFingerprint.slice(0, 12)}. Investigate before touching this database again (db:fingerprint, db:generate).`,
|
|
664
681
|
...(result.logId !== undefined ? { logId: result.logId } : {}),
|
|
665
682
|
fkOrphans: orphans,
|
|
@@ -668,6 +685,7 @@ export async function executeApplyPlan(
|
|
|
668
685
|
return {
|
|
669
686
|
status: 'applied',
|
|
670
687
|
liveFingerprint: landed,
|
|
688
|
+
phases,
|
|
671
689
|
...(result.logId !== undefined ? { logId: result.logId } : {}),
|
|
672
690
|
fkOrphans: orphans,
|
|
673
691
|
};
|
|
@@ -77,7 +77,7 @@ import { readStageStateTwice } from '../stage-read-consistency';
|
|
|
77
77
|
import { renderApplyReceipt, renderCatchUpFailure } from '../plain-output';
|
|
78
78
|
import { renderFkOrphanAdvisory, ownershipColumns } from '../fk-orphan-advisory';
|
|
79
79
|
import { catchUpDerived, type CatchUpVenue } from '../derived-catchup';
|
|
80
|
-
import { lifecycleApplyRefusal, lifecycleStateOnlyRefusal, LifecycleFindingsError } from '../lifecycle-preflight';
|
|
80
|
+
import { lifecycleApplyRefusal, lifecycleRolesOf, lifecycleStateOnlyRefusal, LifecycleFindingsError, unclassifiedGranteesWarning } from '../lifecycle-preflight';
|
|
81
81
|
import { buildReconcileReport } from './db-reconcile';
|
|
82
82
|
import { step, success, fail, info, warn, line as printLine } from '../output';
|
|
83
83
|
|
|
@@ -238,6 +238,30 @@ export function catchUpRerunCommand(flags: Record<string, string>): string {
|
|
|
238
238
|
return parts.join(' ');
|
|
239
239
|
}
|
|
240
240
|
|
|
241
|
+
/** Wall time, in ms, of each db:apply phase that ran. */
|
|
242
|
+
export interface ApplyPhaseTimings {
|
|
243
|
+
verify?: number;
|
|
244
|
+
state?: number;
|
|
245
|
+
derived?: number;
|
|
246
|
+
postChecks?: number;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* One line: the wall time of each phase that ran, in order, or null when none did. Printed beside
|
|
251
|
+
* the receipt, never inside it, so the receipt's contract is unchanged.
|
|
252
|
+
*/
|
|
253
|
+
export function renderPhaseTimings(phases: ApplyPhaseTimings): string | null {
|
|
254
|
+
const named: Array<[string, number | undefined]> = [
|
|
255
|
+
['descent and fingerprint verify', phases.verify],
|
|
256
|
+
['state transaction', phases.state],
|
|
257
|
+
['derived catch-up', phases.derived],
|
|
258
|
+
['post-checks', phases.postChecks],
|
|
259
|
+
];
|
|
260
|
+
const ran = named.filter((entry): entry is [string, number] => entry[1] !== undefined);
|
|
261
|
+
if (ran.length === 0) return null;
|
|
262
|
+
return `phases: ${ran.map(([name, ms]) => `${name} ${(ms / 1000).toFixed(2)}s`).join(', ')}`;
|
|
263
|
+
}
|
|
264
|
+
|
|
241
265
|
/** What the catch-up did, or what to say about the fact that it did not. */
|
|
242
266
|
type CatchUpReport = { ok: true; statements: string[] } | { ok: false; lines: string[] };
|
|
243
267
|
|
|
@@ -261,10 +285,12 @@ async function runCatchUp(
|
|
|
261
285
|
applied: number,
|
|
262
286
|
onRefusal: 'exit' | 'throw',
|
|
263
287
|
declared?: Awaited<ReturnType<typeof loadDeclaredDerived>>,
|
|
288
|
+
phases?: ApplyPhaseTimings,
|
|
264
289
|
): Promise<CatchUpReport> {
|
|
265
290
|
const verbose = flags.verbose === 'true';
|
|
266
291
|
try {
|
|
267
292
|
const outcome = await catchUpDerived(venue, {
|
|
293
|
+
...(phases ? { onPhase: (phase: 'derived' | 'post-checks', ms: number) => { phases[phase === 'derived' ? 'derived' : 'postChecks'] = ms; } } : {}),
|
|
268
294
|
...(declared !== undefined ? { declared } : {}),
|
|
269
295
|
...(flags.models ? { models: flags.models } : {}),
|
|
270
296
|
onStart: () => step('Catching the functions, views and triggers up to the checkout...'),
|
|
@@ -336,6 +362,7 @@ async function catchUpAfterApply(
|
|
|
336
362
|
venue: CatchUpVenue,
|
|
337
363
|
flags: Record<string, string>,
|
|
338
364
|
declared?: Awaited<ReturnType<typeof loadDeclaredDerived>>,
|
|
365
|
+
phases?: ApplyPhaseTimings,
|
|
339
366
|
): Promise<CatchUpReport> {
|
|
340
367
|
if (!LANDED.has(result.status)) return { ok: true, statements: [] };
|
|
341
368
|
if (flags['state-only'] === 'true') return { ok: true, statements: [] };
|
|
@@ -345,7 +372,7 @@ async function catchUpAfterApply(
|
|
|
345
372
|
const applied = result.status === 'already-applied'
|
|
346
373
|
? 0
|
|
347
374
|
: classifyGeneratedStatements((plan.statements ?? []).filter((s) => !unapplied.has(s))).executable.length;
|
|
348
|
-
return runCatchUp(venue, flags, applied, 'throw', declared);
|
|
375
|
+
return runCatchUp(venue, flags, applied, 'throw', declared, phases);
|
|
349
376
|
}
|
|
350
377
|
|
|
351
378
|
/**
|
|
@@ -519,7 +546,11 @@ async function applyPlanViaStage(
|
|
|
519
546
|
const { region } = config;
|
|
520
547
|
const fn = opsFunction(config);
|
|
521
548
|
const declared = await loadDeclaredDerived(flags.models);
|
|
522
|
-
const
|
|
549
|
+
const unclassified = unclassifiedGranteesWarning(declared?.models ?? [], lifecycleRolesOf(declared));
|
|
550
|
+
if (unclassified) warn(unclassified);
|
|
551
|
+
// The plan's own REVOKEs resolve the grant findings they remove: the state transaction runs them
|
|
552
|
+
// before the catch-up creates any lifecycle function, and the post-check reads the catalog after.
|
|
553
|
+
const lifecycleRefusal = await lifecycleApplyRefusal(lambdaQueryRunner(region, fn), declared?.models ?? [], lifecycleRolesOf(declared), plan.statements)
|
|
523
554
|
?? (flags['state-only'] === 'true' ? await lifecycleStateOnlyRefusal(lambdaQueryRunner(region, fn), declared?.models ?? []) : null);
|
|
524
555
|
if (lifecycleRefusal) {
|
|
525
556
|
fail(lifecycleRefusal);
|
|
@@ -535,6 +566,10 @@ async function applyPlanViaStage(
|
|
|
535
566
|
// answered by two containers at two moments. Read the stage twice and refuse a
|
|
536
567
|
// disagreement — the apply never starts. Deliberately OUTSIDE the try below: this
|
|
537
568
|
// refusal must exit as itself, not as "Apply failed".
|
|
569
|
+
// Wall times as this CLI sees them: the reads, descent and authority before the write; the write
|
|
570
|
+
// invoke (the ops Lambda's own lock, gates and transaction); then the catch-up's two halves.
|
|
571
|
+
const phases: ApplyPhaseTimings = {};
|
|
572
|
+
const verifyStarted = Date.now();
|
|
538
573
|
step('Reading the stage TWICE (state and authz are separate ops-Lambda invokes)...');
|
|
539
574
|
const readSession = lambdaSessionRunner(region, fn);
|
|
540
575
|
const readPair = await readStageStateTwice(readSession).catch((err: any) => {
|
|
@@ -626,6 +661,8 @@ async function applyPlanViaStage(
|
|
|
626
661
|
}
|
|
627
662
|
|
|
628
663
|
step('Applying in the ops Lambda (credential-free — the operator never holds a database URL)...');
|
|
664
|
+
const stateStarted = Date.now();
|
|
665
|
+
phases.verify = stateStarted - verifyStarted;
|
|
629
666
|
const result: any = await invokeAction(region, fn, 'db:apply', {
|
|
630
667
|
plan,
|
|
631
668
|
// q1-contract-version direction A: the server refuses on exact mismatch, so a payload-key or
|
|
@@ -641,6 +678,7 @@ async function applyPlanViaStage(
|
|
|
641
678
|
...(flags['snapshot-ref'] ? { snapshotRef: flags['snapshot-ref'] } : {}),
|
|
642
679
|
stage: flags.stage,
|
|
643
680
|
});
|
|
681
|
+
phases.state = Date.now() - stateStarted;
|
|
644
682
|
if (result?.error) {
|
|
645
683
|
fail(`Apply failed: ${result.error}`);
|
|
646
684
|
process.exit(1);
|
|
@@ -668,8 +706,10 @@ async function applyPlanViaStage(
|
|
|
668
706
|
}
|
|
669
707
|
|
|
670
708
|
// Then the functions, views and triggers — on the same stage, credential-free, always.
|
|
671
|
-
const catchUp = await catchUpAfterApply(landedResult!, plan, { kind: 'stage', stage: flags.stage }, flags, declared);
|
|
709
|
+
const catchUp = await catchUpAfterApply(landedResult!, plan, { kind: 'stage', stage: flags.stage }, flags, declared, phases);
|
|
672
710
|
exitOnCatchUpFailure(catchUp);
|
|
711
|
+
const timings = LANDED.has(landedResult!.status) ? renderPhaseTimings(phases) : null;
|
|
712
|
+
if (timings) info(timings);
|
|
673
713
|
const declaredGrantees = await declaredGranteesForReceipt(plan, flags);
|
|
674
714
|
const advisoryModels = await modelsForAdvisory(landedResult!, flags);
|
|
675
715
|
reportApplyResult(landedResult!, plan, {
|
|
@@ -956,7 +996,10 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
956
996
|
const declaredDb = (): ReturnType<typeof loadDeclaredDerived> =>
|
|
957
997
|
declaredDbPromise ??= loadDeclaredDerived(flags.models);
|
|
958
998
|
// Read-only, before the rollback point: a refused apply must not first take a snapshot, backup or dump.
|
|
959
|
-
const
|
|
999
|
+
const unclassified = unclassifiedGranteesWarning((await declaredDb())?.models ?? [], lifecycleRolesOf(await declaredDb()));
|
|
1000
|
+
if (unclassified) warn(unclassified);
|
|
1001
|
+
// As on the stage lane: a finding this plan's own REVOKE removes is not a refusal.
|
|
1002
|
+
const lifecycleRefusal = await lifecycleApplyRefusal(runner, (await declaredDb())?.models ?? [], lifecycleRolesOf(await declaredDb()), plan.statements)
|
|
960
1003
|
?? (flags['state-only'] === 'true' ? await lifecycleStateOnlyRefusal(runner, (await declaredDb())?.models ?? []) : null);
|
|
961
1004
|
if (lifecycleRefusal) {
|
|
962
1005
|
fail(lifecycleRefusal);
|
|
@@ -1083,6 +1126,7 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
1083
1126
|
|
|
1084
1127
|
let landed: ApplyPlanResult | undefined;
|
|
1085
1128
|
let catchUp: CatchUpReport = { ok: true, statements: [] };
|
|
1129
|
+
const phases: ApplyPhaseTimings = {};
|
|
1086
1130
|
try {
|
|
1087
1131
|
// The edge as hashes — internal bookkeeping, not what a reader came for. Verbose only.
|
|
1088
1132
|
if (flags.verbose === 'true') info(`plan ${planHash(plan).slice(0, 12)}: ${plan.fromFingerprint.slice(0, 12)} → ${plan.toFingerprint.slice(0, 12)} (${plan.executable} statement(s)${plan.destructive ? `, ${plan.destructive} destructive` : ''})`);
|
|
@@ -1113,7 +1157,7 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
1113
1157
|
{ verb: 'db:apply', actor: process.env.USER ?? 'unknown' },
|
|
1114
1158
|
async () => {
|
|
1115
1159
|
// Re-checked under the lease, where the writes happen.
|
|
1116
|
-
const leasedRefusal = await lifecycleApplyRefusal(runner, (await declaredDb())?.models ?? []);
|
|
1160
|
+
const leasedRefusal = await lifecycleApplyRefusal(runner, (await declaredDb())?.models ?? [], lifecycleRolesOf(await declaredDb()), plan.statements);
|
|
1117
1161
|
if (leasedRefusal) {
|
|
1118
1162
|
fail(leasedRefusal);
|
|
1119
1163
|
process.exit(1);
|
|
@@ -1175,7 +1219,8 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
1175
1219
|
// they read, and no second operator can slip in between the two halves. Its failure
|
|
1176
1220
|
// is carried out as a report, never thrown: the plan landed, and "rolled back" would
|
|
1177
1221
|
// be a lie.
|
|
1178
|
-
|
|
1222
|
+
if (result.phases) Object.assign(phases, { verify: result.phases.verifyMs, state: result.phases.stateMs });
|
|
1223
|
+
const report = await catchUpAfterApply(result, plan, { kind: 'direct', runner, session }, flags, undefined, phases);
|
|
1179
1224
|
return { result, catchUp: report };
|
|
1180
1225
|
},
|
|
1181
1226
|
);
|
|
@@ -1195,6 +1240,8 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
1195
1240
|
}
|
|
1196
1241
|
|
|
1197
1242
|
exitOnCatchUpFailure(catchUp);
|
|
1243
|
+
const timings = LANDED.has(landed!.status) ? renderPhaseTimings(phases) : null;
|
|
1244
|
+
if (timings) info(timings);
|
|
1198
1245
|
const declaredGrantees = await declaredGranteesForReceipt(plan, flags);
|
|
1199
1246
|
const advisoryModels = await modelsForAdvisory(landed!, flags);
|
|
1200
1247
|
reportApplyResult(landed!, plan, {
|
|
@@ -41,6 +41,7 @@ import { resolveDbSource, type DbSource } from '../db-source';
|
|
|
41
41
|
import { resolveModelsPath } from '../models-path';
|
|
42
42
|
import { loadDeclaredDerived, retiredSqlDirAnywhere, retiredSqlDirFlagRefusal, type DeclaredDerived } from '../declared-derived';
|
|
43
43
|
import { loadModels } from './db-generate';
|
|
44
|
+
import { lifecycleRolesOf } from '../lifecycle-preflight';
|
|
44
45
|
import { step, success, fail, info, warn } from '../output';
|
|
45
46
|
|
|
46
47
|
function requireUrl(flags: Record<string, string>, verb: string): string {
|
|
@@ -134,6 +135,7 @@ export async function dbTemplateRefreshCommand(flags: Record<string, string>): P
|
|
|
134
135
|
const result = await executeTemplateRefresh(url, models, {
|
|
135
136
|
declared: declaredDb?.objects,
|
|
136
137
|
sequences: declaredDb?.sequences,
|
|
138
|
+
lifecycleRoles: lifecycleRolesOf(declaredDb),
|
|
137
139
|
actor: process.env.USER ?? null,
|
|
138
140
|
gitRef: currentGitRef(),
|
|
139
141
|
seed: seedCommand === null ? null : (templateUrl) => {
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
|
|
27
27
|
import type { ModelDescriptor } from '@everystack/model';
|
|
28
28
|
import { buildIntoDatabase } from '../db-build';
|
|
29
|
-
import { LifecycleFindingsError } from '../lifecycle-preflight';
|
|
29
|
+
import { LifecycleFindingsError, lifecycleRolesOf } from '../lifecycle-preflight';
|
|
30
30
|
import { createUrlRunner } from '../db-source';
|
|
31
31
|
import { resolveModelsPath } from '../models-path';
|
|
32
32
|
import { loadDeclaredDerived, retiredSqlDirAnywhere, retiredSqlDirFlagRefusal, type DeclaredDerived } from '../declared-derived';
|
|
@@ -124,6 +124,7 @@ export async function dbBuildCommand(flags: Record<string, string>): Promise<voi
|
|
|
124
124
|
declared: declaredDb?.objects,
|
|
125
125
|
sequences: declaredDb?.sequences,
|
|
126
126
|
extensions: declaredDb?.extensions,
|
|
127
|
+
lifecycleRoles: lifecycleRolesOf(declaredDb),
|
|
127
128
|
actor: process.env.USER ?? null,
|
|
128
129
|
gitRef: currentGitRef(),
|
|
129
130
|
});
|
|
@@ -44,6 +44,7 @@ import { findUnexportedModels, scanModelDirectory, type ModelFileScan } from '..
|
|
|
44
44
|
import { loadDeclaredDerived, retiredSqlDirAnywhere, retiredSqlDirFlagRefusal, type DeclaredDerived } from '../declared-derived';
|
|
45
45
|
import type { SequenceDescriptor } from '@everystack/model';
|
|
46
46
|
import type { SourceObject } from '../derived-source';
|
|
47
|
+
import { lifecycleRolesOf, unclassifiedGranteesWarning, type LifecycleRoleDeclaration } from '../lifecycle-preflight';
|
|
47
48
|
import { findReadAuthzGaps, findNakedGrants } from '../authz-lint';
|
|
48
49
|
import { findSelectGrantGaps } from '../select-grant-advisory';
|
|
49
50
|
import { parseBaseline, renderBaseline, BASELINE_FILE } from '../authz-baseline';
|
|
@@ -125,6 +126,8 @@ export interface StaticCheckInput {
|
|
|
125
126
|
* Absent (undefined) = the caller did not read it (older callers): no map finding at all.
|
|
126
127
|
*/
|
|
127
128
|
modelMapSource?: string | null;
|
|
129
|
+
/** The modules' role declaration (lifecycleRolesOf). Its unclassified grantees are a warning. */
|
|
130
|
+
lifecycleRoles?: LifecycleRoleDeclaration;
|
|
128
131
|
}
|
|
129
132
|
|
|
130
133
|
export function runStaticChecks(input: StaticCheckInput): CheckFinding[] {
|
|
@@ -285,6 +288,11 @@ export function runStaticChecks(input: StaticCheckInput): CheckFinding[] {
|
|
|
285
288
|
}
|
|
286
289
|
}
|
|
287
290
|
|
|
291
|
+
// A grantee the role declaration leaves unclassified counts as a request role in the lifecycle
|
|
292
|
+
// catalog check. Visible, never fatal: classifying it is the operator's call.
|
|
293
|
+
const unclassified = unclassifiedGranteesWarning(input.models, input.lifecycleRoles ?? {});
|
|
294
|
+
if (unclassified) findings.push({ level: 'warn', area: 'authz', message: unclassified });
|
|
295
|
+
|
|
288
296
|
if (input.artifactSource === null && input.artifactOrigin === 'flag') {
|
|
289
297
|
findings.push({
|
|
290
298
|
level: 'fail',
|
|
@@ -369,7 +377,7 @@ export interface EphemeralComposeResult {
|
|
|
369
377
|
export async function executeEphemeralCompose(
|
|
370
378
|
adminUrl: string,
|
|
371
379
|
models: ModelDescriptor[],
|
|
372
|
-
opts: { actor?: string | null; gitRef?: string | null; declared?: SourceObject[]; sequences?: SequenceDescriptor[]; extensions?: string[] } = {},
|
|
380
|
+
opts: { actor?: string | null; gitRef?: string | null; declared?: SourceObject[]; sequences?: SequenceDescriptor[]; extensions?: string[]; lifecycleRoles?: LifecycleRoleDeclaration } = {},
|
|
373
381
|
): Promise<EphemeralComposeResult> {
|
|
374
382
|
const database = `escheck_${process.pid}_${Date.now()}`;
|
|
375
383
|
await createDatabase(adminUrl, database);
|
|
@@ -378,6 +386,7 @@ export async function executeEphemeralCompose(
|
|
|
378
386
|
declared: opts.declared,
|
|
379
387
|
sequences: opts.sequences,
|
|
380
388
|
extensions: opts.extensions,
|
|
389
|
+
lifecycleRoles: opts.lifecycleRoles,
|
|
381
390
|
actor: opts.actor ?? 'db:check',
|
|
382
391
|
gitRef: opts.gitRef ?? null,
|
|
383
392
|
});
|
|
@@ -478,6 +487,7 @@ export async function dbCheckCommand(flags: Record<string, string>): Promise<voi
|
|
|
478
487
|
moduleModels: declaredDb?.models ?? null,
|
|
479
488
|
baselineSource,
|
|
480
489
|
modelMapPath: mapPath, modelMapSource,
|
|
490
|
+
lifecycleRoles: lifecycleRolesOf(declaredDb),
|
|
481
491
|
});
|
|
482
492
|
// Per-finding detail is the --verbose report. The quiet default answers the one question a
|
|
483
493
|
// human asked — is anything pending? — and names the verb that shows it.
|
|
@@ -504,6 +514,7 @@ export async function dbCheckCommand(flags: Record<string, string>): Promise<voi
|
|
|
504
514
|
declared: declaredDb?.objects,
|
|
505
515
|
sequences: declaredDb?.sequences,
|
|
506
516
|
extensions,
|
|
517
|
+
lifecycleRoles: lifecycleRolesOf(declaredDb),
|
|
507
518
|
});
|
|
508
519
|
if (verbose) for (const line of ephemeral.report) info(` ${line}`);
|
|
509
520
|
} catch (err: any) {
|