@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
|
@@ -31,7 +31,8 @@ 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 {
|
|
34
|
+
import { catalogInputsFromModels, lifecycleApplyRefusal } from '../lifecycle-preflight';
|
|
35
|
+
import { introspectSchema, type SchemaSnapshot } from '../schema-introspect';
|
|
35
36
|
import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog';
|
|
36
37
|
import { generateMigrationSql, unmodeledTables } from '../migration-generate';
|
|
37
38
|
import type { SequenceDescriptor } from '@everystack/model';
|
|
@@ -112,6 +113,18 @@ export interface SyncRun {
|
|
|
112
113
|
converged: boolean;
|
|
113
114
|
}
|
|
114
115
|
|
|
116
|
+
/** The live keys into a lifecycle model whose drop the generated statements hold back, as `<constraint> on <table> references lifecycle table <parent>`. */
|
|
117
|
+
function heldLifecycleKeyDrops(heldDrops: readonly string[], current: SchemaSnapshot, models: readonly ModelDescriptor[]): string[] {
|
|
118
|
+
if (heldDrops.length === 0) return [];
|
|
119
|
+
const lifecycle = new Set(models.filter(model => model.lifecycle === true).map(model => `${model.schema}.${model.table}`));
|
|
120
|
+
const qualify = (name: string): string => (name.includes('.') ? name : `public.${name}`);
|
|
121
|
+
const escape = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
122
|
+
return current.tables.flatMap(table => table.foreignKeys
|
|
123
|
+
.filter(fk => fk.name && lifecycle.has(qualify(fk.refTable))
|
|
124
|
+
&& heldDrops.some(held => new RegExp(`DROP CONSTRAINT (IF EXISTS )?"?${escape(fk.name!)}"?;`).test(held)))
|
|
125
|
+
.map(fk => `${fk.name} on ${qualify(table.table)} references lifecycle table ${qualify(fk.refTable)}`));
|
|
126
|
+
}
|
|
127
|
+
|
|
115
128
|
/**
|
|
116
129
|
* One sync: state first (derived objects may depend on new columns), then
|
|
117
130
|
* compute, then the verdict. The exception is the declared functions the
|
|
@@ -132,13 +145,23 @@ async function syncOnce(
|
|
|
132
145
|
allowDrops: options.allowDrops, liveAuthz, sequences: options.sequences,
|
|
133
146
|
governedRoles: options.governedRoles, extensions: options.extensions,
|
|
134
147
|
});
|
|
135
|
-
|
|
148
|
+
const classified = classifyGeneratedStatements(statements);
|
|
149
|
+
hooks.onStatePlan?.(statements, classified, pass);
|
|
150
|
+
// A held drop of a key into a lifecycle table leaves a key the after-state catalog check refuses,
|
|
151
|
+
// after the rest of the state has landed and before any lifecycle function is installed. Refuse
|
|
152
|
+
// here instead, before anything is written.
|
|
153
|
+
const heldKeys = heldLifecycleKeyDrops(classified.heldDrops, current, models);
|
|
154
|
+
if (heldKeys.length > 0) {
|
|
155
|
+
throw new Error(`db:sync would hold back the drop of ${heldKeys.length === 1 ? 'a foreign key' : 'foreign keys'} into a lifecycle table, `
|
|
156
|
+
+ `and the lifecycle catalog check refuses ${heldKeys.length === 1 ? 'it' : 'them'} after the state apply: ${heldKeys.join('; ')}. `
|
|
157
|
+
+ 'Nothing was written. Only a lifecycle table may hold a key into one; re-run with --allow-drops to drop it.');
|
|
158
|
+
}
|
|
136
159
|
|
|
137
160
|
// The one derived → state edge: a declared function a pending policy (or default, or check)
|
|
138
161
|
// calls must exist before that statement is created. Only those functions, only when absent.
|
|
139
162
|
if (options.declared?.length) {
|
|
140
163
|
await createStateReferencedFunctions(runner, session, statements, options.declared, {
|
|
141
|
-
actor: options.actor, gitRef: options.gitRef, now: options.now,
|
|
164
|
+
actor: options.actor, gitRef: options.gitRef, now: options.now, lifecyclePreflighted: true,
|
|
142
165
|
});
|
|
143
166
|
}
|
|
144
167
|
|
|
@@ -157,6 +180,9 @@ async function syncOnce(
|
|
|
157
180
|
declared: options.declared,
|
|
158
181
|
renamedTables: options.renamedTables,
|
|
159
182
|
actor: options.actor, gitRef: options.gitRef, now: options.now,
|
|
183
|
+
// The catalog check runs again here, after the state apply: a table this sync just created can
|
|
184
|
+
// carry a finding (a default privilege, its owner) the check before the state apply could not see.
|
|
185
|
+
applicationRoles: catalogInputsFromModels(models).applicationRoles,
|
|
160
186
|
});
|
|
161
187
|
|
|
162
188
|
const declaredFingerprint = fingerprintModels(models, { sequences: options.sequences }).hash;
|
|
@@ -213,6 +239,9 @@ export async function executeSync(
|
|
|
213
239
|
options: SyncOptions = {},
|
|
214
240
|
hooks: SyncHooks = {},
|
|
215
241
|
): Promise<SyncRun> {
|
|
242
|
+
// State runs before compute, so without this a lifecycle table would land with no transition trigger.
|
|
243
|
+
const lifecycleRefusal = await lifecycleApplyRefusal(runner, models);
|
|
244
|
+
if (lifecycleRefusal) throw new Error(lifecycleRefusal);
|
|
216
245
|
const first = await syncOnce(runner, session, models, options, hooks, 1);
|
|
217
246
|
// The trigger is the STATE bar, not the combined verdict. The cause above is state-layer
|
|
218
247
|
// only, so a compute refusal or an unadopted object cannot be settled by looking again —
|
package/src/cli/commands/db.ts
CHANGED
|
@@ -98,6 +98,28 @@ export function pgKeepaliveConninfo(dbName?: string): string {
|
|
|
98
98
|
return parts.join(' ');
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
/**
|
|
102
|
+
* The verdict for a fire-and-forget ops-Lambda action (migrate, reset, seed) — DB-free. The one
|
|
103
|
+
* guarantee it holds: a result carrying `error` is a FAILURE and never reports success. Extracted
|
|
104
|
+
* from the dispatcher shells so the "error never reports success" boundary is asserted by a test
|
|
105
|
+
* (closing-gate F1 method); the shell still owns the IO, the exit code, and the thrown-error path.
|
|
106
|
+
*/
|
|
107
|
+
export type OpsActionVerdict =
|
|
108
|
+
| { ok: true; message: string; warnings: string[] }
|
|
109
|
+
| { ok: false; message: string };
|
|
110
|
+
|
|
111
|
+
export function opsActionResultVerdict(
|
|
112
|
+
result: { error?: unknown; message?: unknown; warnings?: unknown } | null | undefined,
|
|
113
|
+
opts: { failPrefix: string; successFallback: string },
|
|
114
|
+
): OpsActionVerdict {
|
|
115
|
+
if (result?.error) return { ok: false, message: `${opts.failPrefix}: ${result.error}` };
|
|
116
|
+
return {
|
|
117
|
+
ok: true,
|
|
118
|
+
message: typeof result?.message === 'string' && result.message ? result.message : opts.successFallback,
|
|
119
|
+
warnings: Array.isArray(result?.warnings) ? (result!.warnings as string[]) : [],
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
101
123
|
export async function dbMigrateCommand(flags: Record<string, string>): Promise<void> {
|
|
102
124
|
step('Resolving deployed config...');
|
|
103
125
|
let config;
|
|
@@ -118,14 +140,15 @@ export async function dbMigrateCommand(flags: Record<string, string>): Promise<v
|
|
|
118
140
|
'migrate',
|
|
119
141
|
{},
|
|
120
142
|
);
|
|
121
|
-
|
|
122
|
-
|
|
143
|
+
const verdict = opsActionResultVerdict(result, { failPrefix: 'Migration failed', successFallback: 'Migrations applied' });
|
|
144
|
+
if (!verdict.ok) {
|
|
145
|
+
fail(verdict.message);
|
|
123
146
|
process.exit(1);
|
|
124
147
|
}
|
|
125
|
-
success(
|
|
148
|
+
success(verdict.message);
|
|
126
149
|
// Journal drift warnings — a migration file the journal doesn't list
|
|
127
150
|
// will silently never apply. Never swallow these.
|
|
128
|
-
for (const warning of
|
|
151
|
+
for (const warning of verdict.warnings) {
|
|
129
152
|
warn(warning);
|
|
130
153
|
}
|
|
131
154
|
} catch (err: any) {
|
|
@@ -192,11 +215,12 @@ export async function dbResetCommand(flags: Record<string, string>): Promise<voi
|
|
|
192
215
|
'db:reset',
|
|
193
216
|
{},
|
|
194
217
|
);
|
|
195
|
-
|
|
196
|
-
|
|
218
|
+
const verdict = opsActionResultVerdict(result, { failPrefix: 'Reset failed', successFallback: 'Database reset and migrations applied' });
|
|
219
|
+
if (!verdict.ok) {
|
|
220
|
+
fail(verdict.message);
|
|
197
221
|
process.exit(1);
|
|
198
222
|
}
|
|
199
|
-
success(
|
|
223
|
+
success(verdict.message);
|
|
200
224
|
} catch (err: any) {
|
|
201
225
|
fail(`Reset failed: ${err.message}`);
|
|
202
226
|
for (const line of opsAdviceLines(err, [IAM_ADVICE])) info(line);
|
|
@@ -1382,10 +1406,10 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
1382
1406
|
}
|
|
1383
1407
|
|
|
1384
1408
|
/** Parse the explicit day window once, before choosing a connection venue. */
|
|
1385
|
-
export function
|
|
1409
|
+
export function destroyAfterMilliseconds(days: string | undefined): number | undefined {
|
|
1386
1410
|
if (days === undefined) return undefined;
|
|
1387
1411
|
const milliseconds = Number(days) * 86400000;
|
|
1388
|
-
if (!Number.isFinite(milliseconds) || milliseconds <= 0) throw new Error('--
|
|
1412
|
+
if (!Number.isFinite(milliseconds) || milliseconds <= 0) throw new Error('--destroy-after needs a positive number of days');
|
|
1389
1413
|
return milliseconds;
|
|
1390
1414
|
}
|
|
1391
1415
|
|
|
@@ -1397,8 +1421,8 @@ async function doctorOverdueRows(
|
|
|
1397
1421
|
if (after === undefined) return undefined;
|
|
1398
1422
|
const { loadModels } = await import('./db-generate');
|
|
1399
1423
|
const { resolveModelsPath } = await import('../models-path');
|
|
1400
|
-
const spec = '@everystack/server/
|
|
1401
|
-
const { overdueLifecycleRows } = await import(spec) as typeof import('@everystack/server/
|
|
1424
|
+
const spec = '@everystack/server/destroy';
|
|
1425
|
+
const { overdueLifecycleRows } = await import(spec) as typeof import('@everystack/server/destroy');
|
|
1402
1426
|
const { PgDialect } = await import('drizzle-orm/pg-core');
|
|
1403
1427
|
const dialect = new PgDialect();
|
|
1404
1428
|
const models = await loadModels(resolveModelsPath(modelsPath));
|
|
@@ -1420,10 +1444,11 @@ export function printDoctorReport(report: any): void {
|
|
|
1420
1444
|
}
|
|
1421
1445
|
|
|
1422
1446
|
console.log('');
|
|
1423
|
-
if (report.overdueLifecycleRows === undefined) info('Lifecycle
|
|
1447
|
+
if (report.overdueLifecycleRows === undefined) info('Lifecycle destroy: skipped; pass --destroy-after <days> to count overdue rows');
|
|
1424
1448
|
else {
|
|
1425
|
-
info('Lifecycle
|
|
1449
|
+
info('Lifecycle destroy: overdue rows');
|
|
1426
1450
|
for (const [table, count] of Object.entries(report.overdueLifecycleRows)) info(` ${table}: ${count}`);
|
|
1451
|
+
info(' Why roots are skipped is reported by destroyExpired (see the destroy cron log); doctor counts overdue rows only.');
|
|
1427
1452
|
}
|
|
1428
1453
|
console.log('');
|
|
1429
1454
|
if (report.ok) {
|
|
@@ -1453,7 +1478,7 @@ export function printDoctorReport(report: any): void {
|
|
|
1453
1478
|
* forceRlsCarveouts/functionSchemas are unknown to the CLI, so the functions teeth run at
|
|
1454
1479
|
* their safe default (unpinned SECDEF warns rather than fails). Returns report.ok.
|
|
1455
1480
|
*/
|
|
1456
|
-
async function runDoctorDirect(apiUrl: string, adminUrl: string, sourceLabel: string,
|
|
1481
|
+
async function runDoctorDirect(apiUrl: string, adminUrl: string, sourceLabel: string, destroyAfter: number | undefined, modelsPath: string | undefined): Promise<boolean> {
|
|
1457
1482
|
const { runDoctorProbe } = await loadServerDoctor();
|
|
1458
1483
|
const { createUrlRunner } = await import('../db-source');
|
|
1459
1484
|
step('Probing database connections (api + operator)...');
|
|
@@ -1464,7 +1489,7 @@ async function runDoctorDirect(apiUrl: string, adminUrl: string, sourceLabel: st
|
|
|
1464
1489
|
{ api: apiConn.runner, ops: opsConn.runner },
|
|
1465
1490
|
{ apiSourceLabel: sourceLabel },
|
|
1466
1491
|
);
|
|
1467
|
-
const overdueLifecycleRows = await doctorOverdueRows(
|
|
1492
|
+
const overdueLifecycleRows = await doctorOverdueRows(destroyAfter, modelsPath, opsConn.runner);
|
|
1468
1493
|
printDoctorReport({ ...report, overdueLifecycleRows });
|
|
1469
1494
|
return report.ok;
|
|
1470
1495
|
} finally {
|
|
@@ -1474,9 +1499,9 @@ async function runDoctorDirect(apiUrl: string, adminUrl: string, sourceLabel: st
|
|
|
1474
1499
|
}
|
|
1475
1500
|
|
|
1476
1501
|
export async function dbDoctorCommand(flags: Record<string, string>): Promise<void> {
|
|
1477
|
-
let
|
|
1502
|
+
let destroyAfter: number | undefined;
|
|
1478
1503
|
try {
|
|
1479
|
-
|
|
1504
|
+
destroyAfter = destroyAfterMilliseconds(flags['destroy-after']);
|
|
1480
1505
|
} catch (error) {
|
|
1481
1506
|
fail(error instanceof Error ? error.message : String(error));
|
|
1482
1507
|
process.exit(1);
|
|
@@ -1495,7 +1520,7 @@ export async function dbDoctorCommand(flags: Record<string, string>): Promise<vo
|
|
|
1495
1520
|
info('venue: direct connection (explicit --database-url)');
|
|
1496
1521
|
let ok = false;
|
|
1497
1522
|
try {
|
|
1498
|
-
ok = await runDoctorDirect(venue!.apiUrl, venue!.adminUrl, '--database-url',
|
|
1523
|
+
ok = await runDoctorDirect(venue!.apiUrl, venue!.adminUrl, '--database-url', destroyAfter, flags.models);
|
|
1499
1524
|
} catch (err: any) {
|
|
1500
1525
|
fail(`db:doctor failed: ${err.message}`);
|
|
1501
1526
|
process.exit(1);
|
|
@@ -1535,7 +1560,7 @@ export async function dbDoctorCommand(flags: Record<string, string>): Promise<vo
|
|
|
1535
1560
|
info(`Region: ${config.region}, venue: direct connection (stage secrets)`);
|
|
1536
1561
|
let ok = false;
|
|
1537
1562
|
try {
|
|
1538
|
-
ok = await runDoctorDirect(venue!.apiUrl, venue!.adminUrl, 'DATABASE_URL secret',
|
|
1563
|
+
ok = await runDoctorDirect(venue!.apiUrl, venue!.adminUrl, 'DATABASE_URL secret', destroyAfter, flags.models);
|
|
1539
1564
|
} catch (err: any) {
|
|
1540
1565
|
fail(`db:doctor failed: ${err.message}`);
|
|
1541
1566
|
process.exit(1);
|
|
@@ -1560,7 +1585,7 @@ export async function dbDoctorCommand(flags: Record<string, string>): Promise<vo
|
|
|
1560
1585
|
info('No Ops Lambda (the deployed handler has no dbPlugin) — verifying directly over the stage secrets.');
|
|
1561
1586
|
let ok = false;
|
|
1562
1587
|
try {
|
|
1563
|
-
ok = await runDoctorDirect(venue.apiUrl, venue.adminUrl, 'DATABASE_URL secret',
|
|
1588
|
+
ok = await runDoctorDirect(venue.apiUrl, venue.adminUrl, 'DATABASE_URL secret', destroyAfter, flags.models);
|
|
1564
1589
|
} catch (err: any) {
|
|
1565
1590
|
fail(`db:doctor failed: ${err.message}`);
|
|
1566
1591
|
process.exit(1);
|
|
@@ -1590,7 +1615,7 @@ export async function dbDoctorCommand(flags: Record<string, string>): Promise<vo
|
|
|
1590
1615
|
|
|
1591
1616
|
const { lambdaSessionRunner } = await import('../aws');
|
|
1592
1617
|
const session = lambdaSessionRunner(config.region, opsFunction(config));
|
|
1593
|
-
report.overdueLifecycleRows = await doctorOverdueRows(
|
|
1618
|
+
report.overdueLifecycleRows = await doctorOverdueRows(destroyAfter, flags.models, async query => {
|
|
1594
1619
|
const [rows] = await session([query], { readOnly: true, searchPath: 'pg_catalog' });
|
|
1595
1620
|
if (!Array.isArray(rows)) throw new Error('overdue lifecycle count failed');
|
|
1596
1621
|
return rows;
|
package/src/cli/db-build.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* state lands EXACTLY on the models' fingerprint.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import type
|
|
13
|
+
import { ENSURE_DESTROY_LOG_SQL, type ModelDescriptor } from '@everystack/model';
|
|
14
14
|
import type { TableContract, QueryRunner } from './authz-contract';
|
|
15
15
|
import type { SourceObject } from './derived-source';
|
|
16
16
|
import type { SequenceDescriptor } from '@everystack/model';
|
|
@@ -18,6 +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
22
|
|
|
22
23
|
const SAFE_NAME = /^[a-z_][a-z0-9_$]*$/;
|
|
23
24
|
|
|
@@ -143,6 +144,9 @@ export async function buildIntoDatabase(
|
|
|
143
144
|
// legacy apps do create objects in `public` at runtime.
|
|
144
145
|
await runner('REVOKE CREATE ON SCHEMA public FROM PUBLIC');
|
|
145
146
|
const createdRoles = await ensureContractRoles(runner, models);
|
|
147
|
+
if (models.some(model => model.lifecycle)) {
|
|
148
|
+
for (const statement of ENSURE_DESTROY_LOG_SQL) await runner(statement);
|
|
149
|
+
}
|
|
146
150
|
|
|
147
151
|
// FUNCTIONS BEFORE STATE. An RLS policy's predicate is resolved when the policy is
|
|
148
152
|
// created, so `USING (user_id = auth.user_id())` cannot be created before
|
|
@@ -180,11 +184,17 @@ export async function buildIntoDatabase(
|
|
|
180
184
|
const declaredFunctions = (options.declared ?? [])
|
|
181
185
|
.filter((o) => o.kind === 'function' && !o.signatureDeps?.length);
|
|
182
186
|
if (declaredFunctions.length > 0) {
|
|
187
|
+
// The lifecycle catalog check, with the full models, before any lifecycle-owned function
|
|
188
|
+
// lands: this pass declares functions only, so it carries no transition trigger for the
|
|
189
|
+
// reconcile to read the lifecycle tables from. executeSync runs the same check again below.
|
|
190
|
+
const lifecycleRefusal = await lifecycleApplyRefusal(runner, models);
|
|
191
|
+
if (lifecycleRefusal) throw new Error(lifecycleRefusal);
|
|
183
192
|
await runner('SET check_function_bodies = off');
|
|
184
193
|
try {
|
|
185
194
|
const pre = await executeReconcile(runner, session, {
|
|
186
195
|
declared: declaredFunctions,
|
|
187
196
|
only: declaredFunctions.map(object => object.identity),
|
|
197
|
+
lifecyclePreflighted: true,
|
|
188
198
|
apply: true,
|
|
189
199
|
actor: options.actor ?? 'db-build',
|
|
190
200
|
gitRef: options.gitRef ?? null,
|
package/src/cli/derived-apply.ts
CHANGED
|
@@ -607,7 +607,11 @@ export function renderReconcileSql(plan: ReconcilePlan, source: SourceObject[],
|
|
|
607
607
|
// The new kinds carry their drop on the action (triggers: composed from structure;
|
|
608
608
|
// 'sql' objects: the recorded/declared drop_sql) — the legacy kinds keep the
|
|
609
609
|
// keyword rendering they always had.
|
|
610
|
-
|
|
610
|
+
const dropSql = action.dropSql ?? `DROP ${DROP_KEYWORD[action.kind]} IF EXISTS ${dropTarget(action.kind, action.identity)}`;
|
|
611
|
+
// A rebuilt function with a declared owner drops AS that owner, as its create runs: DROP
|
|
612
|
+
// needs ownership, and an operator holding the owner WITH INHERIT FALSE, SET TRUE has none.
|
|
613
|
+
const owner = action.kind === 'function' && ownerMode === 'set-role' ? srcById.get(action.identity)?.owner : undefined;
|
|
614
|
+
statements.push(...(owner ? [`SET ROLE ${owner}`, dropSql, 'RESET ROLE'] : [dropSql]));
|
|
611
615
|
// A rebuild's drop is followed by its create; only a true removal loses provenance.
|
|
612
616
|
if (!plan.actions.some((a) => a.action === 'create' && a.identity === action.identity)) {
|
|
613
617
|
remove.push(action.identity);
|
|
@@ -35,6 +35,7 @@ import { MODEL_WIRE_CONTRACT, type ReconcileWirePayload } from '../wire';
|
|
|
35
35
|
import { resolveConfig, opsFunction } from './config';
|
|
36
36
|
import { invokeAction } from './aws';
|
|
37
37
|
import { currentGitRef } from './state-apply';
|
|
38
|
+
import { catalogInputsFromModels } from './lifecycle-preflight';
|
|
38
39
|
|
|
39
40
|
/** Where the catch-up runs. The two lanes db:apply itself already has. */
|
|
40
41
|
export type CatchUpVenue =
|
|
@@ -108,6 +109,9 @@ export async function catchUpDerived(
|
|
|
108
109
|
apply: true,
|
|
109
110
|
declared: objects,
|
|
110
111
|
renamedTables: declared?.renamedTables,
|
|
112
|
+
// The lifecycle catalog check's roles; the reconcile, here or in the ops Lambda, refuses
|
|
113
|
+
// lifecycle-owned objects without them.
|
|
114
|
+
applicationRoles: declared?.models ? catalogInputsFromModels(declared.models).applicationRoles : undefined,
|
|
111
115
|
actor: process.env.USER ?? null,
|
|
112
116
|
gitRef: currentGitRef(),
|
|
113
117
|
};
|
|
@@ -207,7 +207,8 @@ function renderFunction(fn: FunctionDescriptor): { sql: string; attachments: Att
|
|
|
207
207
|
const pin = Array.isArray(fn.searchPath) && fn.searchPath.length > 0
|
|
208
208
|
? ` SET search_path = ${fn.searchPath.join(', ')}`
|
|
209
209
|
: '';
|
|
210
|
-
const
|
|
210
|
+
const lockTimeout = fn.lockTimeout === undefined ? '' : ` SET lock_timeout = '${escapeLiteral(fn.lockTimeout)}'`;
|
|
211
|
+
const security = `${definer}${pin}${lockTimeout}`;
|
|
211
212
|
const tag = dollarTag(fn.body);
|
|
212
213
|
const signature = functionSignature(target, fn);
|
|
213
214
|
return {
|
|
@@ -112,8 +112,17 @@ export interface LiveObject {
|
|
|
112
112
|
volatility: string;
|
|
113
113
|
/** The pinned search_path value, when set ('pg_catalog, public'). */
|
|
114
114
|
searchPath?: string;
|
|
115
|
+
/** Function-scoped lock_timeout, when set. */
|
|
116
|
+
lockTimeout?: string;
|
|
115
117
|
/** prosrc — the raw body. */
|
|
116
118
|
src: string;
|
|
119
|
+
/**
|
|
120
|
+
* Every object pg_depend records as depending on this function (normal and auto edges), as
|
|
121
|
+
* pg_describe_object prints it: a view's rule, a trigger, another function's argument default,
|
|
122
|
+
* a column default, a policy. What a DROP FUNCTION without CASCADE would refuse over. Absent
|
|
123
|
+
* when not measured (an older read or fixture).
|
|
124
|
+
*/
|
|
125
|
+
dependents?: string[];
|
|
117
126
|
};
|
|
118
127
|
}
|
|
119
128
|
|
|
@@ -241,7 +250,14 @@ SELECT
|
|
|
241
250
|
(pg_get_userbyid(p.proowner) IN (CURRENT_USER, SESSION_USER)) AS owner_is_connected_role,
|
|
242
251
|
(SELECT split_part(cfg, '=', 2) FROM unnest(COALESCE(p.proconfig, '{}'::text[])) cfg
|
|
243
252
|
WHERE cfg LIKE 'search_path=%' LIMIT 1) AS search_path,
|
|
244
|
-
p.
|
|
253
|
+
(SELECT split_part(cfg, '=', 2) FROM unnest(COALESCE(p.proconfig, '{}'::text[])) cfg
|
|
254
|
+
WHERE cfg LIKE 'lock_timeout=%' LIMIT 1) AS lock_timeout,
|
|
255
|
+
p.prosrc AS src,
|
|
256
|
+
ARRAY(
|
|
257
|
+
SELECT pg_describe_object(d.classid, d.objid, d.objsubid) FROM pg_depend d
|
|
258
|
+
WHERE d.refclassid = 'pg_proc'::regclass AND d.refobjid = p.oid AND d.deptype IN ('n', 'a')
|
|
259
|
+
ORDER BY 1
|
|
260
|
+
) AS dependents
|
|
245
261
|
FROM pg_proc p
|
|
246
262
|
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
247
263
|
JOIN pg_language l ON l.oid = p.prolang
|
|
@@ -550,6 +566,9 @@ export interface FunctionRow {
|
|
|
550
566
|
secdef?: unknown;
|
|
551
567
|
volatility?: unknown;
|
|
552
568
|
search_path?: unknown;
|
|
569
|
+
lock_timeout?: unknown;
|
|
570
|
+
/** See LiveObject.fn.dependents. Optional: absent means not measured. */
|
|
571
|
+
dependents?: unknown;
|
|
553
572
|
/** `pg_get_userbyid(proowner)` — see LiveObject.owner. Optional: pre-owner fixtures omit it. */
|
|
554
573
|
owner?: unknown;
|
|
555
574
|
/** See LiveObject.ownerIsSuperuser. Optional: absent means not measured. */
|
|
@@ -743,7 +762,9 @@ export function assembleDerivedCatalog(rows: DerivedRows): DerivedCatalog {
|
|
|
743
762
|
secdef: row.secdef === true || row.secdef === 't' || row.secdef === 'true',
|
|
744
763
|
volatility: String(row.volatility ?? 'v'),
|
|
745
764
|
...(row.search_path != null && String(row.search_path) !== '' ? { searchPath: String(row.search_path) } : {}),
|
|
765
|
+
...(row.lock_timeout != null ? { lockTimeout: String(row.lock_timeout) } : {}),
|
|
746
766
|
src: String(row.src),
|
|
767
|
+
...(row.dependents !== undefined ? { dependents: pgTextArray(row.dependents) } : {}),
|
|
747
768
|
},
|
|
748
769
|
} : {}),
|
|
749
770
|
});
|
package/src/cli/derived-plan.ts
CHANGED
|
@@ -647,6 +647,29 @@ export function planReconcile(
|
|
|
647
647
|
}
|
|
648
648
|
}
|
|
649
649
|
|
|
650
|
+
// A replace whose RESULT type changes cannot be a CREATE OR REPLACE: PostgreSQL refuses it
|
|
651
|
+
// (42P13). Decided here, before any DDL: it becomes DROP + CREATE (the rebuild lane, so the
|
|
652
|
+
// create runs its owner block and attachments like a fresh build), and is refused, naming
|
|
653
|
+
// them, while anything depends on the function. The drop never cascades.
|
|
654
|
+
for (const id of [...fnReplace.keys()]) {
|
|
655
|
+
const src = srcById.get(id);
|
|
656
|
+
const liveObj = liveById.get(id);
|
|
657
|
+
const declared = src ? declaredResult(src.sql) : undefined;
|
|
658
|
+
const current = liveObj?.fn?.returns;
|
|
659
|
+
if (declared === undefined || current === undefined || sameResult(declared, current)) continue;
|
|
660
|
+
fnReplace.delete(id);
|
|
661
|
+
const change = `result type changes (live ${current}, declared ${declared})`;
|
|
662
|
+
const dependentsOf = [...new Set([...closureOf(id), ...(liveObj?.fn?.dependents ?? [])])].sort();
|
|
663
|
+
if (dependentsOf.length > 0) {
|
|
664
|
+
blocked.push({
|
|
665
|
+
identity: id,
|
|
666
|
+
reason: `${change}, which needs DROP + CREATE; refused while other objects depend on it: ${dependentsOf.join(', ')}. Drop or change them first; nothing is dropped with CASCADE`,
|
|
667
|
+
});
|
|
668
|
+
continue;
|
|
669
|
+
}
|
|
670
|
+
rebuild.set(id, `${change}: replaced by DROP + CREATE`);
|
|
671
|
+
}
|
|
672
|
+
|
|
650
673
|
// Matviews that call a replaced function hold stale rows — refresh them,
|
|
651
674
|
// unless the rebuild already recreates them fresh.
|
|
652
675
|
const refresh = new Map<string, string>();
|
|
@@ -773,3 +796,44 @@ export function planReconcile(
|
|
|
773
796
|
...(reenables.length ? { reenables: reenables.sort((a, b) => a.identity.localeCompare(b.identity)) } : {}),
|
|
774
797
|
};
|
|
775
798
|
}
|
|
799
|
+
|
|
800
|
+
/** The RETURNS clause of a function's CREATE statement, or undefined when it cannot be read. */
|
|
801
|
+
export function declaredResult(createSql: string): string | undefined {
|
|
802
|
+
const header = createSql.split(/\bAS\s+\$/i)[0];
|
|
803
|
+
const m = /\bRETURNS\s+([\s\S]+?)\s+LANGUAGE\s/i.exec(header);
|
|
804
|
+
return m ? m[1].trim() : undefined;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
const TYPE_ALIASES: Array<[RegExp, string]> = [
|
|
808
|
+
[/\btimestamptz\b/g, 'timestamp with time zone'],
|
|
809
|
+
[/\btimestamp\b(?!\s*\(?\d*\)?\s*with(out)?\s+time\s+zone)/g, 'timestamp without time zone'],
|
|
810
|
+
[/\btimetz\b/g, 'time with time zone'],
|
|
811
|
+
[/(?<!with |without )\btime\b(?!\s+with(out)?\s+time\s+zone)/g, 'time without time zone'],
|
|
812
|
+
[/\bbpchar\b|\bchar\b/g, 'character'],
|
|
813
|
+
[/\bint4\b|\bint\b/g, 'integer'],
|
|
814
|
+
[/\bint8\b/g, 'bigint'],
|
|
815
|
+
[/\bint2\b/g, 'smallint'],
|
|
816
|
+
[/\bbool\b/g, 'boolean'],
|
|
817
|
+
[/\bfloat8\b/g, 'double precision'],
|
|
818
|
+
[/\bfloat4\b/g, 'real'],
|
|
819
|
+
[/\bvarchar\b/g, 'character varying'],
|
|
820
|
+
[/\bdecimal\b/g, 'numeric'],
|
|
821
|
+
];
|
|
822
|
+
|
|
823
|
+
/**
|
|
824
|
+
* Whether a declared RETURNS clause and pg_get_function_result name the same result: case,
|
|
825
|
+
* quotes, whitespace, schema qualifiers, type modifiers (which the catalog does not keep on a
|
|
826
|
+
* result) and the common type aliases are not differences. A
|
|
827
|
+
* spelling this misses only turns an already-planned replace into DROP + CREATE.
|
|
828
|
+
*/
|
|
829
|
+
export function sameResult(declared: string, live: string): boolean {
|
|
830
|
+
const norm = (text: string): string => {
|
|
831
|
+
let out = text.toLowerCase().replace(/"/g, '').replace(/\s+/g, ' ').trim();
|
|
832
|
+
out = out.replace(/\b[a-z_][a-z0-9_$]*\.(?=[a-z_])/g, '');
|
|
833
|
+
// pg_get_function_result drops type modifiers: numeric(10,2) prints as numeric.
|
|
834
|
+
out = out.replace(/\s*\(\s*\d+\s*(,\s*\d+\s*)?\)/g, '');
|
|
835
|
+
for (const [pattern, to] of TYPE_ALIASES) out = out.replace(pattern, to);
|
|
836
|
+
return out.replace(/\s*([(),])\s*/g, '$1');
|
|
837
|
+
};
|
|
838
|
+
return norm(declared) === norm(live);
|
|
839
|
+
}
|
|
@@ -754,6 +754,7 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
|
|
|
754
754
|
} else if (f.secdef) {
|
|
755
755
|
props.push(`searchPath: 'unpinned',`);
|
|
756
756
|
}
|
|
757
|
+
if (f.lockTimeout !== undefined) props.push(`lockTimeout: ${tsString(f.lockTimeout)},`);
|
|
757
758
|
// Ownership sits beside security because it IS the other half of it: `security: 'definer'`
|
|
758
759
|
// says the function runs as its owner, and this says who that is.
|
|
759
760
|
if (ownership.prop) props.push(ownership.prop);
|
package/src/cli/index.ts
CHANGED
|
@@ -312,7 +312,7 @@ Usage:
|
|
|
312
312
|
everystack db:reset [--stage <name>] Drop all schemas + re-run migrations (dev only)
|
|
313
313
|
everystack db:psql --stage <name> Interactive ADMIN psql (IAM-gated; resolves the admin URL in-process)
|
|
314
314
|
everystack db:psql [--stage <name>] -c <command> Run one query via Lambda (read-only). Runs as the SELECT-only 'reader' role when the stage has one (db:provision --with-reader + QUERY_DATABASE_URL wired to dbPlugin's queryDatabaseUrl); WITHOUT one it runs on the OPERATOR connection, fenced only by a read-only transaction — see docs/cli.md
|
|
315
|
-
everystack db:doctor [--
|
|
315
|
+
everystack db:doctor [--destroy-after <days>] [--models <barrel>] [--stage <name>] [--direct | --database-url <api> [--admin-database-url <ops>]] Check the DB is least-privilege + RLS-subject (api vs operator connection). No flag = ops-Lambda venue (auto-falls to direct via the stage secrets if the handler has no dbPlugin). --direct = probe both connections from the stage's DATABASE_URL + ADMIN_DATABASE_URL secrets; --database-url = explicit local venue (a lone URL is probed as both)
|
|
316
316
|
everystack db:provision --stage <name> [--direct | --database-url <url>] [--rotation-pairs] [--with-reader] Create the least-privilege role chain (idempotent). --rotation-pairs creates A/B pairs for live rotation. --with-reader adds the SELECT-only 'reader' login db:query runs as, grants it read on the declared schemas, and writes QUERY_DATABASE_URL (opt-in once, then kept converged on every re-provision)
|
|
317
317
|
everystack db:provision --rotate --stage <name> [--slot api|ops|both] [--grace 60] Rotate credentials live (A/B role swap, no deploy, no downtime)
|
|
318
318
|
everystack db:snapshot [--stage <name>] [--instance <id>] Take a physical RDS snapshot (instant DR point; RDS only — use db:backup for portable logical backups)
|
|
@@ -29,7 +29,8 @@ export function canonOfFunction(fn: FunctionDescriptor): FunctionCanon {
|
|
|
29
29
|
language: fn.language, secdef: fn.security === 'definer',
|
|
30
30
|
volatility: { immutable: 'i', stable: 's', volatile: 'v' }[fn.volatility],
|
|
31
31
|
strict: false, leakproof: false,
|
|
32
|
-
config: Array.isArray(fn.searchPath) ? [`search_path=${fn.searchPath.join(', ')}`] : [],
|
|
32
|
+
config: [...(Array.isArray(fn.searchPath) ? [`search_path=${fn.searchPath.join(', ')}`] : []),
|
|
33
|
+
...(fn.lockTimeout === undefined ? [] : [`lock_timeout=${fn.lockTimeout}`])],
|
|
33
34
|
owner: fn.owner ?? '(ambient)', execute: [...execute].sort(),
|
|
34
35
|
// renderFunction adds exactly one newline on each side of the authored body.
|
|
35
36
|
body: `\n${fn.body}\n`,
|