@everystack/cli 0.4.103 → 0.4.104
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 +42 -1
- package/package.json +3 -3
- package/src/cli/authz-compile.ts +15 -1
- package/src/cli/commands/db-apply.ts +34 -10
- package/src/cli/commands/db-backup.ts +62 -26
- package/src/cli/commands/db-fork.ts +2 -1
- package/src/cli/commands/db-plan.ts +3 -0
- package/src/cli/commands/db-pull.ts +46 -12
- package/src/cli/commands/db-reconcile.ts +34 -5
- package/src/cli/commands/db-sync.ts +32 -3
- package/src/cli/commands/db.ts +46 -21
- package/src/cli/db-build.ts +11 -1
- package/src/cli/derived-apply.ts +5 -1
- package/src/cli/derived-catchup.ts +4 -0
- package/src/cli/derived-compile.ts +2 -1
- package/src/cli/derived-introspect.ts +22 -1
- package/src/cli/derived-plan.ts +64 -0
- package/src/cli/derived-render.ts +1 -0
- package/src/cli/index.ts +1 -1
- package/src/cli/lifecycle-canon.ts +2 -1
- package/src/cli/lifecycle-derived.ts +264 -41
- package/src/cli/lifecycle-preflight.ts +498 -0
- package/src/cli/lifecycle-pull.ts +37 -4
- package/src/cli/model-render.ts +73 -7
- package/src/cli/schema-compile.ts +3 -64
- package/src/cli/state-function-deps.ts +2 -1
- package/src/cli/lifecycle-verify.ts +0 -238
package/CHANGELOG.md
CHANGED
|
@@ -4,11 +4,52 @@ 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
|
-
## [
|
|
7
|
+
## [0.4.104] - 2026-10-30
|
|
8
|
+
|
|
9
|
+
### BREAKING CHANGES
|
|
10
|
+
|
|
11
|
+
- destroy is one generated SQL function per lifecycle table; purgeExpired is removed
|
|
12
|
+
- only a lifecycle table may hold a foreign key into a lifecycle table
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- db:restore and db:fork name the repair a lifecycle app needs
|
|
17
|
+
- a destroy call removes only rows soft-deleted before its cutoff
|
|
18
|
+
- close destroy_log to the lifecycle role; the marker functions refuse inside a trigger
|
|
19
|
+
- a lifecycle model refuses TRIGGER and REFERENCES in privileges
|
|
20
|
+
- the apply preflight refuses a live catalog that breaks the soft delete
|
|
21
|
+
- a skipped destroy root says why: reason and blocker
|
|
22
|
+
- say where skip reasons are reported
|
|
23
|
+
- db:reconcile runs the live catalog check on every write path
|
|
8
24
|
|
|
9
25
|
### Fixed
|
|
10
26
|
|
|
11
27
|
- export compileDeclaredState from @everystack/cli/model
|
|
28
|
+
- db:pull only warns about a lifecycle table it cannot fold when it tried to fold
|
|
29
|
+
- db:pull does not render an older CLI's lifecycle_restrict pair as authored code
|
|
30
|
+
- provisioning clears committed destroy_log rows and validates the transaction check
|
|
31
|
+
- a DELETE as the lifecycle role is soft outside a destroy call
|
|
32
|
+
- a destroy call whose marker was removed mid-call fails whole (55000)
|
|
33
|
+
- review nits for lifecycle/testing
|
|
34
|
+
- refuse an out-of-date destroy_log before applying lifecycle objects
|
|
35
|
+
- refuse a destroy_log provisioned before the marker functions
|
|
36
|
+
- <t>_lifecycle_destroy_before() is executable by its owner alone
|
|
37
|
+
- catalog checks pass the RDS shape and see owner, log and settings holes
|
|
38
|
+
- the parent check's 23503 names its table and constraint
|
|
39
|
+
- a function whose result type changes is rebuilt, not replaced
|
|
40
|
+
- cap each blocker name part at 63 characters
|
|
41
|
+
- result types compare without type modifiers; bare time is time without time zone
|
|
42
|
+
- db:sync re-checks the catalog after its state apply; tables match by exact name
|
|
43
|
+
- analytics.post_metrics refers to posts by value, not by key
|
|
44
|
+
- destroy eligibility follows the root
|
|
45
|
+
- review nits for soft-delete-decides
|
|
46
|
+
- destroy_before is internal, so db:check passes on a lifecycle app
|
|
47
|
+
|
|
48
|
+
### Changed
|
|
49
|
+
|
|
50
|
+
- db:pull no longer refuses on lifecycle drift; the verifier is removed
|
|
51
|
+
- one catalog check over tables and roles, with a models adapter
|
|
52
|
+
- a destroy skip is an alarm, not an operator workflow
|
|
12
53
|
|
|
13
54
|
## [0.4.103] - 2026-10-23
|
|
14
55
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.104",
|
|
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.25"
|
|
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.40"
|
|
196
196
|
},
|
|
197
197
|
"scripts": {
|
|
198
198
|
"test": "jest",
|
package/src/cli/authz-compile.ts
CHANGED
|
@@ -460,7 +460,7 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
460
460
|
// A lifecycle model hides deleted rows from every read policy but the lifecycle role's own.
|
|
461
461
|
// Unlike softDelete there is no owner escape: the guard is AND-ed into each SELECT policy as
|
|
462
462
|
// compiled. A `manage` role's ALL policy is NOT guarded, on purpose: it sees deleted rows (to
|
|
463
|
-
// restore or
|
|
463
|
+
// restore or destroy them), exactly as it does under softDelete.
|
|
464
464
|
const lifecycleCol = model.lifecycle ? 'deleted_at' : null;
|
|
465
465
|
const lifecycleGuard = lifecycleCol ? softDeleteGuard(lifecycleCol) : null;
|
|
466
466
|
|
|
@@ -899,6 +899,20 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
899
899
|
* derivation. The lifecycle role is left out: it is the machinery, not an author's role.
|
|
900
900
|
* A policy with no USING admits nothing here, so it is skipped (fail closed).
|
|
901
901
|
*/
|
|
902
|
+
/**
|
|
903
|
+
* The roles whose `manage` covers every row: each one holds an ALL policy USING true WITH CHECK
|
|
904
|
+
* true. Read back from {@link compileTableContract}, so a `manage` that desugars to manage-own
|
|
905
|
+
* (owner-scoped, or role-less on a row-scoped model) is never in it. The lifecycle role is left out.
|
|
906
|
+
*/
|
|
907
|
+
export function allRowsManageRoles(model: ModelDescriptor, opts: CompileOptions = {}): string[] {
|
|
908
|
+
const roles = new Set<string>();
|
|
909
|
+
for (const p of compileTableContract(model, opts).policies) {
|
|
910
|
+
if (p.command !== 'ALL' || p.using !== 'true' || p.check !== 'true') continue;
|
|
911
|
+
for (const role of p.roles) if (role !== LIFECYCLE_OWNER_ROLE) roles.add(role);
|
|
912
|
+
}
|
|
913
|
+
return [...roles].sort();
|
|
914
|
+
}
|
|
915
|
+
|
|
902
916
|
export function deleteRulePredicates(model: ModelDescriptor, opts: CompileOptions = {}): Record<string, string> {
|
|
903
917
|
const byRole = new Map<string, string[]>();
|
|
904
918
|
for (const p of compileTableContract(model, opts).policies) {
|
|
@@ -77,6 +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 } from '../lifecycle-preflight';
|
|
80
81
|
import { buildReconcileReport } from './db-reconcile';
|
|
81
82
|
import { step, success, fail, info, warn, line as printLine } from '../output';
|
|
82
83
|
|
|
@@ -259,10 +260,12 @@ async function runCatchUp(
|
|
|
259
260
|
flags: Record<string, string>,
|
|
260
261
|
applied: number,
|
|
261
262
|
onRefusal: 'exit' | 'throw',
|
|
263
|
+
declared?: Awaited<ReturnType<typeof loadDeclaredDerived>>,
|
|
262
264
|
): Promise<CatchUpReport> {
|
|
263
265
|
const verbose = flags.verbose === 'true';
|
|
264
266
|
try {
|
|
265
267
|
const outcome = await catchUpDerived(venue, {
|
|
268
|
+
...(declared !== undefined ? { declared } : {}),
|
|
266
269
|
...(flags.models ? { models: flags.models } : {}),
|
|
267
270
|
onStart: () => step('Catching the functions, views and triggers up to the checkout...'),
|
|
268
271
|
onRefusal,
|
|
@@ -328,6 +331,7 @@ async function catchUpAfterApply(
|
|
|
328
331
|
plan: EdgePlan,
|
|
329
332
|
venue: CatchUpVenue,
|
|
330
333
|
flags: Record<string, string>,
|
|
334
|
+
declared?: Awaited<ReturnType<typeof loadDeclaredDerived>>,
|
|
331
335
|
): Promise<CatchUpReport> {
|
|
332
336
|
if (!LANDED.has(result.status)) return { ok: true, statements: [] };
|
|
333
337
|
if (flags['state-only'] === 'true') return { ok: true, statements: [] };
|
|
@@ -337,7 +341,7 @@ async function catchUpAfterApply(
|
|
|
337
341
|
const applied = result.status === 'already-applied'
|
|
338
342
|
? 0
|
|
339
343
|
: classifyGeneratedStatements((plan.statements ?? []).filter((s) => !unapplied.has(s))).executable.length;
|
|
340
|
-
return runCatchUp(venue, flags, applied, 'throw');
|
|
344
|
+
return runCatchUp(venue, flags, applied, 'throw', declared);
|
|
341
345
|
}
|
|
342
346
|
|
|
343
347
|
/**
|
|
@@ -510,6 +514,13 @@ async function applyPlanViaStage(
|
|
|
510
514
|
const config = await resolveConfig(flags.stage);
|
|
511
515
|
const { region } = config;
|
|
512
516
|
const fn = opsFunction(config);
|
|
517
|
+
const declared = await loadDeclaredDerived(flags.models);
|
|
518
|
+
const lifecycleRefusal = await lifecycleApplyRefusal(lambdaQueryRunner(region, fn), declared?.models ?? [])
|
|
519
|
+
?? (flags['state-only'] === 'true' ? await lifecycleStateOnlyRefusal(lambdaQueryRunner(region, fn), declared?.models ?? []) : null);
|
|
520
|
+
if (lifecycleRefusal) {
|
|
521
|
+
fail(lifecycleRefusal);
|
|
522
|
+
process.exit(1);
|
|
523
|
+
}
|
|
513
524
|
const attestLineageLost = flags['attest-lineage-lost']; // validated (not bare 'true') in dbApplyCommand
|
|
514
525
|
|
|
515
526
|
// The edge as hashes — internal bookkeeping, not what a reader came for. Verbose only.
|
|
@@ -653,7 +664,7 @@ async function applyPlanViaStage(
|
|
|
653
664
|
}
|
|
654
665
|
|
|
655
666
|
// Then the functions, views and triggers — on the same stage, credential-free, always.
|
|
656
|
-
const catchUp = await catchUpAfterApply(landedResult!, plan, { kind: 'stage', stage: flags.stage }, flags);
|
|
667
|
+
const catchUp = await catchUpAfterApply(landedResult!, plan, { kind: 'stage', stage: flags.stage }, flags, declared);
|
|
657
668
|
exitOnCatchUpFailure(catchUp);
|
|
658
669
|
const declaredGrantees = await declaredGranteesForReceipt(plan, flags);
|
|
659
670
|
const advisoryModels = await modelsForAdvisory(landedResult!, flags);
|
|
@@ -934,6 +945,21 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
934
945
|
}
|
|
935
946
|
}
|
|
936
947
|
|
|
948
|
+
step(connectingVia(dbSource));
|
|
949
|
+
const { runner, session, transaction, end } = await createUrlRunner(dbSource.url);
|
|
950
|
+
// Every gate below needs the declared derived bundle. Never evaluate the models barrel twice.
|
|
951
|
+
let declaredDbPromise: ReturnType<typeof loadDeclaredDerived> | undefined;
|
|
952
|
+
const declaredDb = (): ReturnType<typeof loadDeclaredDerived> =>
|
|
953
|
+
declaredDbPromise ??= loadDeclaredDerived(flags.models);
|
|
954
|
+
// Read-only, before the rollback point: a refused apply must not first take a snapshot, backup or dump.
|
|
955
|
+
const lifecycleRefusal = await lifecycleApplyRefusal(runner, (await declaredDb())?.models ?? [])
|
|
956
|
+
?? (flags['state-only'] === 'true' ? await lifecycleStateOnlyRefusal(runner, (await declaredDb())?.models ?? []) : null);
|
|
957
|
+
if (lifecycleRefusal) {
|
|
958
|
+
fail(lifecycleRefusal);
|
|
959
|
+
await end();
|
|
960
|
+
process.exit(1);
|
|
961
|
+
}
|
|
962
|
+
|
|
937
963
|
// Losing data should be hard (decision 11): a destructive plan applies
|
|
938
964
|
// only confirmed (--confirm, always), with a durable rollback point, and —
|
|
939
965
|
// when the stage declares an approver set — identity-approved (decision 12).
|
|
@@ -1051,16 +1077,8 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
1051
1077
|
}
|
|
1052
1078
|
}
|
|
1053
1079
|
|
|
1054
|
-
step(connectingVia(dbSource));
|
|
1055
|
-
const { runner, session, transaction, end } = await createUrlRunner(dbSource.url);
|
|
1056
|
-
|
|
1057
1080
|
let landed: ApplyPlanResult | undefined;
|
|
1058
1081
|
let catchUp: CatchUpReport = { ok: true, statements: [] };
|
|
1059
|
-
// Both gates below need the declared derived bundle. Keep its existing lazy timing — the
|
|
1060
|
-
// baseline gate is still the first reader — but never evaluate the models barrel twice.
|
|
1061
|
-
let declaredDbPromise: ReturnType<typeof loadDeclaredDerived> | undefined;
|
|
1062
|
-
const declaredDb = (): ReturnType<typeof loadDeclaredDerived> =>
|
|
1063
|
-
declaredDbPromise ??= loadDeclaredDerived(flags.models);
|
|
1064
1082
|
try {
|
|
1065
1083
|
// The edge as hashes — internal bookkeeping, not what a reader came for. Verbose only.
|
|
1066
1084
|
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` : ''})`);
|
|
@@ -1090,6 +1108,12 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
1090
1108
|
runner,
|
|
1091
1109
|
{ verb: 'db:apply', actor: process.env.USER ?? 'unknown' },
|
|
1092
1110
|
async () => {
|
|
1111
|
+
// Re-checked under the lease, where the writes happen.
|
|
1112
|
+
const leasedRefusal = await lifecycleApplyRefusal(runner, (await declaredDb())?.models ?? []);
|
|
1113
|
+
if (leasedRefusal) {
|
|
1114
|
+
fail(leasedRefusal);
|
|
1115
|
+
process.exit(1);
|
|
1116
|
+
}
|
|
1093
1117
|
const result = await executeApplyPlan(runner, session, plan, {
|
|
1094
1118
|
transaction,
|
|
1095
1119
|
actor: process.env.USER ?? null,
|
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
backupKey, backupId, metaKey, utcStamp,
|
|
29
29
|
} from '../backup';
|
|
30
30
|
import { pgEnvFromUrl, pgKeepaliveConninfo } from './db';
|
|
31
|
-
import { pollTaskUntilStopped } from '../task-poll';
|
|
31
|
+
import { pollTaskUntilStopped, type TaskPollResult } from '../task-poll';
|
|
32
32
|
import { step, success, fail, info, warn } from '../output';
|
|
33
33
|
|
|
34
34
|
/** Which database db:backup dumps. LOCAL requires a target --stage (its bucket receives the upload
|
|
@@ -327,29 +327,70 @@ export function printRestoreWitness(
|
|
|
327
327
|
* it requires --confirm; restoring production-tier data downward or into a production target raises
|
|
328
328
|
* a louder warning (the S0 guards). The id encodes the source stage.
|
|
329
329
|
*/
|
|
330
|
-
|
|
330
|
+
/**
|
|
331
|
+
* The db:restore preflight — the destructive gate, DB-free. A restore DROPS and replaces a
|
|
332
|
+
* database, so this decides who is allowed to proceed before anything connects: the id must be
|
|
333
|
+
* present and well-formed, and `--confirm` is mandatory (with the cross-stage / prod-target
|
|
334
|
+
* warnings escalated). Extracted from the shell so the gate itself is asserted by a test — a
|
|
335
|
+
* refactor that let the write dispatch without --confirm goes red (closing-gate F1 method).
|
|
336
|
+
*/
|
|
337
|
+
export type RestorePreflight =
|
|
338
|
+
| { ok: true; fromStage: string; toStage: string }
|
|
339
|
+
| { ok: false; kind: 'usage'; message: string }
|
|
340
|
+
| { ok: false; kind: 'needs-confirm'; warnings: string[]; hint: string };
|
|
341
|
+
|
|
342
|
+
export function restorePreflight(flags: Record<string, string>): RestorePreflight {
|
|
331
343
|
const id = flags.from;
|
|
332
|
-
if (!id) {
|
|
333
|
-
fail('db:restore requires --from <backup-id> (see `everystack db:backups`).');
|
|
334
|
-
process.exit(1);
|
|
335
|
-
}
|
|
344
|
+
if (!id) return { ok: false, kind: 'usage', message: 'db:restore requires --from <backup-id> (see `everystack db:backups`).' };
|
|
336
345
|
const ref = parseBackupRef(id);
|
|
337
|
-
if (!ref || !keyForId(id)) {
|
|
338
|
-
fail(`Malformed backup id: ${id}`);
|
|
339
|
-
process.exit(1);
|
|
340
|
-
}
|
|
346
|
+
if (!ref || !keyForId(id)) return { ok: false, kind: 'usage', message: `Malformed backup id: ${id}` };
|
|
341
347
|
const fromStage = ref.stage;
|
|
342
348
|
const toStage = flags.stage ?? fromStage;
|
|
343
|
-
|
|
344
|
-
// Destructive — always require --confirm. Production-tier source/target escalate the warning.
|
|
345
349
|
if (!flags.confirm) {
|
|
346
|
-
|
|
350
|
+
// Destructive — always require --confirm. Production-tier source/target escalate the warning.
|
|
351
|
+
const warnings = [`Restore is DESTRUCTIVE — it drops and replaces ${toStage}'s database with backup ${id}.`];
|
|
347
352
|
for (const g of [crossStageGuard(fromStage, toStage), restoreTargetGuard(toStage)]) {
|
|
348
|
-
if (g.requiresConfirm && g.warning)
|
|
353
|
+
if (g.requiresConfirm && g.warning) warnings.push(g.warning);
|
|
354
|
+
}
|
|
355
|
+
return { ok: false, kind: 'needs-confirm', warnings, hint: 'Re-run with --confirm to proceed.' };
|
|
356
|
+
}
|
|
357
|
+
return { ok: true, fromStage, toStage };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Map the restore task's poll outcome to an exit verdict — DB-free. The one guarantee it exists to
|
|
362
|
+
* hold: a restore that timed out, errored, or exited non-zero NEVER reports success. Only a clean
|
|
363
|
+
* `stopped` with exitCode 0 is a success; every other outcome is exit 1 with the intent to reconcile.
|
|
364
|
+
*/
|
|
365
|
+
export type RestoreVerdict = { exitCode: 0 | 1; kind: 'ok' | 'timeout' | 'error' | 'nonzero'; message: string };
|
|
366
|
+
|
|
367
|
+
export function restorePollVerdict(poll: TaskPollResult, ctx: { id: string; toStage: string; intentKey: string }): RestoreVerdict {
|
|
368
|
+
if (poll.outcome === 'timeout')
|
|
369
|
+
return { exitCode: 1, kind: 'timeout', message: `db:restore timed out (last status: ${poll.lastStatus}). Intent ${ctx.intentKey} — reconcile via ECS/the S3 intent.` };
|
|
370
|
+
if (poll.outcome === 'error')
|
|
371
|
+
return { exitCode: 1, kind: 'error', message: `task:status failed repeatedly: ${poll.status.error}. The restore may still be running — reconcile intent ${ctx.intentKey}.` };
|
|
372
|
+
if (poll.status.exitCode !== 0)
|
|
373
|
+
return { exitCode: 1, kind: 'nonzero', message: `Restore failed (exit ${poll.status.exitCode ?? 'unknown'})${poll.status.stoppedReason ? ` — ${poll.status.stoppedReason}` : ''}. The target DB may be partially restored — check the S3 intent ${ctx.intentKey} and the task logs.` };
|
|
374
|
+
return { exitCode: 0, kind: 'ok', message: `Restored ${ctx.id} into ${ctx.toStage}.` };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** What a restored lifecycle app needs; shared by db:restore and db:fork. */
|
|
378
|
+
export const LIFECYCLE_RESTORE_REPAIR =
|
|
379
|
+
'If the app declares lifecycle: true, DELETEs on those tables fail until you run db:apply, then db:reconcile --apply --overwrite-drift (docs/backups.md).';
|
|
380
|
+
|
|
381
|
+
export async function dbRestoreCommand(flags: Record<string, string>): Promise<void> {
|
|
382
|
+
const id = flags.from;
|
|
383
|
+
const pre = restorePreflight(flags);
|
|
384
|
+
if (!pre.ok) {
|
|
385
|
+
if (pre.kind === 'usage') fail(pre.message);
|
|
386
|
+
else {
|
|
387
|
+
for (const w of pre.warnings) warn(w);
|
|
388
|
+
info(pre.hint);
|
|
349
389
|
}
|
|
350
|
-
info('Re-run with --confirm to proceed.');
|
|
351
390
|
process.exit(1);
|
|
391
|
+
return; // process.exit is mocked to throw in tests; keep the narrowing honest for callers
|
|
352
392
|
}
|
|
393
|
+
const { toStage } = pre;
|
|
353
394
|
|
|
354
395
|
step('Resolving deployed config...');
|
|
355
396
|
let config: CliConfig;
|
|
@@ -383,19 +424,14 @@ export async function dbRestoreCommand(flags: Record<string, string>): Promise<v
|
|
|
383
424
|
|
|
384
425
|
step(`Restoring ${id} into ${toStage} (pg_restore in the Task; this may take a while)...`);
|
|
385
426
|
const poll = await pollTaskUntilStopped(config.region, fn, { runId, taskArn });
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
}
|
|
390
|
-
if (poll.outcome === 'error') {
|
|
391
|
-
fail(`task:status failed repeatedly: ${poll.status.error}. The restore may still be running — reconcile intent ${intentKey}.`);
|
|
392
|
-
process.exit(1);
|
|
393
|
-
}
|
|
394
|
-
if (poll.status.exitCode !== 0) {
|
|
395
|
-
fail(`Restore failed (exit ${poll.status.exitCode ?? 'unknown'})${poll.status.stoppedReason ? ` — ${poll.status.stoppedReason}` : ''}. The target DB may be partially restored — check the S3 intent ${intentKey} and the task logs.`);
|
|
427
|
+
const verdict = restorePollVerdict(poll, { id, toStage, intentKey });
|
|
428
|
+
if (verdict.exitCode !== 0) {
|
|
429
|
+
fail(verdict.message);
|
|
396
430
|
process.exit(1);
|
|
397
431
|
}
|
|
398
|
-
success(
|
|
432
|
+
success(verdict.message);
|
|
433
|
+
// The dump carries no owners or grants, so a lifecycle table refuses every DELETE (never hard-deletes) until repaired.
|
|
434
|
+
info(LIFECYCLE_RESTORE_REPAIR);
|
|
399
435
|
}
|
|
400
436
|
|
|
401
437
|
/** db:backup:download — print (or save) a presigned URL to a backup's dump for local download. */
|
|
@@ -24,7 +24,7 @@ import { invokeAction, presignGet } from '../aws';
|
|
|
24
24
|
import { keyForId, metaKey, parseBackupRef, isProductionTier, crossStageGuard } from '../backup';
|
|
25
25
|
import { pollTaskUntilStopped } from '../task-poll';
|
|
26
26
|
import { runStageBackup, stageBackupDeps } from '../stage-backup';
|
|
27
|
-
import { printRestoreWitness } from './db-backup';
|
|
27
|
+
import { LIFECYCLE_RESTORE_REPAIR, printRestoreWitness } from './db-backup';
|
|
28
28
|
import { step, success, fail, info, warn } from '../output';
|
|
29
29
|
|
|
30
30
|
export type ForkGuardVerdict =
|
|
@@ -157,5 +157,6 @@ export async function dbForkCommand(flags: Record<string, string>): Promise<void
|
|
|
157
157
|
info('Next:');
|
|
158
158
|
info(` everystack db:fingerprint --stage ${target} # where the fork is (a state some main commit declares)`);
|
|
159
159
|
info(` everystack db:plan --stage ${target} # your branch's edge on top — descent composes for free`);
|
|
160
|
+
info(` ${LIFECYCLE_RESTORE_REPAIR}`);
|
|
160
161
|
info(` sst remove --stage ${target} # teardown takes the database with it`);
|
|
161
162
|
}
|
|
@@ -63,6 +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 { lifecycleApplyRefusal } from '../lifecycle-preflight';
|
|
66
67
|
import { loadModels } from './db-generate';
|
|
67
68
|
import { loadDeclaredDerived, type DeclaredDerived } from '../declared-derived';
|
|
68
69
|
import { introspectDerived } from '../derived-introspect';
|
|
@@ -237,6 +238,8 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
|
|
|
237
238
|
}
|
|
238
239
|
|
|
239
240
|
try {
|
|
241
|
+
const lifecycleNote = await lifecycleApplyRefusal(runner, models);
|
|
242
|
+
if (lifecycleNote) warn(lifecycleNote);
|
|
240
243
|
let snapshot: SchemaSnapshot;
|
|
241
244
|
let contract: AuthzContract;
|
|
242
245
|
if (dbSource.kind === 'url') {
|
|
@@ -43,13 +43,12 @@
|
|
|
43
43
|
|
|
44
44
|
import fs from 'node:fs/promises';
|
|
45
45
|
import path from 'node:path';
|
|
46
|
-
import { introspectSchema, MATVIEW_COLUMNS_SQL, matviewColumnsByIdentity, FK_TARGETS_SQL, foreignKeyTargets, type ColumnRow, type ColumnSchema, type ForeignKeyTargetRow } from '../schema-introspect';
|
|
46
|
+
import { introspectSchema, MATVIEW_COLUMNS_SQL, matviewColumnsByIdentity, FK_TARGETS_SQL, foreignKeyTargets, type ColumnRow, type ColumnSchema, type ForeignKeyTargetRow, type TableSchema } from '../schema-introspect';
|
|
47
47
|
import { fingerprintLive } from '../schema-fingerprint';
|
|
48
48
|
import { ungovernedGrants, vestigialSequenceGrants, renderVestigialSequenceGrants, ALWAYS_GOVERNED, type GrantExemption } from '../authz-reconcile';
|
|
49
49
|
import { buildStageBaseline, mergeBaseline, readBaselineFile, writeBaselineFile, BASELINE_FILE } from '../authz-baseline';
|
|
50
50
|
import { introspectDerived, type DerivedCatalog } from '../derived-introspect';
|
|
51
51
|
import { foldLifecycle } from '../lifecycle-pull';
|
|
52
|
-
import { verifyLifecycle, LifecycleDriftError } from '../lifecycle-verify';
|
|
53
52
|
import { LIFECYCLE_OWNER_ROLE } from '@everystack/model';
|
|
54
53
|
|
|
55
54
|
/**
|
|
@@ -86,7 +85,7 @@ import { introspectTableOwners, buildOwnershipReport, renderOwnershipReport, typ
|
|
|
86
85
|
import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog';
|
|
87
86
|
import { renderDerivedSource, renderDerivedFile, renderDerivedDirectory, type DerivedRenderResult, type RenderedDerivedFile } from '../derived-render';
|
|
88
87
|
import { containedPath } from '../generated-names';
|
|
89
|
-
import { renderModelSource, renderModelFiles, pullableTables, skippedInfrastructureTables, modelVarName, externalForeignKeyWarnings, ABILITY_PRESETS, type RenderedModelFile } from '../model-render';
|
|
88
|
+
import { renderModelSource, renderModelFiles, pullableTables, skippedInfrastructureTables, modelVarName, toCamelCase, externalForeignKeyWarnings, ABILITY_PRESETS, type RenderedModelFile } from '../model-render';
|
|
90
89
|
import { deriveAbilities } from '../authz-derive';
|
|
91
90
|
import type { QueryRunner } from '../authz-contract';
|
|
92
91
|
import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source';
|
|
@@ -249,6 +248,7 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
249
248
|
let derivedCatalog: DerivedCatalog | undefined;
|
|
250
249
|
/** Tables folded to `lifecycle: true` (lifecycle-pull.ts). */
|
|
251
250
|
let lifecycleTables: ReadonlySet<string> = new Set();
|
|
251
|
+
const lifecycleForeignKeyTables = new Set<string>();
|
|
252
252
|
let lifecycleDeleteWhen: ReadonlyMap<string, string> = new Map();
|
|
253
253
|
let matviewColumns: Map<string, ColumnSchema[]> | undefined;
|
|
254
254
|
let candidatesByIdentity: Map<string, string[]> | undefined;
|
|
@@ -274,9 +274,6 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
274
274
|
// The derived layer rides the same pull (B5) — views/matviews/functions/sequences
|
|
275
275
|
// render as descriptors; adoption is pull → commit → --baseline → clean reconcile.
|
|
276
276
|
derivedCatalog = await introspectDerived(session);
|
|
277
|
-
const lifecycle = await verifyLifecycle(session, ['anon', 'authenticated', 'admin', LIFECYCLE_OWNER_ROLE, ...governRoles]);
|
|
278
|
-
if (lifecycle.failures.length) throw new LifecycleDriftError(lifecycle.failures);
|
|
279
|
-
for (const warning of lifecycle.notes) caution(warning);
|
|
280
277
|
// --abilities live: the authz half of the on-ramp. Same connection, one more read —
|
|
281
278
|
// the grants and policies that already exist become the models' declared abilities,
|
|
282
279
|
// instead of a human transcribing them by hand.
|
|
@@ -351,6 +348,47 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
351
348
|
for (const line of renderVestigialSequenceGrants(vestigialSequenceGrants(contract), governedAtPull)) caution(line);
|
|
352
349
|
pulledFingerprint = fingerprintLive(rawSnapshot, rawContract).hash;
|
|
353
350
|
}
|
|
351
|
+
const tables = pullableTables(current, schema);
|
|
352
|
+
const derivedObjects = derivedCatalog.objects;
|
|
353
|
+
const isLifecycleTable = (table: TableSchema): boolean => {
|
|
354
|
+
const name = table.table.slice(table.table.indexOf('.') + 1);
|
|
355
|
+
return lifecycleTables.has(table.table) || (['deleted_at', 'deleted_by', 'deleted_batch', 'deleted_xid'].every(column => table.columns.some(c => c.name === column))
|
|
356
|
+
&& derivedObjects.some(object => object.kind === 'trigger' && object.identity === `${table.table}.${name}_lifecycle_transition`));
|
|
357
|
+
};
|
|
358
|
+
for (const table of tables) {
|
|
359
|
+
const name = table.table.slice(table.table.indexOf('.') + 1);
|
|
360
|
+
if (isLifecycleTable(table)) {
|
|
361
|
+
lifecycleForeignKeyTables.add(table.table);
|
|
362
|
+
for (const fk of table.foreignKeys) {
|
|
363
|
+
if (fk.onDelete !== 'cascade') continue;
|
|
364
|
+
const parent = fk.refTable.includes('.') ? fk.refTable : `public.${fk.refTable}`;
|
|
365
|
+
const key = `${table.table}.${fk.columns.join(',')}`;
|
|
366
|
+
const parentTable = tables.find(t => t.table === parent);
|
|
367
|
+
// A one-column key into a parent rendered lifecycle: true renders as its hasMany dependent.
|
|
368
|
+
if (fk.columns.length === 1 && lifecycleTables.has(parent)) continue;
|
|
369
|
+
// Only a one-column key from a lifecycle parent can be declared: hasMany joins on one
|
|
370
|
+
// column, and a lifecycle child cannot cascade from a table without lifecycle. The
|
|
371
|
+
// pulled barrel does not link models, so the advice names the thunk form the pull uses.
|
|
372
|
+
if (fk.columns.length === 1 && parentTable && isLifecycleTable(parentTable)) {
|
|
373
|
+
const child = modelVarName(table.table);
|
|
374
|
+
const field = toCamelCase(fk.columns[0]);
|
|
375
|
+
caution(`${key} cascades from lifecycle table ${parent} in the database; db:pull cannot render ` + '`dependent`'
|
|
376
|
+
+ `, so it is rendered as restrict. Declare \`hasMany(() => ${child}, '${field}', { dependent: 'cascade' })\` in ${modelVarName(parent)}'s relations`
|
|
377
|
+
+ ` and remove \`onDelete\` from ${child}'s ${field} field; then db:plan shows no foreign-key change.`);
|
|
378
|
+
} else {
|
|
379
|
+
caution(`${key} cascades from ${parent} in the database; db:pull renders it as restrict, and the models cannot declare this cascade, so db:plan shows this key changing to restrict.`);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
if (!lifecycleTables.has(table.table) && derivedCatalog.objects.some(object =>
|
|
384
|
+
object.kind === 'trigger' && object.identity === `${table.table}.${name}_lifecycle_transition`)) {
|
|
385
|
+
if (abilities === 'live') {
|
|
386
|
+
caution(`${table.table} has lifecycle objects but they are not exactly what the compiler writes. Rendered as a plain table. Run db:apply from the checkout that declared it, or review db:plan.`);
|
|
387
|
+
} else {
|
|
388
|
+
note(`${table.table} is a lifecycle table. The default pull renders it as a plain table; pass --abilities live to render lifecycle: true.`);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
354
392
|
// --matviews-as-tables: the flip needs real fields — one extra catalog read for the
|
|
355
393
|
// matview columns the derived layer (definition-only) doesn't carry.
|
|
356
394
|
if (matviewsAsTables) {
|
|
@@ -372,10 +410,6 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
372
410
|
await end?.();
|
|
373
411
|
} catch (err: any) {
|
|
374
412
|
await end?.().catch(() => {});
|
|
375
|
-
if (err instanceof LifecycleDriftError) {
|
|
376
|
-
for (const line of err.lines) console.error(line);
|
|
377
|
-
process.exit(1);
|
|
378
|
-
}
|
|
379
413
|
fail(err.message);
|
|
380
414
|
if (dbSource.kind === 'url') {
|
|
381
415
|
detail('Check the connection string and that the database is accepting connections.');
|
|
@@ -494,10 +528,10 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
494
528
|
: undefined;
|
|
495
529
|
if (flags.out && !flags.out.endsWith('.ts')) {
|
|
496
530
|
const dir = path.resolve(flags.out);
|
|
497
|
-
modelFiles = renderModelFiles(current, { schema, abilities, liveAuthz, governRoles, fkTargets, derived: embeddedDerived, externalDerived, lifecycleTables, lifecycleDeleteWhen })
|
|
531
|
+
modelFiles = renderModelFiles(current, { schema, abilities, liveAuthz, governRoles, fkTargets, derived: embeddedDerived, externalDerived, lifecycleTables, lifecycleDeleteWhen, lifecycleForeignKeyTables })
|
|
498
532
|
.map((f) => ({ ...f, target: containedPath(dir, f.file) }));
|
|
499
533
|
} else {
|
|
500
|
-
modelSource = renderModelSource(current, { schema, abilities, liveAuthz, governRoles, fkTargets, derived: embeddedDerived, lifecycleTables, lifecycleDeleteWhen });
|
|
534
|
+
modelSource = renderModelSource(current, { schema, abilities, liveAuthz, governRoles, fkTargets, derived: embeddedDerived, lifecycleTables, lifecycleDeleteWhen, lifecycleForeignKeyTables });
|
|
501
535
|
}
|
|
502
536
|
}
|
|
503
537
|
} catch (err: any) {
|
|
@@ -32,6 +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
36
|
import { planReconcile, objectGrantDelta, TRIGGER_ENABLED_STATE, type ReconcilePlan, type ReconcileOptions } from '../derived-plan';
|
|
36
37
|
import {
|
|
37
38
|
renderReconcileSql,
|
|
@@ -77,6 +78,18 @@ export interface ExecuteOptions extends ReconcileOptions {
|
|
|
77
78
|
planRef?: string | null;
|
|
78
79
|
/** Descriptor-compiled objects (the declared derived layer) — the ONE source stream. */
|
|
79
80
|
declared?: SourceObject[];
|
|
81
|
+
/**
|
|
82
|
+
* The application roles for the lifecycle catalog check (catalogInputsFromModels). Required
|
|
83
|
+
* whenever a declared object is lifecycle-owned: absent, the reconcile refuses rather than skip
|
|
84
|
+
* the check. `unknown` because the ops Lambda passes it straight off the wire.
|
|
85
|
+
*/
|
|
86
|
+
applicationRoles?: unknown;
|
|
87
|
+
/**
|
|
88
|
+
* The caller already ran lifecycleApplyRefusal with the full models, on this runner, in this
|
|
89
|
+
* process (executeSync does, first thing), so the reconcile does not run the catalog check again.
|
|
90
|
+
* Never set from a wire payload.
|
|
91
|
+
*/
|
|
92
|
+
lifecyclePreflighted?: boolean;
|
|
80
93
|
/** Injectable clock for tests. */
|
|
81
94
|
now?: () => number;
|
|
82
95
|
}
|
|
@@ -89,6 +102,8 @@ export interface ReconcileRun {
|
|
|
89
102
|
statements: string[];
|
|
90
103
|
/** Why apply was refused, when it was. */
|
|
91
104
|
refusal?: string;
|
|
105
|
+
/** A plan-only run: what the lifecycle catalog check found (the text an apply would refuse with). */
|
|
106
|
+
lifecycleFindings?: string;
|
|
92
107
|
/**
|
|
93
108
|
* Which mechanism enacted the declared owners, when any were enacted. Reported because the two
|
|
94
109
|
* make different demands of the operator: `set-role` needs membership plus CREATE-on-schema for
|
|
@@ -140,13 +155,21 @@ export async function executeReconcile(
|
|
|
140
155
|
// catalog and re-renders if this builder can use the cheaper one; a plan that is never applied
|
|
141
156
|
// (or is refused) shows the conservative form, which is the honest default.
|
|
142
157
|
let rendered = renderReconcileSql(plan, parsed.objects);
|
|
143
|
-
|
|
144
|
-
|
|
158
|
+
// Every mode that writes or adopts a lifecycle-owned object (--apply with --baseline,
|
|
159
|
+
// --rebaseline, --only, --overwrite-drift or none) refuses on it below; a plan-only run reports
|
|
160
|
+
// it as a finding. See lifecycleCatalogRefusal for what it defends and from whom.
|
|
161
|
+
const lifecycleGate = (): Promise<string | null> => (options.lifecyclePreflighted ? Promise.resolve(null)
|
|
162
|
+
: lifecycleReconcileRefusal(runner, parsed.objects, { applicationRoles: options.applicationRoles }));
|
|
163
|
+
|
|
164
|
+
if (!options.apply) {
|
|
165
|
+
const lifecycleFindings = await lifecycleGate();
|
|
166
|
+
return { plan, applied: false, statements: rendered.statements, ...(lifecycleFindings ? { lifecycleFindings } : {}) };
|
|
167
|
+
}
|
|
145
168
|
|
|
146
169
|
if (plan.blocked.length > 0) {
|
|
147
170
|
return {
|
|
148
171
|
plan, applied: false, statements: rendered.statements,
|
|
149
|
-
refusal: `blocked chains: ${plan.blocked.map((b) => b.identity).join('
|
|
172
|
+
refusal: `blocked chains: ${plan.blocked.map((b) => `${b.identity} (${b.reason})`).join('; ')} — resolve the dependents first`,
|
|
150
173
|
};
|
|
151
174
|
}
|
|
152
175
|
if (plan.drift.length > 0 && !options.overwriteDrift) {
|
|
@@ -155,6 +178,8 @@ export async function executeReconcile(
|
|
|
155
178
|
refusal: `drift on ${plan.drift.map((d) => d.identity).join(', ')} — inspect, then re-run with --overwrite-drift to rebuild from source`,
|
|
156
179
|
};
|
|
157
180
|
}
|
|
181
|
+
const lifecycleRefusal = await lifecycleGate();
|
|
182
|
+
if (lifecycleRefusal) return { plan, applied: false, statements: rendered.statements, refusal: lifecycleRefusal };
|
|
158
183
|
if (rendered.statements.length === 0 && rendered.record.length === 0 && rendered.remove.length === 0 && rendered.migrate.length === 0) {
|
|
159
184
|
return { plan, applied: false, statements: [] };
|
|
160
185
|
}
|
|
@@ -740,6 +765,9 @@ export async function dbReconcileCommand(flags: Record<string, string>): Promise
|
|
|
740
765
|
const reconcileOptions = {
|
|
741
766
|
apply,
|
|
742
767
|
declared: declared?.objects,
|
|
768
|
+
// The lifecycle catalog check's roles, from the barrel's models. The tables come from the
|
|
769
|
+
// declared transition triggers inside the reconcile, the same derivation the ops Lambda runs.
|
|
770
|
+
applicationRoles: declared?.models ? catalogInputsFromModels(declared.models).applicationRoles : undefined,
|
|
743
771
|
renamedTables: declared?.renamedTables,
|
|
744
772
|
baseline: flags.baseline === 'true',
|
|
745
773
|
rebaseline: flags.rebaseline === 'true',
|
|
@@ -820,9 +848,10 @@ export async function dbReconcileCommand(flags: Record<string, string>): Promise
|
|
|
820
848
|
}
|
|
821
849
|
|
|
822
850
|
if (flags.json === 'true') {
|
|
823
|
-
console.log(JSON.stringify({ ...run.plan, applied: run.applied, refusal: run.refusal ?? null }, null, 2));
|
|
851
|
+
console.log(JSON.stringify({ ...run.plan, applied: run.applied, refusal: run.refusal ?? null, lifecycleFindings: run.lifecycleFindings ?? null }, null, 2));
|
|
824
852
|
} else {
|
|
825
853
|
for (const line of buildReconcileReport(run.plan)) info(line);
|
|
854
|
+
if (run.lifecycleFindings) warn(`lifecycle: ${run.lifecycleFindings}`);
|
|
826
855
|
if (run.refusal) {
|
|
827
856
|
fail(`not applied: ${run.refusal}`);
|
|
828
857
|
} else if (run.applied) {
|
|
@@ -848,7 +877,7 @@ export async function dbReconcileCommand(flags: Record<string, string>): Promise
|
|
|
848
877
|
}
|
|
849
878
|
|
|
850
879
|
if (run.refusal) process.exit(1);
|
|
851
|
-
if (check && checkFails(run.plan)) process.exit(1);
|
|
880
|
+
if (check && (checkFails(run.plan) || run.lifecycleFindings)) process.exit(1);
|
|
852
881
|
} catch (err: any) {
|
|
853
882
|
if (err instanceof MutationLeaseError) {
|
|
854
883
|
// The lease refused before any DDL — nothing was reconciled.
|