@everystack/cli 0.4.104 → 0.4.105
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 +12 -0
- package/package.json +2 -2
- package/src/cli/commands/db-apply.ts +5 -1
- package/src/cli/commands/db-build.ts +3 -1
- package/src/cli/commands/db-plan.ts +2 -1
- package/src/cli/commands/db-sync.ts +5 -1
- package/src/cli/db-build.ts +10 -1
- package/src/cli/derived-apply.ts +12 -1
- package/src/cli/derived-catchup.ts +28 -7
- package/src/cli/index.ts +10 -0
- package/src/cli/lifecycle-preflight.ts +133 -14
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,18 @@ 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.105] - 2026-10-31
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- rule 5 judges who can replace a trigger function, not its security mode
|
|
12
|
+
- judge declared trigger-function owners before the derived layer lands
|
|
13
|
+
- re-run the catalog check after the derived layer lands
|
|
14
|
+
- read the reconcile's builder on its own connection
|
|
15
|
+
- everystack --version prints the version
|
|
16
|
+
- run the post-check on the reconcile's own connection
|
|
17
|
+
- say the tables were applied only when this run applied them
|
|
18
|
+
|
|
7
19
|
## [0.4.104] - 2026-10-30
|
|
8
20
|
|
|
9
21
|
### BREAKING CHANGES
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.105",
|
|
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>",
|
|
@@ -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.41"
|
|
196
196
|
},
|
|
197
197
|
"scripts": {
|
|
198
198
|
"test": "jest",
|
|
@@ -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 } from '../lifecycle-preflight';
|
|
80
|
+
import { lifecycleApplyRefusal, lifecycleStateOnlyRefusal, LifecycleFindingsError } 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
|
|
|
@@ -269,6 +269,8 @@ async function runCatchUp(
|
|
|
269
269
|
...(flags.models ? { models: flags.models } : {}),
|
|
270
270
|
onStart: () => step('Catching the functions, views and triggers up to the checkout...'),
|
|
271
271
|
onRefusal,
|
|
272
|
+
// A lifecycle refusal says the tables were applied only when this run applied some.
|
|
273
|
+
stateApplied: applied > 0,
|
|
272
274
|
});
|
|
273
275
|
if (outcome.run) {
|
|
274
276
|
if (verbose) {
|
|
@@ -289,6 +291,8 @@ async function runCatchUp(
|
|
|
289
291
|
return { ok: true, statements: outcome.statements };
|
|
290
292
|
} catch (err: any) {
|
|
291
293
|
const cause = String(err?.message ?? err);
|
|
294
|
+
// A lifecycle finding's report already says what committed and what did not, one line each.
|
|
295
|
+
if (err instanceof LifecycleFindingsError) return { ok: false, lines: cause.split('\n') };
|
|
292
296
|
return {
|
|
293
297
|
ok: false,
|
|
294
298
|
// The cause is printed ONCE, at every verbosity: its headline on the report line, and
|
|
@@ -26,6 +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
30
|
import { createUrlRunner } from '../db-source';
|
|
30
31
|
import { resolveModelsPath } from '../models-path';
|
|
31
32
|
import { loadDeclaredDerived, retiredSqlDirAnywhere, retiredSqlDirFlagRefusal, type DeclaredDerived } from '../declared-derived';
|
|
@@ -127,7 +128,8 @@ export async function dbBuildCommand(flags: Record<string, string>): Promise<voi
|
|
|
127
128
|
gitRef: currentGitRef(),
|
|
128
129
|
});
|
|
129
130
|
} catch (err: any) {
|
|
130
|
-
|
|
131
|
+
// A lifecycle finding says itself what was and was not written.
|
|
132
|
+
fail(err instanceof LifecycleFindingsError ? err.message : `Build failed: ${err.message}`);
|
|
131
133
|
process.exit(1);
|
|
132
134
|
}
|
|
133
135
|
|
|
@@ -239,7 +239,8 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
|
|
|
239
239
|
|
|
240
240
|
try {
|
|
241
241
|
const lifecycleNote = await lifecycleApplyRefusal(runner, models);
|
|
242
|
-
|
|
242
|
+
// db:plan only reads, so it mints the plan anyway; db:apply runs the same check and refuses.
|
|
243
|
+
if (lifecycleNote) warn(`db:apply will refuse this plan until each lifecycle finding below is fixed.\n${lifecycleNote}`);
|
|
243
244
|
let snapshot: SchemaSnapshot;
|
|
244
245
|
let contract: AuthzContract;
|
|
245
246
|
if (dbSource.kind === 'url') {
|
|
@@ -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, lifecycleApplyRefusal } from '../lifecycle-preflight';
|
|
34
|
+
import { catalogInputsFromModels, lifecycleApplyRefusal, lifecycleDeclaredOwnerRefusal, LifecycleFindingsError } 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';
|
|
@@ -242,6 +242,10 @@ export async function executeSync(
|
|
|
242
242
|
// State runs before compute, so without this a lifecycle table would land with no transition trigger.
|
|
243
243
|
const lifecycleRefusal = await lifecycleApplyRefusal(runner, models);
|
|
244
244
|
if (lifecycleRefusal) throw new Error(lifecycleRefusal);
|
|
245
|
+
// Before anything is written, so before both places a pass creates derived objects (the
|
|
246
|
+
// functions the state batch calls, then the reconcile).
|
|
247
|
+
const ownerRefusal = await lifecycleDeclaredOwnerRefusal(runner, session, options.declared ?? [], models, { stateApplied: false });
|
|
248
|
+
if (ownerRefusal) throw new LifecycleFindingsError(ownerRefusal);
|
|
245
249
|
const first = await syncOnce(runner, session, models, options, hooks, 1);
|
|
246
250
|
// The trigger is the STATE bar, not the combined verdict. The cause above is state-layer
|
|
247
251
|
// only, so a compute refusal or an unadopted object cannot be settled by looking again —
|
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 } from './lifecycle-preflight';
|
|
21
|
+
import { lifecycleApplyRefusal, lifecycleDeclaredOwnerRefusal, LifecycleFindingsError, lifecyclePostApplyRefusal } from './lifecycle-preflight';
|
|
22
22
|
|
|
23
23
|
const SAFE_NAME = /^[a-z_][a-z0-9_$]*$/;
|
|
24
24
|
|
|
@@ -181,6 +181,11 @@ export async function buildIntoDatabase(
|
|
|
181
181
|
// declared state: 20 such pairs, `type "…_row" does not exist`, from-scratch only. Their
|
|
182
182
|
// explicit `dependsOn` could not have helped — this pass dropped the view from the wave, so
|
|
183
183
|
// no edge could order it back in.
|
|
184
|
+
// Before the first derived write: the owners the reconcile will give the declared trigger
|
|
185
|
+
// functions on lifecycle tables. This pass runs before executeSync, which judges them again
|
|
186
|
+
// first thing, before its own state apply.
|
|
187
|
+
const ownerRefusal = await lifecycleDeclaredOwnerRefusal(runner, session, options.declared ?? [], models, { stateApplied: false });
|
|
188
|
+
if (ownerRefusal) throw new LifecycleFindingsError(ownerRefusal);
|
|
184
189
|
const declaredFunctions = (options.declared ?? [])
|
|
185
190
|
.filter((o) => o.kind === 'function' && !o.signatureDeps?.length);
|
|
186
191
|
if (declaredFunctions.length > 0) {
|
|
@@ -214,6 +219,10 @@ export async function buildIntoDatabase(
|
|
|
214
219
|
actor: options.actor ?? 'db-build',
|
|
215
220
|
gitRef: options.gitRef ?? null,
|
|
216
221
|
});
|
|
222
|
+
// Verification: the live catalog check again, over what the derived layer just made live. A
|
|
223
|
+
// 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);
|
|
225
|
+
if (post) throw new LifecycleFindingsError(post);
|
|
217
226
|
return {
|
|
218
227
|
converged: run.converged,
|
|
219
228
|
fingerprintMatch: run.fingerprintMatch,
|
package/src/cli/derived-apply.ts
CHANGED
|
@@ -181,6 +181,16 @@ function objectSql(obj: SourceObject, ownerMode: OwnerApplyMode = 'set-role', re
|
|
|
181
181
|
return [`SET ROLE ${obj.owner}`, ...statements, 'RESET ROLE'];
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
+
/**
|
|
185
|
+
* The role that owns an object once the reconcile has created it: its declared owner (objectSql
|
|
186
|
+
* enacts it), else `builder`, the role the reconcile runs as, which creates it as itself. The
|
|
187
|
+
* lifecycle pre-exposure gate reads owners through this too, so the role it judges is the role the
|
|
188
|
+
* reconcile makes.
|
|
189
|
+
*/
|
|
190
|
+
export function reconcileOwner(obj: Pick<SourceObject, 'owner'>, builder: string): string {
|
|
191
|
+
return obj.owner || builder;
|
|
192
|
+
}
|
|
193
|
+
|
|
184
194
|
const ALTER_OWNER: Record<string, string> = {
|
|
185
195
|
function: 'ALTER FUNCTION',
|
|
186
196
|
view: 'ALTER VIEW',
|
|
@@ -228,7 +238,8 @@ export function triggerFunctionOf(trigger: SourceObject, source: readonly Source
|
|
|
228
238
|
function withTransientExecute(obj: SourceObject, statements: string[], source: readonly SourceObject[], grantee?: string): string[] {
|
|
229
239
|
if (!grantee || obj.kind !== 'trigger') return statements;
|
|
230
240
|
const fn = triggerFunctionOf(obj, source);
|
|
231
|
-
|
|
241
|
+
// The builder owns a function it creates for itself, and an owner holds EXECUTE.
|
|
242
|
+
if (!fn || reconcileOwner(fn, grantee) === grantee) return statements;
|
|
232
243
|
const declared = parseGrantAttachments(fn.attachments).grants[grantee] ?? [];
|
|
233
244
|
if (declared.includes('EXECUTE') || declared.includes('ALL')) return statements;
|
|
234
245
|
const target = dropTarget('function', fn.identity);
|
|
@@ -33,9 +33,9 @@ import { loadDeclaredDerived, type DeclaredDerived } from './declared-derived';
|
|
|
33
33
|
import { modelContractPreflight } from './model-wire-lane';
|
|
34
34
|
import { MODEL_WIRE_CONTRACT, type ReconcileWirePayload } from '../wire';
|
|
35
35
|
import { resolveConfig, opsFunction } from './config';
|
|
36
|
-
import { invokeAction } from './aws';
|
|
36
|
+
import { invokeAction, lambdaQueryRunner, lambdaSessionRunner } from './aws';
|
|
37
37
|
import { currentGitRef } from './state-apply';
|
|
38
|
-
import { catalogInputsFromModels } from './lifecycle-preflight';
|
|
38
|
+
import { catalogInputsFromModels, lifecycleDeclaredOwnerRefusal, LifecycleFindingsError, lifecyclePostApplyRefusal } 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 =
|
|
@@ -77,6 +77,12 @@ export interface CatchUpOptions {
|
|
|
77
77
|
* wrongly handed an exit loses the sentence its operator was owed.
|
|
78
78
|
*/
|
|
79
79
|
onRefusal: 'exit' | 'throw';
|
|
80
|
+
/**
|
|
81
|
+
* Whether THIS run committed table or column changes before the catch-up: a lifecycle refusal
|
|
82
|
+
* then says so. False for db:apply with no --plan, and for a plan that was already applied.
|
|
83
|
+
* Required for the same reason as `onRefusal`: only the caller knows what it wrote.
|
|
84
|
+
*/
|
|
85
|
+
stateApplied: boolean;
|
|
80
86
|
}
|
|
81
87
|
|
|
82
88
|
export interface CatchUpOutcome {
|
|
@@ -116,11 +122,21 @@ export async function catchUpDerived(
|
|
|
116
122
|
gitRef: currentGitRef(),
|
|
117
123
|
};
|
|
118
124
|
|
|
125
|
+
// Before the first derived write, judge the owners the reconcile will give the declared trigger
|
|
126
|
+
// functions on lifecycle tables.
|
|
127
|
+
const config = venue.kind === 'stage' ? await resolveConfig(venue.stage) : undefined;
|
|
128
|
+
const reader: QueryRunner = (q) => (venue.kind === 'direct' ? venue.runner : lambdaQueryRunner(config!.region, opsFunction(config!)))(q);
|
|
129
|
+
// The builder is read on the reconcile's own connection: on a stage, db:session runs on the
|
|
130
|
+
// operator connection, while db:query may run on a reader.
|
|
131
|
+
const operator: SessionRunner = (statements, opts) =>
|
|
132
|
+
(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 });
|
|
134
|
+
if (gate) throw new LifecycleFindingsError(gate);
|
|
135
|
+
|
|
119
136
|
let run: ReconcileRun;
|
|
120
137
|
if (venue.kind === 'direct') {
|
|
121
138
|
run = await executeReconcile(venue.runner, venue.session, options);
|
|
122
139
|
} else {
|
|
123
|
-
const config = await resolveConfig(venue.stage);
|
|
124
140
|
// The ops Lambda CREATES AND REPLACES database objects from these descriptors, so verify the
|
|
125
141
|
// deployed stage understands their shape before sending them — the same handshake
|
|
126
142
|
// db:reconcile --apply --stage runs, for the same payload.
|
|
@@ -130,14 +146,14 @@ export async function catchUpDerived(
|
|
|
130
146
|
// before the process ends. A catch-up that is the whole command has written nothing, and
|
|
131
147
|
// asks to exit: print the skew, say nothing was sent, exit.
|
|
132
148
|
await modelContractPreflight({
|
|
133
|
-
region: config
|
|
134
|
-
fn: opsFunction(config),
|
|
149
|
+
region: config!.region,
|
|
150
|
+
fn: opsFunction(config!),
|
|
135
151
|
directCommand: `everystack db:apply --stage ${venue.stage} --direct`,
|
|
136
152
|
onRefusal: opts.onRefusal,
|
|
137
153
|
})('db:apply');
|
|
138
154
|
const result: any = await invokeAction(
|
|
139
|
-
config
|
|
140
|
-
opsFunction(config),
|
|
155
|
+
config!.region,
|
|
156
|
+
opsFunction(config!),
|
|
141
157
|
'db:reconcile',
|
|
142
158
|
{ ...options, modelContract: MODEL_WIRE_CONTRACT } satisfies ReconcileWirePayload,
|
|
143
159
|
);
|
|
@@ -154,5 +170,10 @@ export async function catchUpDerived(
|
|
|
154
170
|
// not land, so db:apply must say so and exit the same way.
|
|
155
171
|
if (run.refusal) throw new CatchUpError(run.refusal);
|
|
156
172
|
|
|
173
|
+
// Verification: the live catalog check again, over what the reconcile just made live. On the
|
|
174
|
+
// operator session, where the commit is: db:query may read a replica that has not caught up.
|
|
175
|
+
const post = await lifecyclePostApplyRefusal(operator, declared?.models ?? []);
|
|
176
|
+
if (post) throw new LifecycleFindingsError(post);
|
|
177
|
+
|
|
157
178
|
return { declared: objects.length, run, statements: run.applied ? (run.statements ?? []) : [] };
|
|
158
179
|
}
|
package/src/cli/index.ts
CHANGED
|
@@ -44,6 +44,8 @@ import { auditLighthouseCommand } from './commands/lighthouse';
|
|
|
44
44
|
import { auditCommand } from './commands/audit';
|
|
45
45
|
import { uiAuditCommand } from './commands/ui-audit';
|
|
46
46
|
import { runbookCommand } from './commands/runbook';
|
|
47
|
+
import { readFileSync } from 'node:fs';
|
|
48
|
+
import path from 'node:path';
|
|
47
49
|
import { fail } from './output';
|
|
48
50
|
import { parseFlags } from './parse-flags';
|
|
49
51
|
|
|
@@ -56,6 +58,13 @@ async function main() {
|
|
|
56
58
|
process.exit(0);
|
|
57
59
|
}
|
|
58
60
|
|
|
61
|
+
if (command === '--version' || command === '-v') {
|
|
62
|
+
// tsx polyfills __dirname in ESM (see runbook.ts cliVersion).
|
|
63
|
+
const pkg = JSON.parse(readFileSync(path.join(eval('__dirname') as string, '..', '..', 'package.json'), 'utf8'));
|
|
64
|
+
console.log(pkg.version);
|
|
65
|
+
process.exit(0);
|
|
66
|
+
}
|
|
67
|
+
|
|
59
68
|
const flags = parseFlags(args.slice(1));
|
|
60
69
|
|
|
61
70
|
// `everystack <command> --help` shows help instead of running the command
|
|
@@ -405,6 +414,7 @@ Auth:
|
|
|
405
414
|
|
|
406
415
|
Options:
|
|
407
416
|
--help, -h Show this help message
|
|
417
|
+
--version, -v Print the @everystack/cli version
|
|
408
418
|
`);
|
|
409
419
|
}
|
|
410
420
|
|
|
@@ -3,6 +3,8 @@ import { createHash } from 'node:crypto';
|
|
|
3
3
|
import { DESTROY_LOG_MARKER_BODIES, DESTROY_LOG_MARKER_LANGUAGES, DESTROY_LOG_MARKER_SEARCH_PATH, LIFECYCLE_OWNER_ROLE, type ModelDescriptor } from '@everystack/model';
|
|
4
4
|
import type { QueryRunner } from './authz-contract';
|
|
5
5
|
import { compileTableContract } from './authz-compile';
|
|
6
|
+
import { reconcileOwner, triggerFunctionOf } from './derived-apply';
|
|
7
|
+
import { isSessionError, type SessionRunner } from './session';
|
|
6
8
|
import { parseQualified, type SourceObject } from './derived-source';
|
|
7
9
|
import { lifecycleDerived, lifecycleManifestOf, lifecycleRole } from './lifecycle-derived';
|
|
8
10
|
|
|
@@ -248,20 +250,43 @@ const markerSql = (lc: string): string => `SELECT s.sig, p.oid IS NOT NULL AS pr
|
|
|
248
250
|
ORDER BY s.i`;
|
|
249
251
|
|
|
250
252
|
/**
|
|
251
|
-
* Rule 5. A trigger on a lifecycle table whose function is
|
|
252
|
-
*
|
|
253
|
+
* Rule 5. A trigger on a lifecycle table whose function's owner is an application-side role, or
|
|
254
|
+
* one an application-side role reaches. The soft delete's stamp and destroy's DELETE run as the
|
|
255
|
+
* lifecycle role, so an INVOKER trigger function runs as it and a DEFINER one as its owner; either
|
|
256
|
+
* way whoever can replace the body acts inside every soft delete and destroy. So the security
|
|
257
|
+
* mode does not matter, and neither the table's owner nor the lifecycle role is exempt.
|
|
253
258
|
*/
|
|
254
|
-
const
|
|
259
|
+
const triggerOwnerSql = (tables: string[], apps: string[]): string => `WITH ${sideCte(apps)}
|
|
260
|
+
SELECT n.nspname::text || '.' || c.relname::text AS "table", g.tgname::text AS trigger,
|
|
255
261
|
pg_catalog.quote_ident(fn.nspname) || '.' || pg_catalog.quote_ident(p.proname) || '(' || pg_catalog.pg_get_function_identity_arguments(p.oid) || ')' AS function,
|
|
256
|
-
pg_catalog.pg_get_userbyid(p.proowner)::text AS owner,
|
|
262
|
+
pg_catalog.pg_get_userbyid(p.proowner)::text AS owner,
|
|
263
|
+
ARRAY(SELECT s.rolname FROM side s WHERE s.oid = p.proowner OR ${reaches('s.oid', 'p.proowner')} ORDER BY 1) AS via
|
|
257
264
|
FROM pg_catalog.pg_trigger g JOIN pg_catalog.pg_class c ON c.oid = g.tgrelid
|
|
258
265
|
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace JOIN pg_catalog.pg_proc p ON p.oid = g.tgfoid
|
|
259
266
|
JOIN pg_catalog.pg_namespace fn ON fn.oid = p.pronamespace
|
|
260
267
|
WHERE NOT g.tgisinternal AND (n.nspname::text || '.' || c.relname::text) = ANY(${textArray(tables)})
|
|
261
|
-
AND
|
|
262
|
-
AND p.proowner IS DISTINCT FROM (SELECT oid FROM pg_catalog.pg_roles WHERE rolname = ${literal(lc)})
|
|
268
|
+
AND EXISTS (SELECT 1 FROM side s WHERE s.oid = p.proowner OR ${reaches('s.oid', 'p.proowner')})
|
|
263
269
|
ORDER BY 1, 2`;
|
|
264
270
|
|
|
271
|
+
/**
|
|
272
|
+
* One rule-5 finding. `via` holds the application-side roles that are, or reach, `owner`. When the
|
|
273
|
+
* owner is one of them the fix is a new owner; otherwise it is to revoke the path to the owner.
|
|
274
|
+
* `would`: the pre-exposure gate, which judges a declared trigger before the reconcile creates it.
|
|
275
|
+
*/
|
|
276
|
+
export function triggerOwnerFinding(row: { trigger: string; table: string; function: string; owner: string; via: readonly string[] }, would = false): string {
|
|
277
|
+
const { owner, via } = row;
|
|
278
|
+
const sideOwned = via.includes(owner);
|
|
279
|
+
const who = sideOwned ? `${owner}, an application-side role`
|
|
280
|
+
: `${owner}, which the application-side role${via.length === 1 ? '' : 's'} ${via.join(', ')} can assume`;
|
|
281
|
+
const fix = sideOwned
|
|
282
|
+
? `declare ${row.function}'s owner in db/models as a role no application role can act as, and apply `
|
|
283
|
+
+ `(the apply re-creates a declared function with its declared owner, so a live ALTER alone is undone); `
|
|
284
|
+
+ `for a function not declared in db/models: ALTER FUNCTION ${row.function} OWNER TO <that role>;`
|
|
285
|
+
: `${via.map(r => `REVOKE ${ident(owner)} FROM ${ident(r)};`).join(' ')} (or from the role each reaches ${owner} through)`;
|
|
286
|
+
return `trigger ${row.trigger} on ${row.table} ${would ? 'would run' : 'runs'} ${row.function}, owned by ${who}; `
|
|
287
|
+
+ `${via.join(', ')} can replace its body, and it runs inside every soft delete and destroy. Fix: ${fix}`;
|
|
288
|
+
}
|
|
289
|
+
|
|
265
290
|
/**
|
|
266
291
|
* Rule 6. Every foreign key into a lifecycle table (a partition's clone excluded), with its columns
|
|
267
292
|
* in key order and its ON DELETE action. Which ones a model declares is decided by the caller.
|
|
@@ -298,16 +323,32 @@ const OWNER_HAZARD = {
|
|
|
298
323
|
*
|
|
299
324
|
* The one implementation: db:apply, db:sync and db:plan reach it through lifecycleApplyRefusal
|
|
300
325
|
* (inputs from the models); db:reconcile, local and in the ops Lambda, through
|
|
301
|
-
* lifecycleReconcileRefusal (tables from the declared transition triggers)
|
|
326
|
+
* lifecycleReconcileRefusal (tables from the declared transition triggers); db:apply and db:build
|
|
327
|
+
* again after the derived layer lands, through lifecyclePostApplyRefusal.
|
|
302
328
|
*/
|
|
303
329
|
export async function lifecycleCatalogRefusal(
|
|
304
330
|
runner: QueryRunner,
|
|
305
331
|
inputs: LifecycleCatalogInputs,
|
|
306
332
|
options: { lifecycleRole?: string } = {},
|
|
307
333
|
): Promise<string | null> {
|
|
334
|
+
const findings = await lifecycleCatalogFindings(runner, inputs, options);
|
|
335
|
+
if (findings.length === 0) return null;
|
|
336
|
+
return `the live catalog breaks the lifecycle soft delete (${findings.length} finding${findings.length === 1 ? '' : 's'}); `
|
|
337
|
+
+ `fix each, then re-run:\n${findingLines(findings)}`;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const findingLines = (findings: readonly string[]): string => findings.map(finding => ` - ${finding}`).join('\n');
|
|
341
|
+
const countFindings = (n: number): string => `${n} lifecycle finding${n === 1 ? '' : 's'}`;
|
|
342
|
+
|
|
343
|
+
/** lifecycleCatalogRefusal's findings, one string each, without the heading. */
|
|
344
|
+
async function lifecycleCatalogFindings(
|
|
345
|
+
runner: QueryRunner,
|
|
346
|
+
inputs: LifecycleCatalogInputs,
|
|
347
|
+
options: { lifecycleRole?: string } = {},
|
|
348
|
+
): Promise<string[]> {
|
|
308
349
|
const lc = options.lifecycleRole ?? LIFECYCLE_OWNER_ROLE;
|
|
309
350
|
const tables = [...new Set(inputs.lifecycleTables)].sort();
|
|
310
|
-
if (tables.length === 0) return
|
|
351
|
+
if (tables.length === 0) return [];
|
|
311
352
|
const apps = [...new Set(inputs.applicationRoles)].filter(notApplicationRole(lc)).sort();
|
|
312
353
|
const findings: string[] = [];
|
|
313
354
|
|
|
@@ -371,10 +412,9 @@ export async function lifecycleCatalogRefusal(
|
|
|
371
412
|
}
|
|
372
413
|
}
|
|
373
414
|
|
|
374
|
-
for (const row of await runner(
|
|
375
|
-
findings.push(
|
|
376
|
-
|
|
377
|
-
+ `Fix: ALTER FUNCTION ${row.function} SECURITY INVOKER; or ALTER FUNCTION ${row.function} OWNER TO ${ident(String(row.table_owner))};`);
|
|
415
|
+
for (const row of await runner(triggerOwnerSql(tables, apps))) {
|
|
416
|
+
findings.push(triggerOwnerFinding({ trigger: String(row.trigger), table: String(row.table), function: String(row.function),
|
|
417
|
+
owner: String(row.owner), via: textList(row.via) }));
|
|
378
418
|
}
|
|
379
419
|
|
|
380
420
|
const declared = new Set(inputs.dependentKeys.map(key => JSON.stringify([key.child, key.column, key.parent])));
|
|
@@ -403,9 +443,88 @@ export async function lifecycleCatalogRefusal(
|
|
|
403
443
|
+ 'if the models declare this key nullify, upgrade the CLI (@everystack/cli) and re-run.' : ''));
|
|
404
444
|
}
|
|
405
445
|
|
|
446
|
+
return findings;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* A lifecycle finding raised around the derived layer: by the pre-exposure gate (nothing derived
|
|
451
|
+
* was written) or by the post-check (the objects are live). Its message is the whole report, one
|
|
452
|
+
* line of head and one line per finding, so a caller prints it as is.
|
|
453
|
+
*/
|
|
454
|
+
export class LifecycleFindingsError extends Error {}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* The pre-exposure gate: rule 5 over the DECLARED owners, before the reconcile creates anything.
|
|
458
|
+
* Each declared trigger on a lifecycle table whose function is declared is judged by the owner the
|
|
459
|
+
* reconcile will give that function (reconcileOwner: the declared owner, else the role the
|
|
460
|
+
* reconcile runs as). A declared owner that does not exist live is a finding. A trigger whose
|
|
461
|
+
* function is not declared is left to the post-check, which reads the live owner.
|
|
462
|
+
*
|
|
463
|
+
* `session` reads the builder: it runs on the connection the reconcile writes on. On a stage that
|
|
464
|
+
* is db:session, the operator connection; db:query (`runner`) may run on a reader
|
|
465
|
+
* (QUERY_DATABASE_URL), whose CURRENT_USER is not the role that will own the function. `runner`
|
|
466
|
+
* reads only pg_roles, which is the same on any connection.
|
|
467
|
+
*
|
|
468
|
+
* `stateApplied`: the table and column changes have committed, so the refusal says so.
|
|
469
|
+
*/
|
|
470
|
+
export async function lifecycleDeclaredOwnerRefusal(
|
|
471
|
+
runner: QueryRunner,
|
|
472
|
+
session: SessionRunner,
|
|
473
|
+
declared: readonly SourceObject[],
|
|
474
|
+
models: readonly ModelDescriptor[],
|
|
475
|
+
options: { stateApplied: boolean },
|
|
476
|
+
): Promise<string | null> {
|
|
477
|
+
const { lifecycleTables, applicationRoles } = catalogInputsFromModels(models);
|
|
478
|
+
const tables = new Set(lifecycleTables);
|
|
479
|
+
const triggers = declared.flatMap((object) => {
|
|
480
|
+
if (object.kind !== 'trigger' || !object.table) return [];
|
|
481
|
+
const { schema, name } = parseQualified(object.table);
|
|
482
|
+
const fn = tables.has(`${schema}.${name}`) ? triggerFunctionOf(object, declared) : undefined;
|
|
483
|
+
return fn ? [{ trigger: object.name, table: `${schema}.${name}`, fn }] : [];
|
|
484
|
+
});
|
|
485
|
+
if (triggers.length === 0) return null;
|
|
486
|
+
const [slot] = await session(['SELECT CURRENT_USER::text AS builder']);
|
|
487
|
+
if (isSessionError(slot) || slot?.[0]?.builder == null) throw new Error('could not read the role the reconcile runs as (CURRENT_USER)');
|
|
488
|
+
const builder = String(slot[0].builder);
|
|
489
|
+
const judged = triggers.map(t => ({ ...t, owner: reconcileOwner(t.fn, builder) }));
|
|
490
|
+
const owners = [...new Set(judged.map(t => t.owner))].sort();
|
|
491
|
+
const rows = await runner(`WITH ${sideCte(applicationRoles)}
|
|
492
|
+
SELECT d.name AS owner, o.oid IS NOT NULL AS present,
|
|
493
|
+
ARRAY(SELECT s.rolname FROM side s WHERE s.oid = o.oid OR ${reaches('s.oid', 'o.oid')} ORDER BY 1) AS via
|
|
494
|
+
FROM pg_catalog.unnest(${textArray(owners)}) AS d(name) LEFT JOIN pg_catalog.pg_roles o ON o.rolname = d.name`);
|
|
495
|
+
const byOwner = new Map(rows.map(row => [String(row.owner), { present: truthy(row.present), via: textList(row.via) }]));
|
|
496
|
+
const findings = judged.flatMap((t) => {
|
|
497
|
+
const owner = byOwner.get(t.owner);
|
|
498
|
+
if (!owner?.present) {
|
|
499
|
+
return [`trigger ${t.trigger} on ${t.table} would run ${t.fn.identity}, owned by ${t.owner}, which does not exist. `
|
|
500
|
+
+ `Fix: create ${t.owner} as a role no application role can act as, or declare an existing such role as ${t.fn.identity}'s owner in db/models.`];
|
|
501
|
+
}
|
|
502
|
+
if (owner.via.length === 0) return [];
|
|
503
|
+
return [triggerOwnerFinding({ trigger: t.trigger, table: t.table, function: t.fn.identity, owner: t.owner, via: owner.via }, true)];
|
|
504
|
+
});
|
|
505
|
+
if (findings.length === 0) return null;
|
|
506
|
+
return `${options.stateApplied ? 'tables and columns applied; ' : ''}derived layer NOT applied: ${countFindings(findings.length)}; `
|
|
507
|
+
+ `fix each, then re-run:\n${findingLines(findings)}`;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* The post-check: the live catalog check again, after the derived layer landed. The reconcile
|
|
512
|
+
* commits what it created, so the report never says refused or rolled back: the objects are live,
|
|
513
|
+
* and the next apply's preflight refuses until each finding is fixed.
|
|
514
|
+
*
|
|
515
|
+
* It reads on `session`, the reconcile's own connection: on a stage, db:session (the operator
|
|
516
|
+
* connection). db:query may read a replica the reconcile's commit has not reached yet, and pass.
|
|
517
|
+
*/
|
|
518
|
+
export async function lifecyclePostApplyRefusal(session: SessionRunner, models: readonly ModelDescriptor[]): Promise<string | null> {
|
|
519
|
+
if (!models.some(model => model.lifecycle === true)) return null;
|
|
520
|
+
const read: QueryRunner = async (q) => {
|
|
521
|
+
const [slot] = await session([q]);
|
|
522
|
+
if (isSessionError(slot)) throw new Error(slot.message);
|
|
523
|
+
return slot ?? [];
|
|
524
|
+
};
|
|
525
|
+
const findings = await lifecycleCatalogFindings(read, catalogInputsFromModels(models));
|
|
406
526
|
if (findings.length === 0) return null;
|
|
407
|
-
return
|
|
408
|
-
+ `fix each, then re-run:\n${findings.map(finding => ` - ${finding}`).join('\n')}`;
|
|
527
|
+
return `${countFindings(findings.length)} after reconcile; the objects are live; the next apply refuses until fixed:\n${findingLines(findings)}`;
|
|
409
528
|
}
|
|
410
529
|
|
|
411
530
|
/** An older CLI sends no applicationRoles, so the catalog check cannot run: fail closed. */
|