@everystack/cli 0.4.99 → 0.4.102
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 +105 -0
- package/package.json +3 -3
- package/src/cli/apply-execute.ts +15 -1
- package/src/cli/authz-baseline.ts +8 -4
- package/src/cli/authz-compile.ts +61 -7
- package/src/cli/authz-contract.ts +23 -14
- package/src/cli/authz-derive.ts +282 -30
- package/src/cli/authz-reconcile.ts +6 -4
- package/src/cli/catalog-rename.ts +115 -0
- package/src/cli/commands/db-check.ts +40 -0
- package/src/cli/commands/db-generate.ts +10 -9
- package/src/cli/commands/db-plan.ts +30 -13
- package/src/cli/commands/db-pull.ts +153 -63
- package/src/cli/commands/db-reconcile.ts +22 -3
- package/src/cli/commands/db-sync.ts +6 -5
- package/src/cli/commands/db.ts +91 -8
- package/src/cli/commands/update.ts +3 -2
- package/src/cli/db-build.ts +1 -0
- package/src/cli/declared-derived.ts +3 -1
- package/src/cli/declared-diff.ts +4 -1
- package/src/cli/derived-apply.ts +116 -10
- package/src/cli/derived-grants.ts +4 -2
- package/src/cli/derived-introspect.ts +23 -3
- package/src/cli/derived-plan.ts +37 -1
- package/src/cli/derived-render.ts +33 -34
- package/src/cli/edge-plan.ts +57 -9
- package/src/cli/generated-artifacts.ts +22 -0
- package/src/cli/generated-names.ts +108 -0
- package/src/cli/host-url.ts +48 -27
- package/src/cli/index.ts +5 -5
- package/src/cli/lifecycle-canon.ts +37 -0
- package/src/cli/lifecycle-derived.ts +721 -0
- package/src/cli/lifecycle-pull.ts +177 -0
- package/src/cli/lifecycle-verify.ts +232 -0
- package/src/cli/migration-compile.ts +8 -3
- package/src/cli/migration-generate.ts +23 -3
- package/src/cli/model-map.ts +255 -0
- package/src/cli/model-render.ts +243 -77
- package/src/cli/plain-output.ts +198 -10
- package/src/cli/runbook/model-section.ts +8 -5
- package/src/cli/schema-compile.ts +57 -18
- package/src/cli/schema-diff.ts +184 -41
- package/src/cli/schema-fingerprint.ts +39 -6
- package/src/cli/schema-introspect.ts +66 -2
- package/src/cli/schema-source.ts +40 -9
- package/src/cli/schema-swap.ts +3 -2
- package/src/cli/state-apply.ts +10 -4
- package/src/cli/ts-template.ts +52 -0
|
@@ -32,9 +32,10 @@
|
|
|
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 { planReconcile, objectGrantDelta, type ReconcilePlan, type ReconcileOptions } from '../derived-plan';
|
|
35
|
+
import { planReconcile, objectGrantDelta, TRIGGER_ENABLED_STATE, type ReconcilePlan, type ReconcileOptions } from '../derived-plan';
|
|
36
36
|
import {
|
|
37
37
|
renderReconcileSql,
|
|
38
|
+
triggerEnableSql,
|
|
38
39
|
renderProvenanceUpsert,
|
|
39
40
|
renderProvenanceMigrate,
|
|
40
41
|
triggerDropSql,
|
|
@@ -47,6 +48,8 @@ import {
|
|
|
47
48
|
ownerPreflightSql,
|
|
48
49
|
ownerPreflightRefusal,
|
|
49
50
|
builderOwnerMode,
|
|
51
|
+
transientExecuteGrantee,
|
|
52
|
+
triggerFunctionOf,
|
|
50
53
|
type OwnerApplyMode,
|
|
51
54
|
ENSURE_RECONCILER_SQL,
|
|
52
55
|
} from '../derived-apply';
|
|
@@ -170,7 +173,8 @@ export async function executeReconcile(
|
|
|
170
173
|
const refusal = ownerPreflightRefusal(rows);
|
|
171
174
|
if (refusal) return { plan, applied: false, statements: rendered.statements, refusal };
|
|
172
175
|
ownerMode = builderOwnerMode(rows);
|
|
173
|
-
|
|
176
|
+
const executeGrantee = transientExecuteGrantee(rows);
|
|
177
|
+
if (ownerMode !== 'set-role' || executeGrantee) rendered = renderReconcileSql(plan, parsed.objects, ownerMode, executeGrantee);
|
|
174
178
|
}
|
|
175
179
|
|
|
176
180
|
const now = options.now ?? Date.now;
|
|
@@ -364,7 +368,15 @@ export async function executeReconcile(
|
|
|
364
368
|
liveById = new Map(after.objects.map((o) => [o.identity, o]));
|
|
365
369
|
}
|
|
366
370
|
|
|
367
|
-
|
|
371
|
+
// A built trigger on an owned function had EXECUTE lent to the builder for its CREATE
|
|
372
|
+
// (withTransientExecute). The function may not be in `built` (a trigger-only rebuild), so it
|
|
373
|
+
// joins the ruler here: the post-DDL catalog proves the lend was taken back, or the batch rolls back.
|
|
374
|
+
const lentFunctions = built
|
|
375
|
+
.map((identity) => srcById.get(identity))
|
|
376
|
+
.map((trigger) => (trigger ? triggerFunctionOf(trigger, parsed.objects) : undefined))
|
|
377
|
+
.filter((fn): fn is SourceObject => Boolean(fn?.owner))
|
|
378
|
+
.map((fn) => fn.identity);
|
|
379
|
+
const grantTouched = new Set([...built, ...plan.regrants.map((r) => r.identity), ...lentFunctions]);
|
|
368
380
|
const unconverged: string[] = [];
|
|
369
381
|
for (const identity of [...grantTouched].sort()) {
|
|
370
382
|
const src = srcById.get(identity);
|
|
@@ -573,6 +585,12 @@ export function buildReconcileReport(plan: ReconcilePlan): string[] {
|
|
|
573
585
|
for (const r of plan.regrants) {
|
|
574
586
|
lines.push(`≠ regrant ${r.identity} — live grants differ from source (${r.statements.join('; ')})`);
|
|
575
587
|
}
|
|
588
|
+
for (const r of plan.reenables ?? []) {
|
|
589
|
+
lines.push(
|
|
590
|
+
`! trigger ${r.identity} — ${TRIGGER_ENABLED_STATE[r.state] ?? 'not enabled as declared'} `
|
|
591
|
+
+ `(tgenabled '${r.state}', declared 'O'); --apply runs ${triggerEnableSql(r.name, r.table)}`,
|
|
592
|
+
);
|
|
593
|
+
}
|
|
576
594
|
for (const d of plan.dependencyDrift) {
|
|
577
595
|
const parts = [
|
|
578
596
|
...(d.missing.length ? [`reads ${d.missing.join(', ')} undeclared`] : []),
|
|
@@ -612,6 +630,7 @@ export function checkFails(plan: ReconcilePlan): boolean {
|
|
|
612
630
|
|| plan.needsBaseline.length > 0
|
|
613
631
|
|| plan.blocked.length > 0
|
|
614
632
|
|| plan.regrants.length > 0
|
|
633
|
+
|| (plan.reenables?.length ?? 0) > 0
|
|
615
634
|
|| plan.dependencyDrift.length > 0;
|
|
616
635
|
}
|
|
617
636
|
|
|
@@ -45,7 +45,8 @@ import {
|
|
|
45
45
|
type StateSyncOutcome,
|
|
46
46
|
} from '../state-apply';
|
|
47
47
|
import { fingerprintModels } from '../schema-fingerprint';
|
|
48
|
-
import {
|
|
48
|
+
import { writeGeneratedArtifacts } from '../generated-artifacts';
|
|
49
|
+
import { modelMapPath } from '../model-map';
|
|
49
50
|
import type { SourceFile } from '../derived-source';
|
|
50
51
|
import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source';
|
|
51
52
|
import { borrowedSessionRunner, type SessionRunner } from '../session';
|
|
@@ -378,10 +379,10 @@ export async function dbSyncCommand(flags: Record<string, string>): Promise<void
|
|
|
378
379
|
models = await loadModels(modelsPath);
|
|
379
380
|
declaredDb = await loadDeclaredDerived(flags.models);
|
|
380
381
|
info(`${models.length} model(s).`);
|
|
381
|
-
// Same contract as db:generate: the drizzle runtime schema
|
|
382
|
-
//
|
|
383
|
-
step(`Writing the drizzle schema → ${path.relative(process.cwd(), schemaOut)}...`);
|
|
384
|
-
await
|
|
382
|
+
// Same contract as db:generate: the drizzle runtime schema and the model map are
|
|
383
|
+
// derived artifacts, refreshed from the models on every run.
|
|
384
|
+
step(`Writing the drizzle schema → ${path.relative(process.cwd(), schemaOut)} and the model map → ${path.relative(process.cwd(), modelMapPath(schemaOut))}...`);
|
|
385
|
+
await writeGeneratedArtifacts(schemaOut, models, declaredDb?.derived);
|
|
385
386
|
} catch (err: any) {
|
|
386
387
|
fail(err.message);
|
|
387
388
|
process.exit(1);
|
package/src/cli/commands/db.ts
CHANGED
|
@@ -346,6 +346,8 @@ export interface ServerProvision {
|
|
|
346
346
|
declaredOwners?: DeclaredOwner[];
|
|
347
347
|
declaredObjects?: Adoptee[];
|
|
348
348
|
confirm?: boolean;
|
|
349
|
+
/** The Models declare a lifecycle model: provision the reserved everystack_lifecycle role. */
|
|
350
|
+
lifecycleRole?: boolean;
|
|
349
351
|
},
|
|
350
352
|
deps: {
|
|
351
353
|
execute: (sql: string) => Promise<unknown>;
|
|
@@ -955,10 +957,14 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
955
957
|
// function/view/matview objects to ADOPT, read from the same barrel as the schema set.
|
|
956
958
|
let declaredOwners: DeclaredOwner[] = [];
|
|
957
959
|
let declaredObjects: Adoptee[] = [];
|
|
960
|
+
// A `lifecycle: true` model compiles to the reserved role everystack_lifecycle, which nothing
|
|
961
|
+
// else on a stage creates (db:apply never makes roles). Provision makes it, NOLOGIN, no members.
|
|
962
|
+
let lifecycleRole = false;
|
|
958
963
|
try {
|
|
959
964
|
const { resolveModelsPath } = await import('../models-path');
|
|
960
965
|
const { loadModels } = await import('./db-generate');
|
|
961
966
|
const models = await loadModels(resolveModelsPath(flags.models));
|
|
967
|
+
lifecycleRole = Array.isArray(models) && models.some((m) => m.lifecycle === true);
|
|
962
968
|
let derivedObjects: Array<{ schema: string }> = [];
|
|
963
969
|
try {
|
|
964
970
|
const { loadDeclaredDerived } = await import('../declared-derived');
|
|
@@ -1023,14 +1029,16 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
1023
1029
|
const { runner, end } = await createUrlRunner(url);
|
|
1024
1030
|
try {
|
|
1025
1031
|
// Credentials ONLY — ownership convergence runs separately, AFTER the secret is written.
|
|
1026
|
-
|
|
1032
|
+
// Rows from either driver shape, as the ops Lambda's adapter reads them (plugin.ts).
|
|
1033
|
+
const rowsOf = (res: any): any[] => (Array.isArray(res) ? res : (res?.rows ?? []));
|
|
1034
|
+
return await runProvision({ ...credentials, lifecycleRole }, {
|
|
1027
1035
|
execute: async (statement) => runner(statement),
|
|
1036
|
+
query: async (statement) => rowsOf(await runner(statement)),
|
|
1028
1037
|
connection: parseUrlConnection(url),
|
|
1029
1038
|
// Additive-only: an already-live login role keeps its password (never reset on re-provision).
|
|
1030
1039
|
roleHasLogin: async (role) => {
|
|
1031
1040
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(role)) return false;
|
|
1032
|
-
|
|
1033
|
-
return Array.isArray(rows) && rows.length > 0;
|
|
1041
|
+
return rowsOf(await runner(`SELECT 1 FROM pg_roles WHERE rolname = '${role}' AND rolcanlogin`)).length > 0;
|
|
1034
1042
|
},
|
|
1035
1043
|
// Same probe as the ops-Lambda venue: a real login as the new role, DDL-capable.
|
|
1036
1044
|
connectProbe: async (probeUrl) => {
|
|
@@ -1068,6 +1076,7 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
1068
1076
|
// The credentials half is generated locally and is not part of the wire contract.
|
|
1069
1077
|
const provisionWire = {
|
|
1070
1078
|
searchPath,
|
|
1079
|
+
lifecycleRole,
|
|
1071
1080
|
modelContract: MODEL_WIRE_CONTRACT,
|
|
1072
1081
|
} satisfies ProvisionWirePayload;
|
|
1073
1082
|
result = await invokeAction(config.region, opsFunction(config), 'db:provision', {
|
|
@@ -1113,6 +1122,35 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
1113
1122
|
: (result.adminVerified === true ? 'connectivity verified' : 'connectivity NOT verified');
|
|
1114
1123
|
success(`Operator login ready: ${result.adminRole} (LOGIN, INHERIT owner) — ${adminMsg}`);
|
|
1115
1124
|
}
|
|
1125
|
+
if (result.lifecycleRole) {
|
|
1126
|
+
const lc = result.lifecycleRole as {
|
|
1127
|
+
role: string; created: boolean | null; attributes?: string[] | null;
|
|
1128
|
+
members: Array<{ role: string; admin: boolean; inherit: boolean; set: boolean }> | null;
|
|
1129
|
+
};
|
|
1130
|
+
const membersMsg = lc.members === null ? 'members not checked' : (lc.members.length ? `${lc.members.length} member(s)` : 'no members');
|
|
1131
|
+
success(`Lifecycle role ${lc.created ? 'created' : 're-asserted'}: ${lc.role} (NOLOGIN, NOINHERIT, no BYPASSRLS, ${membersMsg})`);
|
|
1132
|
+
// The operator is a member by design: ownership convergence grants it SET on the role so it can
|
|
1133
|
+
// create the role's owned functions. It (without ADMIN), and its rotation pair when this provision
|
|
1134
|
+
// configures one, are expected; anyone else is not.
|
|
1135
|
+
const operator = result.adminRole as string | undefined;
|
|
1136
|
+
const operatorRoles = operator ? [operator, ...(credentials.rotationPairs ? [`${operator}_a`, `${operator}_b`] : [])] : [];
|
|
1137
|
+
const isExpected = (m: { role: string; admin: boolean }): boolean => !m.admin && operatorRoles.includes(m.role);
|
|
1138
|
+
const expected = (lc.members ?? []).filter(isExpected);
|
|
1139
|
+
const unexpected = (lc.members ?? []).filter((m) => !isExpected(m));
|
|
1140
|
+
if (expected.length) info(` ${lc.role} members, expected (operator): ${expected.map((m) => m.role).join(', ')}`);
|
|
1141
|
+
if (unexpected.length) {
|
|
1142
|
+
const named = unexpected.map((m) => {
|
|
1143
|
+
const opts = [m.set && 'SET', m.inherit && 'INHERIT', m.admin && 'ADMIN'].filter(Boolean).join(', ');
|
|
1144
|
+
return `${m.role}${opts ? ` (${opts})` : ''}`;
|
|
1145
|
+
}).join(', ');
|
|
1146
|
+
warn(`${lc.role} has unexpected direct member(s): ${named}. A member with SET can SET ROLE ${lc.role} and pass the lifecycle guard. Nothing was revoked; review each.`);
|
|
1147
|
+
}
|
|
1148
|
+
if (lc.attributes?.length) {
|
|
1149
|
+
warn(`${lc.role} carries ${lc.attributes.join(', ')}. The lifecycle role needs none of them, and db:provision does not strip them; remove each with ALTER ROLE ${lc.role} NO<attribute>.`);
|
|
1150
|
+
}
|
|
1151
|
+
} else if (lifecycleRole) {
|
|
1152
|
+
warn('This @everystack/server did not create the lifecycle role everystack_lifecycle the models need. Upgrade the server pin and re-run.');
|
|
1153
|
+
}
|
|
1116
1154
|
if (result.readerRole) {
|
|
1117
1155
|
const readerMsg = result.readerPasswordSet === false ? 'existing login preserved' : 'password set';
|
|
1118
1156
|
success(`Read-only login ready: ${result.readerRole} (LOGIN, NOINHERIT, no BYPASSRLS, SELECT only) — ${readerMsg}`);
|
|
@@ -1343,6 +1381,30 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
1343
1381
|
}
|
|
1344
1382
|
}
|
|
1345
1383
|
|
|
1384
|
+
/** Parse the explicit day window once, before choosing a connection venue. */
|
|
1385
|
+
export function purgeAfterMilliseconds(days: string | undefined): number | undefined {
|
|
1386
|
+
if (days === undefined) return undefined;
|
|
1387
|
+
const milliseconds = Number(days) * 86400000;
|
|
1388
|
+
if (!Number.isFinite(milliseconds) || milliseconds <= 0) throw new Error('--purge-after needs a positive number of days');
|
|
1389
|
+
return milliseconds;
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
async function doctorOverdueRows(
|
|
1393
|
+
after: number | undefined,
|
|
1394
|
+
modelsPath: string | undefined,
|
|
1395
|
+
runner: (query: string) => Promise<any[]>,
|
|
1396
|
+
): Promise<Record<string, number> | undefined> {
|
|
1397
|
+
if (after === undefined) return undefined;
|
|
1398
|
+
const { loadModels } = await import('./db-generate');
|
|
1399
|
+
const { resolveModelsPath } = await import('../models-path');
|
|
1400
|
+
const spec = '@everystack/server/purge';
|
|
1401
|
+
const { overdueLifecycleRows } = await import(spec) as typeof import('@everystack/server/purge');
|
|
1402
|
+
const { PgDialect } = await import('drizzle-orm/pg-core');
|
|
1403
|
+
const dialect = new PgDialect();
|
|
1404
|
+
const models = await loadModels(resolveModelsPath(modelsPath));
|
|
1405
|
+
return overdueLifecycleRows({ execute: query => runner(dialect.sqlToQuery(query.inlineParams()).sql) }, models, { after });
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1346
1408
|
/** Render a doctor report — identical output whichever venue produced it. */
|
|
1347
1409
|
export function printDoctorReport(report: any): void {
|
|
1348
1410
|
console.log('');
|
|
@@ -1357,6 +1419,12 @@ export function printDoctorReport(report: any): void {
|
|
|
1357
1419
|
if (c.status === 'fail' && c.remediation) info(` fix: ${c.remediation}`);
|
|
1358
1420
|
}
|
|
1359
1421
|
|
|
1422
|
+
console.log('');
|
|
1423
|
+
if (report.overdueLifecycleRows === undefined) info('Lifecycle purge: skipped; pass --purge-after <days> to count overdue rows');
|
|
1424
|
+
else {
|
|
1425
|
+
info('Lifecycle purge: overdue rows');
|
|
1426
|
+
for (const [table, count] of Object.entries(report.overdueLifecycleRows)) info(` ${table}: ${count}`);
|
|
1427
|
+
}
|
|
1360
1428
|
console.log('');
|
|
1361
1429
|
if (report.ok) {
|
|
1362
1430
|
success('db:doctor — database is least-privilege and RLS-subject');
|
|
@@ -1385,7 +1453,7 @@ export function printDoctorReport(report: any): void {
|
|
|
1385
1453
|
* forceRlsCarveouts/functionSchemas are unknown to the CLI, so the functions teeth run at
|
|
1386
1454
|
* their safe default (unpinned SECDEF warns rather than fails). Returns report.ok.
|
|
1387
1455
|
*/
|
|
1388
|
-
async function runDoctorDirect(apiUrl: string, adminUrl: string, sourceLabel: string): Promise<boolean> {
|
|
1456
|
+
async function runDoctorDirect(apiUrl: string, adminUrl: string, sourceLabel: string, purgeAfter: number | undefined, modelsPath: string | undefined): Promise<boolean> {
|
|
1389
1457
|
const { runDoctorProbe } = await loadServerDoctor();
|
|
1390
1458
|
const { createUrlRunner } = await import('../db-source');
|
|
1391
1459
|
step('Probing database connections (api + operator)...');
|
|
@@ -1396,7 +1464,8 @@ async function runDoctorDirect(apiUrl: string, adminUrl: string, sourceLabel: st
|
|
|
1396
1464
|
{ api: apiConn.runner, ops: opsConn.runner },
|
|
1397
1465
|
{ apiSourceLabel: sourceLabel },
|
|
1398
1466
|
);
|
|
1399
|
-
|
|
1467
|
+
const overdueLifecycleRows = await doctorOverdueRows(purgeAfter, modelsPath, opsConn.runner);
|
|
1468
|
+
printDoctorReport({ ...report, overdueLifecycleRows });
|
|
1400
1469
|
return report.ok;
|
|
1401
1470
|
} finally {
|
|
1402
1471
|
await apiConn.end().catch(() => {});
|
|
@@ -1405,6 +1474,13 @@ async function runDoctorDirect(apiUrl: string, adminUrl: string, sourceLabel: st
|
|
|
1405
1474
|
}
|
|
1406
1475
|
|
|
1407
1476
|
export async function dbDoctorCommand(flags: Record<string, string>): Promise<void> {
|
|
1477
|
+
let purgeAfter: number | undefined;
|
|
1478
|
+
try {
|
|
1479
|
+
purgeAfter = purgeAfterMilliseconds(flags['purge-after']);
|
|
1480
|
+
} catch (error) {
|
|
1481
|
+
fail(error instanceof Error ? error.message : String(error));
|
|
1482
|
+
process.exit(1);
|
|
1483
|
+
}
|
|
1408
1484
|
// Explicit --database-url / --admin-database-url is a fully LOCAL venue — no AWS, no
|
|
1409
1485
|
// stage, no secrets. Resolve and run before touching config (mirrors db:apply's
|
|
1410
1486
|
// local-dev --database-url path).
|
|
@@ -1419,7 +1495,7 @@ export async function dbDoctorCommand(flags: Record<string, string>): Promise<vo
|
|
|
1419
1495
|
info('venue: direct connection (explicit --database-url)');
|
|
1420
1496
|
let ok = false;
|
|
1421
1497
|
try {
|
|
1422
|
-
ok = await runDoctorDirect(venue!.apiUrl, venue!.adminUrl, '--database-url');
|
|
1498
|
+
ok = await runDoctorDirect(venue!.apiUrl, venue!.adminUrl, '--database-url', purgeAfter, flags.models);
|
|
1423
1499
|
} catch (err: any) {
|
|
1424
1500
|
fail(`db:doctor failed: ${err.message}`);
|
|
1425
1501
|
process.exit(1);
|
|
@@ -1459,7 +1535,7 @@ export async function dbDoctorCommand(flags: Record<string, string>): Promise<vo
|
|
|
1459
1535
|
info(`Region: ${config.region}, venue: direct connection (stage secrets)`);
|
|
1460
1536
|
let ok = false;
|
|
1461
1537
|
try {
|
|
1462
|
-
ok = await runDoctorDirect(venue!.apiUrl, venue!.adminUrl, 'DATABASE_URL secret');
|
|
1538
|
+
ok = await runDoctorDirect(venue!.apiUrl, venue!.adminUrl, 'DATABASE_URL secret', purgeAfter, flags.models);
|
|
1463
1539
|
} catch (err: any) {
|
|
1464
1540
|
fail(`db:doctor failed: ${err.message}`);
|
|
1465
1541
|
process.exit(1);
|
|
@@ -1484,7 +1560,7 @@ export async function dbDoctorCommand(flags: Record<string, string>): Promise<vo
|
|
|
1484
1560
|
info('No Ops Lambda (the deployed handler has no dbPlugin) — verifying directly over the stage secrets.');
|
|
1485
1561
|
let ok = false;
|
|
1486
1562
|
try {
|
|
1487
|
-
ok = await runDoctorDirect(venue.apiUrl, venue.adminUrl, 'DATABASE_URL secret');
|
|
1563
|
+
ok = await runDoctorDirect(venue.apiUrl, venue.adminUrl, 'DATABASE_URL secret', purgeAfter, flags.models);
|
|
1488
1564
|
} catch (err: any) {
|
|
1489
1565
|
fail(`db:doctor failed: ${err.message}`);
|
|
1490
1566
|
process.exit(1);
|
|
@@ -1512,6 +1588,13 @@ export async function dbDoctorCommand(flags: Record<string, string>): Promise<vo
|
|
|
1512
1588
|
process.exit(1);
|
|
1513
1589
|
}
|
|
1514
1590
|
|
|
1591
|
+
const { lambdaSessionRunner } = await import('../aws');
|
|
1592
|
+
const session = lambdaSessionRunner(config.region, opsFunction(config));
|
|
1593
|
+
report.overdueLifecycleRows = await doctorOverdueRows(purgeAfter, flags.models, async query => {
|
|
1594
|
+
const [rows] = await session([query], { readOnly: true, searchPath: 'pg_catalog' });
|
|
1595
|
+
if (!Array.isArray(rows)) throw new Error('overdue lifecycle count failed');
|
|
1596
|
+
return rows;
|
|
1597
|
+
});
|
|
1515
1598
|
printDoctorReport(report);
|
|
1516
1599
|
process.exit(report.ok ? 0 : 1);
|
|
1517
1600
|
}
|
|
@@ -6,7 +6,7 @@ import { pipeline } from 'node:stream/promises';
|
|
|
6
6
|
import { createWriteStream } from 'node:fs';
|
|
7
7
|
import { spawn } from 'node:child_process';
|
|
8
8
|
import { resolveConfig, probeUrl, type CliConfig } from '../config';
|
|
9
|
-
import { missingHostUrlError } from '../host-url';
|
|
9
|
+
import { loadSstOutputs, missingHostUrlError } from '../host-url';
|
|
10
10
|
import { uploadToS3, headS3, invokeAction, rollingRestart } from '../aws';
|
|
11
11
|
import { step, success, warn, fail, info } from '../output';
|
|
12
12
|
import { opsAdviceLines, IAM_ADVICE } from '../ops-advice';
|
|
@@ -135,7 +135,8 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
|
|
|
135
135
|
// published channel's bundle) is the same either way, and the documented day-to-day flow is
|
|
136
136
|
// `update --channel production` with no --stage at all. A secret loaded above may supply
|
|
137
137
|
// HOST_URL, which is why this runs after that step rather than before it.
|
|
138
|
-
const
|
|
138
|
+
const trace = await loadSstOutputs(flags.stage);
|
|
139
|
+
const noHostUrl = await missingHostUrlError(flags.stage, { siteUrl: resolvedConfig?.siteUrl, trace });
|
|
139
140
|
if (noHostUrl) {
|
|
140
141
|
fail(noHostUrl);
|
|
141
142
|
process.exit(1);
|
package/src/cli/db-build.ts
CHANGED
|
@@ -184,6 +184,7 @@ export async function buildIntoDatabase(
|
|
|
184
184
|
try {
|
|
185
185
|
const pre = await executeReconcile(runner, session, {
|
|
186
186
|
declared: declaredFunctions,
|
|
187
|
+
only: declaredFunctions.map(object => object.identity),
|
|
187
188
|
apply: true,
|
|
188
189
|
actor: options.actor ?? 'db-build',
|
|
189
190
|
gitRef: options.gitRef ?? null,
|
|
@@ -14,6 +14,7 @@ import { pathToFileURL } from 'node:url';
|
|
|
14
14
|
import type { Module, ModelDescriptor, SequenceDescriptor, DerivedDescriptor } from '@everystack/model';
|
|
15
15
|
import { linkModels, ModelLinkError, moduleGovernedRoles } from '@everystack/model';
|
|
16
16
|
import { compileDerived } from './derived-compile';
|
|
17
|
+
import { lifecycleDerived } from './lifecycle-derived';
|
|
17
18
|
import { compileTableRenames, compileTableMoves } from './schema-compile';
|
|
18
19
|
import type { SourceObject } from './derived-source';
|
|
19
20
|
import { resolveModelsPath } from './models-path';
|
|
@@ -181,7 +182,8 @@ export async function loadDeclaredDerived(modelsFlag?: string): Promise<Declared
|
|
|
181
182
|
export function composeDeclaredDerived(modules: Module[], modelsPath: string): DeclaredDerived {
|
|
182
183
|
try {
|
|
183
184
|
const models = modules.flatMap((m) => m.models);
|
|
184
|
-
|
|
185
|
+
// The lifecycle triggers are generated from the models, never declared on a module.
|
|
186
|
+
const derived = [...modules.flatMap((m) => m.derived), ...lifecycleDerived(models)];
|
|
185
187
|
return {
|
|
186
188
|
objects: compileDerived(models, derived),
|
|
187
189
|
derived,
|
package/src/cli/declared-diff.ts
CHANGED
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
29
|
import type { ModelDescriptor } from '@everystack/model';
|
|
30
|
+
import { assertExternalTargets, relationDeleteActions } from '@everystack/model';
|
|
30
31
|
import type { SchemaSnapshot } from './schema-introspect';
|
|
31
32
|
import type { AuthzContract } from './authz-contract';
|
|
32
33
|
import { compileTableSchema, compileEnums } from './schema-compile';
|
|
@@ -54,10 +55,12 @@ export function compileDeclaredState(
|
|
|
54
55
|
models: ModelDescriptor[],
|
|
55
56
|
opts: { schema?: string } = {},
|
|
56
57
|
): DeclaredState {
|
|
58
|
+
assertExternalTargets(models);
|
|
59
|
+
const dependents = relationDeleteActions(models);
|
|
57
60
|
const schema = opts.schema ?? 'public';
|
|
58
61
|
return {
|
|
59
62
|
snapshot: {
|
|
60
|
-
tables: models.map((m) => compileTableSchema(m, { schema })),
|
|
63
|
+
tables: models.map((m) => compileTableSchema(m, { schema, dependents })),
|
|
61
64
|
enums: compileEnums(models),
|
|
62
65
|
},
|
|
63
66
|
contract: {
|
package/src/cli/derived-apply.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* the reconciler writes it and never reads it.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import { LIFECYCLE_OWNER_ROLE } from '@everystack/model';
|
|
16
17
|
import { normalizeSql, parseQualified, type SourceObject } from './derived-source';
|
|
17
18
|
import type { ReconcilePlan } from './derived-plan';
|
|
18
19
|
import { parseGrantAttachments } from './derived-grants';
|
|
@@ -88,6 +89,12 @@ export function triggerDropSql(name: string, table: string): string {
|
|
|
88
89
|
return `DROP TRIGGER IF EXISTS "${name}" ON ${table}`;
|
|
89
90
|
}
|
|
90
91
|
|
|
92
|
+
/** Restore a managed trigger to the declared `tgenabled = 'O'`. `table` is the qualified
|
|
93
|
+
* `schema.table` (the live ownerIdentity), so no search_path decides which table it hits. */
|
|
94
|
+
export function triggerEnableSql(name: string, table: string): string {
|
|
95
|
+
return `ALTER TABLE ${quoteQualified(table)} ENABLE TRIGGER "${name.replace(/"/g, '""')}"`;
|
|
96
|
+
}
|
|
97
|
+
|
|
91
98
|
const DROP_KEYWORD: Record<string, string> = {
|
|
92
99
|
'materialized view': 'MATERIALIZED VIEW',
|
|
93
100
|
view: 'VIEW',
|
|
@@ -185,6 +192,54 @@ function ownerTarget(obj: SourceObject): string {
|
|
|
185
192
|
return obj.kind === 'function' ? dropTarget('function', obj.identity) : quoteQualified(obj.identity);
|
|
186
193
|
}
|
|
187
194
|
|
|
195
|
+
/** Parse the SQL spelling, then compare stored identifiers rather than quoted text. */
|
|
196
|
+
const TRIGGER_IDENT = String.raw`(?:"(?:[^"]|"")*"|[a-z_\u0080-\uffff][a-z0-9_$\u0080-\uffff]*)`;
|
|
197
|
+
const TRIGGER_EXECUTE_RE = new RegExp(
|
|
198
|
+
String.raw`\bEXECUTE\s+(?:FUNCTION|PROCEDURE)\s+(${TRIGGER_IDENT})(?:\s*\.\s*(${TRIGGER_IDENT}))?\s*\(\s*\)\s*;?\s*$`, 'i',
|
|
199
|
+
);
|
|
200
|
+
const storedIdentifier = (token: string): string => token.startsWith('"')
|
|
201
|
+
? token.slice(1, -1).replace(/""/g, '"') : token.toLowerCase();
|
|
202
|
+
|
|
203
|
+
/** The declared function a trigger executes, or undefined when the source set does not declare it. */
|
|
204
|
+
export function triggerFunctionOf(trigger: SourceObject, source: readonly SourceObject[]): SourceObject | undefined {
|
|
205
|
+
if (trigger.kind !== 'trigger') return undefined;
|
|
206
|
+
const target = TRIGGER_EXECUTE_RE.exec(trigger.sql);
|
|
207
|
+
if (!target) return undefined;
|
|
208
|
+
const schema = target[2] ? storedIdentifier(target[1]) : 'public';
|
|
209
|
+
const name = storedIdentifier(target[2] ?? target[1]);
|
|
210
|
+
return source.find((o) => o.kind === 'function' && o.schema === schema && o.name === name
|
|
211
|
+
&& splitFunctionIdentity(o.identity).args === '');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* CREATE TRIGGER needs EXECUTE on the trigger function, and a trigger has no owner: the builder
|
|
216
|
+
* creates it as itself. When the function belongs to a declared owner with PUBLIC revoked, the
|
|
217
|
+
* builder holds no EXECUTE — in set-role mode it is a member WITH INHERIT FALSE, and in alter-owner
|
|
218
|
+
* mode it gave the function away before the trigger. So the owner lends EXECUTE for the one
|
|
219
|
+
* statement and takes it back, in the same transaction: nothing persists, nothing drifts.
|
|
220
|
+
*
|
|
221
|
+
* `grantee` is the builder, and only when it is not a true superuser (a superuser needs no EXECUTE).
|
|
222
|
+
* Skipped when the function's own declaration grants the builder EXECUTE — the REVOKE would strip
|
|
223
|
+
* a declared grant.
|
|
224
|
+
*
|
|
225
|
+
* On a non-atomic batch (CONCURRENTLY/VACUUM) a failure between the GRANT and the REVOKE leaves the
|
|
226
|
+
* grant behind; the builder already holds SET on the owner, so that is drift, not escalation.
|
|
227
|
+
*/
|
|
228
|
+
function withTransientExecute(obj: SourceObject, statements: string[], source: readonly SourceObject[], grantee?: string): string[] {
|
|
229
|
+
if (!grantee || obj.kind !== 'trigger') return statements;
|
|
230
|
+
const fn = triggerFunctionOf(obj, source);
|
|
231
|
+
if (!fn?.owner) return statements;
|
|
232
|
+
const declared = parseGrantAttachments(fn.attachments).grants[grantee] ?? [];
|
|
233
|
+
if (declared.includes('EXECUTE') || declared.includes('ALL')) return statements;
|
|
234
|
+
const target = dropTarget('function', fn.identity);
|
|
235
|
+
const to = quoteIdent(grantee);
|
|
236
|
+
return [
|
|
237
|
+
`SET ROLE ${fn.owner}`, `GRANT EXECUTE ON FUNCTION ${target} TO ${to}`, 'RESET ROLE',
|
|
238
|
+
...statements,
|
|
239
|
+
`SET ROLE ${fn.owner}`, `REVOKE EXECUTE ON FUNCTION ${target} FROM ${to}`, 'RESET ROLE',
|
|
240
|
+
];
|
|
241
|
+
}
|
|
242
|
+
|
|
188
243
|
/**
|
|
189
244
|
* The schemas a from-scratch derived apply must put on the search_path before it
|
|
190
245
|
* creates anything. Derived bodies carry BARE table refs — db:pull deparsed them
|
|
@@ -266,7 +321,7 @@ export function renderEnsureObjectSchemas(objects: SourceObject[]): string[] {
|
|
|
266
321
|
* backup's authz manifest — reads the SAME answer instead of re-deriving it.
|
|
267
322
|
*/
|
|
268
323
|
export function declaredObjectSchemaUsage(objects: SourceObject[]): Record<string, string[]> {
|
|
269
|
-
const usage: Record<string, string[]> =
|
|
324
|
+
const usage: Record<string, string[]> = Object.create(null); // keyed by schema name, which may be '__proto__'
|
|
270
325
|
const schemas = [...new Set(objects.map((o) => o.schema))].filter((s) => s !== 'public').sort();
|
|
271
326
|
for (const schema of schemas) {
|
|
272
327
|
const roles = new Set<string>();
|
|
@@ -305,6 +360,12 @@ export interface OwnerRequirement {
|
|
|
305
360
|
* owner be assumable would refuse work the batch was never going to attempt. On a from-scratch
|
|
306
361
|
* build every object is created, so this set is every declared owner anyway.
|
|
307
362
|
*/
|
|
363
|
+
/** A baseline may stamp the generated manifest, never rewrite an adopted function body. */
|
|
364
|
+
function lifecycleBaselineComments(object: SourceObject | undefined): string[] {
|
|
365
|
+
if (object?.kind !== 'function' || object.owner !== LIFECYCLE_OWNER_ROLE) return [];
|
|
366
|
+
return object.attachments.filter(a => a.kind === 'comment' && a.sql.includes('"everystack":"lifecycle"')).map(a => a.sql);
|
|
367
|
+
}
|
|
368
|
+
|
|
308
369
|
export function ownerRequirements(plan: ReconcilePlan, source: SourceObject[]): OwnerRequirement[] {
|
|
309
370
|
const srcById = new Map(source.map((o) => [o.identity, o]));
|
|
310
371
|
const byOwner = new Map<string, Set<string>>();
|
|
@@ -315,6 +376,20 @@ export function ownerRequirements(plan: ReconcilePlan, source: SourceObject[]):
|
|
|
315
376
|
(byOwner.get(obj.owner) ?? byOwner.set(obj.owner, new Set()).get(obj.owner)!).add(obj.schema);
|
|
316
377
|
}
|
|
317
378
|
const grantOnly = new Map<string, Set<string>>();
|
|
379
|
+
for (const action of plan.actions) {
|
|
380
|
+
// A trigger on an owned function: the owner lends the builder EXECUTE for the CREATE TRIGGER
|
|
381
|
+
// (withTransientExecute), so the owner must be assumable and must USE the function's schema.
|
|
382
|
+
if (action.action !== 'create' && action.action !== 'replace') continue;
|
|
383
|
+
const trigger = srcById.get(action.identity);
|
|
384
|
+
const fn = trigger ? triggerFunctionOf(trigger, source) : undefined;
|
|
385
|
+
if (!fn?.owner) continue;
|
|
386
|
+
(grantOnly.get(fn.owner) ?? grantOnly.set(fn.owner, new Set()).get(fn.owner)!).add(fn.schema);
|
|
387
|
+
}
|
|
388
|
+
for (const action of plan.actions) {
|
|
389
|
+
const object = srcById.get(action.identity);
|
|
390
|
+
if (action.action !== 'baseline' || !object?.owner || !lifecycleBaselineComments(object).length) continue;
|
|
391
|
+
(grantOnly.get(object.owner) ?? grantOnly.set(object.owner, new Set()).get(object.owner)!).add(object.schema);
|
|
392
|
+
}
|
|
318
393
|
for (const r of plan.regrants) {
|
|
319
394
|
const schema = srcById.get(r.identity)?.schema;
|
|
320
395
|
if (!r.owner || !schema) continue;
|
|
@@ -357,6 +432,8 @@ SELECT
|
|
|
357
432
|
)
|
|
358
433
|
FROM pg_roles b WHERE b.rolname = CURRENT_USER
|
|
359
434
|
) AS builder_is_superuser,
|
|
435
|
+
-- A TRUE superuser only. It needs no EXECUTE to create a trigger; an rds_superuser member does.
|
|
436
|
+
(SELECT b.rolsuper FROM pg_roles b WHERE b.rolname = CURRENT_USER) AS builder_rolsuper,
|
|
360
437
|
d.owner_name,
|
|
361
438
|
d.schema_name,
|
|
362
439
|
d.needs_create,
|
|
@@ -378,6 +455,7 @@ ORDER BY d.owner_name, d.schema_name
|
|
|
378
455
|
export interface OwnerPreflightRow {
|
|
379
456
|
builder?: unknown;
|
|
380
457
|
builder_is_superuser?: unknown;
|
|
458
|
+
builder_rolsuper?: unknown;
|
|
381
459
|
owner_name: string;
|
|
382
460
|
schema_name: string;
|
|
383
461
|
/** False for a schema the owner only GRANTs/REVOKEs in — USAGE is checked, CREATE is not. */
|
|
@@ -403,6 +481,17 @@ export function builderOwnerMode(rows: OwnerPreflightRow[]): OwnerApplyMode {
|
|
|
403
481
|
return rows.some((r) => truthy(r.builder_is_superuser)) ? 'alter-owner' : 'set-role';
|
|
404
482
|
}
|
|
405
483
|
|
|
484
|
+
/**
|
|
485
|
+
* The role an owned trigger function lends EXECUTE to for its CREATE TRIGGER (see
|
|
486
|
+
* withTransientExecute): the builder, unless it is a true superuser. Undefined when the rows do
|
|
487
|
+
* not name the builder — an older caller or a fixture — which renders no transient grant.
|
|
488
|
+
*/
|
|
489
|
+
export function transientExecuteGrantee(rows: OwnerPreflightRow[]): string | undefined {
|
|
490
|
+
const builder = rows.find((r) => r.builder != null)?.builder;
|
|
491
|
+
if (builder == null || rows.some((r) => truthy(r.builder_rolsuper))) return undefined;
|
|
492
|
+
return String(builder);
|
|
493
|
+
}
|
|
494
|
+
|
|
406
495
|
/**
|
|
407
496
|
* The refusal text, or null when every declared owner is usable.
|
|
408
497
|
*
|
|
@@ -430,16 +519,22 @@ export function ownerPreflightRefusal(rows: OwnerPreflightRow[]): string | null
|
|
|
430
519
|
const noCreate: Array<{ owner: string; schema: string; exists: boolean }> = [];
|
|
431
520
|
const noUsage: Array<{ owner: string; schema: string }> = [];
|
|
432
521
|
|
|
433
|
-
// A superuser builder creates as itself and ALTERs the owner,
|
|
434
|
-
// the owner
|
|
435
|
-
//
|
|
436
|
-
//
|
|
522
|
+
// A TRUE superuser builder creates as itself and ALTERs the owner, and PostgreSQL checks nothing
|
|
523
|
+
// of the owner for it. Refusing on CREATE or USAGE would be a false refusal that demands a
|
|
524
|
+
// privilege expansion onto a role designed to hold none. "The role must exist" survives, because
|
|
525
|
+
// no mechanism can hand an object to a principal that is not there.
|
|
526
|
+
//
|
|
527
|
+
// An rds_superuser member also takes alter-owner, but it is not a superuser to PostgreSQL 16:
|
|
528
|
+
// its ALTER … OWNER TO needs SET on the new owner and CREATE for that owner on the schema, and
|
|
529
|
+
// an owned trigger function's transient EXECUTE grant runs as the owner and needs its USAGE. So
|
|
530
|
+
// it is checked exactly as a set-role builder is.
|
|
437
531
|
const alterOwner = builderOwnerMode(rows) === 'alter-owner';
|
|
532
|
+
const trueSuperuser = rows.some((r) => truthy(r.builder_rolsuper));
|
|
438
533
|
|
|
439
534
|
for (const row of rows) {
|
|
440
535
|
const owner = String(row.owner_name);
|
|
441
536
|
if (!truthy(row.role_exists)) { missing.add(owner); continue; }
|
|
442
|
-
if (
|
|
537
|
+
if (trueSuperuser) continue;
|
|
443
538
|
if (!truthy(row.can_set_role)) unassumable.add(owner);
|
|
444
539
|
// A superuser owner holds CREATE everywhere, including in a schema this build is about to
|
|
445
540
|
// make — there is nothing to refuse.
|
|
@@ -478,7 +573,9 @@ export function ownerPreflightRefusal(rows: OwnerPreflightRow[]): string | null
|
|
|
478
573
|
}
|
|
479
574
|
for (const owner of [...unassumable].sort()) {
|
|
480
575
|
lines.push(
|
|
481
|
-
|
|
576
|
+
alterOwner
|
|
577
|
+
? ` CANNOT SET ROLE '${builderName}' cannot assume '${owner}'. ALTER … OWNER TO it and a trigger's transient EXECUTE grant on its functions run as the owner:`
|
|
578
|
+
: ` CANNOT SET ROLE '${builderName}' cannot assume '${owner}'. The CREATE and every GRANT/REVOKE on its objects run as the owner:`,
|
|
482
579
|
` GRANT ${owner} TO ${builderName} WITH INHERIT FALSE, SET TRUE;`,
|
|
483
580
|
);
|
|
484
581
|
}
|
|
@@ -497,7 +594,8 @@ export function ownerPreflightRefusal(rows: OwnerPreflightRow[]): string | null
|
|
|
497
594
|
return lines.join('\n');
|
|
498
595
|
}
|
|
499
596
|
|
|
500
|
-
|
|
597
|
+
/** `executeGrantee`: the builder an owned trigger function lends EXECUTE to ({@link transientExecuteGrantee}). */
|
|
598
|
+
export function renderReconcileSql(plan: ReconcilePlan, source: SourceObject[], ownerMode: OwnerApplyMode = 'set-role', executeGrantee?: string): RenderedReconcile {
|
|
501
599
|
const srcById = new Map(source.map((o) => [o.identity, o]));
|
|
502
600
|
const statements: string[] = [];
|
|
503
601
|
const record: string[] = [];
|
|
@@ -519,14 +617,14 @@ export function renderReconcileSql(plan: ReconcilePlan, source: SourceObject[],
|
|
|
519
617
|
case 'replace': {
|
|
520
618
|
const obj = srcById.get(action.identity);
|
|
521
619
|
if (!obj) throw new Error(`replace action for ${action.identity} has no source object`);
|
|
522
|
-
statements.push(...objectSql(obj, ownerMode, action.revokes).map(ensureOrReplace));
|
|
620
|
+
statements.push(...withTransientExecute(obj, objectSql(obj, ownerMode, action.revokes).map(ensureOrReplace), source, executeGrantee));
|
|
523
621
|
record.push(action.identity);
|
|
524
622
|
break;
|
|
525
623
|
}
|
|
526
624
|
case 'create': {
|
|
527
625
|
const obj = srcById.get(action.identity);
|
|
528
626
|
if (!obj) throw new Error(`create action for ${action.identity} has no source object`);
|
|
529
|
-
statements.push(...objectSql(obj, ownerMode));
|
|
627
|
+
statements.push(...withTransientExecute(obj, objectSql(obj, ownerMode), source, executeGrantee));
|
|
530
628
|
record.push(action.identity);
|
|
531
629
|
break;
|
|
532
630
|
}
|
|
@@ -535,6 +633,10 @@ export function renderReconcileSql(plan: ReconcilePlan, source: SourceObject[],
|
|
|
535
633
|
break;
|
|
536
634
|
}
|
|
537
635
|
case 'baseline': {
|
|
636
|
+
const object = srcById.get(action.identity);
|
|
637
|
+
const comments = lifecycleBaselineComments(object);
|
|
638
|
+
if (comments.length && object?.owner && ownerMode === 'set-role') statements.push(`SET ROLE ${object.owner}`, ...comments, 'RESET ROLE');
|
|
639
|
+
else statements.push(...comments);
|
|
538
640
|
record.push(action.identity);
|
|
539
641
|
break;
|
|
540
642
|
}
|
|
@@ -567,6 +669,10 @@ export function renderReconcileSql(plan: ReconcilePlan, source: SourceObject[],
|
|
|
567
669
|
else statements.push(...r.statements);
|
|
568
670
|
}
|
|
569
671
|
|
|
672
|
+
// A managed trigger that is disabled (or replica/always) goes back to the declared 'O' in the
|
|
673
|
+
// same batch. No provenance to record: tgenabled is in no hash.
|
|
674
|
+
for (const r of plan.reenables ?? []) statements.push(triggerEnableSql(r.name, r.table));
|
|
675
|
+
|
|
570
676
|
// NOTE — a `remove` entry that is also a migration's TARGET is CORRECT, not a contradiction.
|
|
571
677
|
// A legacy-keyed function the source has removed migrates onto its signature identity and is
|
|
572
678
|
// then dropped through it: re-key, drop the object, delete the row. That is the documented path
|
|
@@ -70,7 +70,9 @@ export function revokesPublic(attachments: readonly Attachment[]): boolean {
|
|
|
70
70
|
|
|
71
71
|
/** The declared grant contract, read back from an object's own grant attachments. */
|
|
72
72
|
export function parseGrantAttachments(attachments: readonly Attachment[]): ParsedGrants {
|
|
73
|
-
|
|
73
|
+
// Null-prototype: a grantee is a role name and '__proto__' is a legal one. The live side
|
|
74
|
+
// (derived-introspect) keys it the same way, so the two sides diff on equal terms.
|
|
75
|
+
const grants: Record<string, Set<string>> = Object.create(null);
|
|
74
76
|
let target: string | null = null;
|
|
75
77
|
let hasColumnGrants = false;
|
|
76
78
|
|
|
@@ -101,7 +103,7 @@ export function parseGrantAttachments(attachments: readonly Attachment[]): Parse
|
|
|
101
103
|
}
|
|
102
104
|
}
|
|
103
105
|
|
|
104
|
-
const out: Record<string, string[]> =
|
|
106
|
+
const out: Record<string, string[]> = Object.create(null);
|
|
105
107
|
for (const role of Object.keys(grants).sort()) out[role] = [...grants[role]].sort();
|
|
106
108
|
return { grants: out, target, hasColumnGrants };
|
|
107
109
|
}
|