@everystack/cli 0.4.82 → 0.4.83
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +34 -0
- package/package.json +9 -1
- package/src/cli/authz-compile.ts +0 -0
- package/src/cli/authz-contract.ts +5 -3
- package/src/cli/authz-reconcile.ts +18 -15
- package/src/cli/aws.ts +17 -1
- package/src/cli/commands/db-apply.ts +445 -60
- package/src/cli/commands/db-check.ts +39 -20
- package/src/cli/commands/db-plan.ts +61 -27
- package/src/cli/commands/db-pull.ts +67 -11
- package/src/cli/commands/db-reconcile.ts +22 -1
- package/src/cli/commands/db.ts +137 -19
- package/src/cli/db-source.ts +8 -1
- package/src/cli/derived-catchup.ts +154 -0
- package/src/cli/derived-render.ts +531 -245
- package/src/cli/derived-source.ts +25 -90
- package/src/cli/edge-plan.ts +76 -14
- package/src/cli/index.ts +7 -7
- package/src/cli/model-wire-lane.ts +143 -0
- package/src/cli/output.ts +12 -0
- package/src/cli/plain-output.ts +521 -0
- package/src/cli/schema-diff.ts +6 -0
- package/src/cli/schema-fingerprint-v11.ts +234 -0
- package/src/cli/schema-fingerprint.ts +20 -6
- package/src/cli/sql-statement.ts +98 -0
- package/src/cli/state-apply.ts +46 -24
- package/src/wire.ts +30 -0
package/src/cli/commands/db.ts
CHANGED
|
@@ -9,11 +9,26 @@ import { resolveConfig, opsFunction } from '../config';
|
|
|
9
9
|
import { invokeAction } from '../aws';
|
|
10
10
|
import { step, success, fail, info, warn } from '../output';
|
|
11
11
|
import { opsAdviceLines, IAM_ADVICE } from '../ops-advice';
|
|
12
|
+
import { modelContractPreflight } from '../model-wire-lane';
|
|
13
|
+
import {
|
|
14
|
+
MODEL_WIRE_CONTRACT,
|
|
15
|
+
type DeclaredOwner,
|
|
16
|
+
type Adoptee,
|
|
17
|
+
type ProvisionWirePayload,
|
|
18
|
+
type OwnershipWirePayload,
|
|
19
|
+
} from '../../wire';
|
|
12
20
|
|
|
13
21
|
/** Secret-store keys that may hold the privileged (operator) connection URL,
|
|
14
22
|
* in precedence order. Names vary by deployment; --secret-name overrides. */
|
|
15
23
|
const ADMIN_URL_KEYS = ['ADMIN_DATABASE_URL', 'AdminDatabaseUrl', 'ADMIN_URL'];
|
|
16
24
|
|
|
25
|
+
/** Secret-store keys that may hold the SELECT-only (reader) connection URL — the one `db:query`
|
|
26
|
+
* runs on. Deliberately NOT `READ_DATABASE_URL`: that key is the read-REPLICA slot, and it carries
|
|
27
|
+
* the same least-privilege API role at a different host (getReadDatabaseUrl/createReadDb, and the
|
|
28
|
+
* `ReadDatabaseUrl` rotation slot). Reusing it would point app read traffic at a role with no
|
|
29
|
+
* app-role membership. Different purpose, different key. */
|
|
30
|
+
const QUERY_URL_KEYS = ['QUERY_DATABASE_URL', 'QueryDatabaseUrl'];
|
|
31
|
+
|
|
17
32
|
/**
|
|
18
33
|
* Parse a postgres connection URL into psql's PG* environment variables.
|
|
19
34
|
*
|
|
@@ -223,12 +238,17 @@ export function buildProvisionSecretPlan(args: {
|
|
|
223
238
|
* so its secret must be left untouched. Absent/true = the password was set, write the secret. */
|
|
224
239
|
passwordSet?: boolean;
|
|
225
240
|
adminPasswordSet?: boolean;
|
|
241
|
+
/** The SELECT-only login db:query connects as. Absent = no reader was requested. */
|
|
242
|
+
readerRole?: string;
|
|
243
|
+
/** false = the reader already existed as a live login (additive-only) — leave its secret alone. */
|
|
244
|
+
readerPasswordSet?: boolean;
|
|
226
245
|
};
|
|
227
246
|
authUrl?: string;
|
|
228
247
|
adminUrl?: string;
|
|
248
|
+
readerUrl?: string;
|
|
229
249
|
existing: Record<string, string>;
|
|
230
250
|
}): ProvisionSecretPlan {
|
|
231
|
-
const { result, authUrl, adminUrl, existing } = args;
|
|
251
|
+
const { result, authUrl, adminUrl, readerUrl, existing } = args;
|
|
232
252
|
const updates: Record<string, string> = {};
|
|
233
253
|
const notes: string[] = [];
|
|
234
254
|
const warnings: string[] = [];
|
|
@@ -266,6 +286,19 @@ export function buildProvisionSecretPlan(args: {
|
|
|
266
286
|
);
|
|
267
287
|
}
|
|
268
288
|
|
|
289
|
+
// The SELECT-only reader — only ever touched when one was actually provisioned, so a stage that
|
|
290
|
+
// never asked for one is byte-identical to before this key existed.
|
|
291
|
+
if (result.readerRole && readerUrl && result.readerPasswordSet !== false) {
|
|
292
|
+
updates.QUERY_DATABASE_URL = readerUrl;
|
|
293
|
+
const prevReaderRole = roleOfUrl(existing.QUERY_DATABASE_URL ?? existing.QueryDatabaseUrl);
|
|
294
|
+
notes.push(`QUERY_DATABASE_URL: ${prevReaderRole ?? 'unset'} → ${result.readerRole} (db:query runs here — SELECT only)`);
|
|
295
|
+
} else if (result.readerRole) {
|
|
296
|
+
// NOT "--rotate to change it": rotation covers the api (authenticator) and ops (migrator) slots
|
|
297
|
+
// only — there are no A/B pairs for the reader. Say the true remedy instead of a verb that
|
|
298
|
+
// silently does nothing here.
|
|
299
|
+
notes.push(`QUERY_DATABASE_URL: '${result.readerRole}' already provisioned — password + secret preserved (--rotate does not cover this slot; to re-mint it, DROP ROLE ${result.readerRole} and re-run db:provision --with-reader)`);
|
|
300
|
+
}
|
|
301
|
+
|
|
269
302
|
return { updates, notes, warnings };
|
|
270
303
|
}
|
|
271
304
|
|
|
@@ -306,8 +339,12 @@ export interface ServerProvision {
|
|
|
306
339
|
runProvision(
|
|
307
340
|
payload: {
|
|
308
341
|
authPassword?: string; adminPassword?: string; searchPath?: string[]; rotationPairs?: boolean;
|
|
309
|
-
|
|
310
|
-
|
|
342
|
+
/** The SELECT-only reader's password. Absent = no reader role is created at all. */
|
|
343
|
+
readerPassword?: string;
|
|
344
|
+
/** The SELECT-only login db:query connects as. Default 'reader' server-side. */
|
|
345
|
+
readerRole?: string;
|
|
346
|
+
declaredOwners?: DeclaredOwner[];
|
|
347
|
+
declaredObjects?: Adoptee[];
|
|
311
348
|
confirm?: boolean;
|
|
312
349
|
},
|
|
313
350
|
deps: {
|
|
@@ -322,17 +359,21 @@ export interface ServerProvision {
|
|
|
322
359
|
/** ENACT + ADOPT, run SEPARATELY and AFTER the secret is written (the lockout-safe ordering). */
|
|
323
360
|
convergeOwnership(
|
|
324
361
|
payload: {
|
|
325
|
-
declaredOwners?:
|
|
326
|
-
declaredObjects?:
|
|
362
|
+
declaredOwners?: DeclaredOwner[];
|
|
363
|
+
declaredObjects?: Adoptee[];
|
|
327
364
|
confirm?: boolean;
|
|
328
365
|
adminRole?: string;
|
|
366
|
+
/** Present = also converge the reader's read grants over `searchPath`. Absent = skip them. */
|
|
367
|
+
readerRole?: string;
|
|
368
|
+
/** The declared schema set the reader's grants are scoped to. */
|
|
369
|
+
searchPath?: string[];
|
|
329
370
|
},
|
|
330
371
|
deps: {
|
|
331
372
|
execute: (sql: string) => Promise<unknown>;
|
|
332
373
|
query?: (sql: string) => Promise<any[]>;
|
|
333
374
|
master?: { execute: (sql: string) => Promise<unknown>; query: (sql: string) => Promise<any[]> } | null;
|
|
334
375
|
},
|
|
335
|
-
): Promise<{ ownerEnactment?: { enacted: number; refusals: string[] }; ownerAdoption?: { reassigned: number; manifest: Array<{ identity: string; kind: string; secdef: boolean; from: string; to: string }>; findings: Array<{ severity: string; identity: string; detail: string }>; refusals: string[] } }>;
|
|
376
|
+
): Promise<{ ownerEnactment?: { enacted: number; refusals: string[] }; ownerAdoption?: { reassigned: number; manifest: Array<{ identity: string; kind: string; secdef: boolean; from: string; to: string }>; findings: Array<{ severity: string; identity: string; detail: string }>; refusals: string[]; warnings?: string[] }; readerGrants?: { granted: number; refusals: string[] } }>;
|
|
336
377
|
}
|
|
337
378
|
|
|
338
379
|
/**
|
|
@@ -482,12 +523,12 @@ const ADOPTABLE_KINDS = new Set(['function', 'view', 'materialized view']);
|
|
|
482
523
|
* them owned by the operator, no convergence needed).
|
|
483
524
|
*/
|
|
484
525
|
export function extractDeclaredOwnership(objects: Array<{ kind: string; schema: string; name: string; identity: string; sql: string; owner?: string }>): {
|
|
485
|
-
declaredOwners:
|
|
486
|
-
declaredObjects:
|
|
526
|
+
declaredOwners: DeclaredOwner[];
|
|
527
|
+
declaredObjects: Adoptee[];
|
|
487
528
|
} {
|
|
488
529
|
const seen = new Set<string>();
|
|
489
|
-
const declaredOwners:
|
|
490
|
-
const declaredObjects:
|
|
530
|
+
const declaredOwners: DeclaredOwner[] = [];
|
|
531
|
+
const declaredObjects: Adoptee[] = [];
|
|
491
532
|
for (const o of objects) {
|
|
492
533
|
if (!o.owner) continue;
|
|
493
534
|
const key = `${o.owner}\t${o.schema}`;
|
|
@@ -894,8 +935,8 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
894
935
|
let declaredSchemas: string[] = [];
|
|
895
936
|
// Declared ownership (brownfield convergence) — the (owner, schema) pairs to ENACT and the
|
|
896
937
|
// function/view/matview objects to ADOPT, read from the same barrel as the schema set.
|
|
897
|
-
let declaredOwners:
|
|
898
|
-
let declaredObjects:
|
|
938
|
+
let declaredOwners: DeclaredOwner[] = [];
|
|
939
|
+
let declaredObjects: Adoptee[] = [];
|
|
899
940
|
try {
|
|
900
941
|
const { resolveModelsPath } = await import('../models-path');
|
|
901
942
|
const { loadModels } = await import('./db-generate');
|
|
@@ -920,12 +961,42 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
920
961
|
const confirm = !!flags.confirm;
|
|
921
962
|
const ownershipPayload = { declaredOwners, declaredObjects, confirm };
|
|
922
963
|
|
|
964
|
+
/**
|
|
965
|
+
* The CLI half of the Model-derived wire handshake, for the ops-Lambda venue only (the direct
|
|
966
|
+
* venue crosses no wire — the CLI runs the provisioning core in-process).
|
|
967
|
+
*
|
|
968
|
+
* Both of this command's ops invokes carry Model-derived data — `db:provision`'s declared
|
|
969
|
+
* `searchPath`, then `db:converge-ownership`'s declared owners and objects — and both go to the
|
|
970
|
+
* SAME stage, so the shared runner memoizes one `_health` round trip across them.
|
|
971
|
+
*/
|
|
972
|
+
const preflightModelContract = modelContractPreflight({
|
|
973
|
+
region: config.region,
|
|
974
|
+
fn: opsFunction(config),
|
|
975
|
+
directCommand: `everystack db:provision --stage ${flags.stage ?? '<stage>'} --direct`,
|
|
976
|
+
// Provision-specific, and worth the extra clause: the operator's likely next question is
|
|
977
|
+
// "where does --direct get a connection from, and do I have to hand it one?" It does not —
|
|
978
|
+
// it reads the stage's own secret, so nothing lands on argv.
|
|
979
|
+
directHint: "it reads that connection from the stage's ADMIN_DATABASE_URL secret, so no credential reaches argv",
|
|
980
|
+
// This runs before either invoke, so nothing has been written: the refusal prints and exits.
|
|
981
|
+
onRefusal: 'exit',
|
|
982
|
+
});
|
|
983
|
+
|
|
923
984
|
// Generate BOTH login passwords and keep them in memory only — they are written straight
|
|
924
985
|
// into the secret store and are NEVER printed, logged, or returned to a human.
|
|
925
986
|
const { randomBytes } = await import('node:crypto');
|
|
926
987
|
const authPassword = randomBytes(24).toString('hex');
|
|
927
988
|
const adminPassword = randomBytes(24).toString('hex');
|
|
928
989
|
|
|
990
|
+
// The SELECT-only reader (the connection db:query runs on) is OPT-IN the first time: it mints a
|
|
991
|
+
// third credential and its grants take an ACCESS EXCLUSIVE lock on every table in the declared
|
|
992
|
+
// schemas, neither of which a routine re-provision should do by surprise. It is STICKY afterwards
|
|
993
|
+
// — a stage that already carries the secret keeps the reader converged on every later run, so a
|
|
994
|
+
// schema added later does not silently fall out of its reach.
|
|
995
|
+
const readerSecretExists = QUERY_URL_KEYS.some((k) => stageSecrets[k]);
|
|
996
|
+
const withReader = !!flags['with-reader'] || readerSecretExists;
|
|
997
|
+
const readerPassword = withReader ? randomBytes(24).toString('hex') : undefined;
|
|
998
|
+
const credentials = { authPassword, adminPassword, searchPath, rotationPairs: !!flags['rotation-pairs'], ...(readerPassword ? { readerPassword } : {}) };
|
|
999
|
+
|
|
929
1000
|
// Run the role chain over a direct connection to `url` — shared by the explicit direct venue
|
|
930
1001
|
// and the auto-fallback below.
|
|
931
1002
|
const provisionDirect = async (url: string): Promise<any> => {
|
|
@@ -934,7 +1005,7 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
934
1005
|
const { runner, end } = await createUrlRunner(url);
|
|
935
1006
|
try {
|
|
936
1007
|
// Credentials ONLY — ownership convergence runs separately, AFTER the secret is written.
|
|
937
|
-
return await runProvision(
|
|
1008
|
+
return await runProvision(credentials, {
|
|
938
1009
|
execute: async (statement) => runner(statement),
|
|
939
1010
|
connection: parseUrlConnection(url),
|
|
940
1011
|
// Additive-only: an already-live login role keeps its password (never reset on re-provision).
|
|
@@ -973,7 +1044,18 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
973
1044
|
conn = parseUrlConnection(directUrl);
|
|
974
1045
|
} else {
|
|
975
1046
|
try {
|
|
976
|
-
|
|
1047
|
+
await preflightModelContract('db:provision');
|
|
1048
|
+
// The Model-derived half of the payload, pinned against the SHARED declaration so a shape
|
|
1049
|
+
// change here cannot land without the shape pin (and therefore the contract bump) noticing.
|
|
1050
|
+
// The credentials half is generated locally and is not part of the wire contract.
|
|
1051
|
+
const provisionWire = {
|
|
1052
|
+
searchPath,
|
|
1053
|
+
modelContract: MODEL_WIRE_CONTRACT,
|
|
1054
|
+
} satisfies ProvisionWirePayload;
|
|
1055
|
+
result = await invokeAction(config.region, opsFunction(config), 'db:provision', {
|
|
1056
|
+
...credentials,
|
|
1057
|
+
...provisionWire,
|
|
1058
|
+
});
|
|
977
1059
|
} catch (err: any) {
|
|
978
1060
|
// Auto-fallback: no Ops Lambda (Unknown action), but an ADMIN_DATABASE_URL secret exists —
|
|
979
1061
|
// provision directly over it. Master never crosses the CLI; the operator sets nothing new.
|
|
@@ -1013,6 +1095,12 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
1013
1095
|
: (result.adminVerified === true ? 'connectivity verified' : 'connectivity NOT verified');
|
|
1014
1096
|
success(`Operator login ready: ${result.adminRole} (LOGIN, INHERIT owner) — ${adminMsg}`);
|
|
1015
1097
|
}
|
|
1098
|
+
if (result.readerRole) {
|
|
1099
|
+
const readerMsg = result.readerPasswordSet === false ? 'existing login preserved' : 'password set';
|
|
1100
|
+
success(`Read-only login ready: ${result.readerRole} (LOGIN, NOINHERIT, no BYPASSRLS, SELECT only) — ${readerMsg}`);
|
|
1101
|
+
} else if (withReader) {
|
|
1102
|
+
warn('No read-only login was created — this @everystack/server predates the SELECT-only reader. Upgrade the server pin and re-run; db:query keeps running on the operator connection until then.');
|
|
1103
|
+
}
|
|
1016
1104
|
|
|
1017
1105
|
// Ownership convergence runs AFTER the secret write (below) — never before. Declared here so the
|
|
1018
1106
|
// final exit status can see it.
|
|
@@ -1026,8 +1114,11 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
1026
1114
|
// review-then-`--confirm` flow), and nothing converged. Credentials are already saved on both
|
|
1027
1115
|
// paths by the time this runs, so it can strand nothing.
|
|
1028
1116
|
const runConvergence = async (): Promise<void> => {
|
|
1029
|
-
|
|
1030
|
-
|
|
1117
|
+
// The reader's read grants ride this same step, for the same reason ADOPT does: they can stall
|
|
1118
|
+
// on a lock, and nothing that can stall may sit between the password swap and the secret write.
|
|
1119
|
+
const readerRole = result.readerRole as string | undefined;
|
|
1120
|
+
if (!declaredOwners.length && !declaredObjects.length && !readerRole) return;
|
|
1121
|
+
const ownerPayload = { ...ownershipPayload, adminRole: result.adminRole, ...(readerRole ? { readerRole, searchPath } : {}) };
|
|
1031
1122
|
let owned: any = {};
|
|
1032
1123
|
try {
|
|
1033
1124
|
if (directUrl) {
|
|
@@ -1046,7 +1137,16 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
1046
1137
|
await end?.();
|
|
1047
1138
|
}
|
|
1048
1139
|
} else {
|
|
1049
|
-
|
|
1140
|
+
// Same stage, same handshake — memoized, so this normally costs no extra round trip. It
|
|
1141
|
+
// still stands on its own: this is the invoke that actually REASSIGNS OWNERSHIP from
|
|
1142
|
+
// Model-derived data, so it must never dispatch to an unverified stage.
|
|
1143
|
+
await preflightModelContract('db:converge-ownership');
|
|
1144
|
+
owned = await invokeAction(
|
|
1145
|
+
config.region,
|
|
1146
|
+
opsFunction(config),
|
|
1147
|
+
'db:converge-ownership',
|
|
1148
|
+
{ ...ownerPayload, modelContract: MODEL_WIRE_CONTRACT } satisfies OwnershipWirePayload,
|
|
1149
|
+
);
|
|
1050
1150
|
}
|
|
1051
1151
|
} catch (err: any) {
|
|
1052
1152
|
warn(`Ownership convergence failed (credentials are already saved — nothing stranded): ${err.message}`);
|
|
@@ -1066,6 +1166,16 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
1066
1166
|
for (const f of adopt.findings ?? []) warn(` audit (${f.severity}): ${f.identity} — ${f.detail}`);
|
|
1067
1167
|
if (adopt.reassigned) success(`Ownership adoption: ${adopt.reassigned} object(s) reassigned (one transaction).`);
|
|
1068
1168
|
for (const r of adopt.refusals ?? []) { warn(`Ownership adoption: ${r}`); ownershipIncomplete = true; }
|
|
1169
|
+
// Warnings are the succeeded-but half: the reassignment landed, some cleanup after it did not.
|
|
1170
|
+
// Printed, never counted as incomplete — nothing is left to converge.
|
|
1171
|
+
for (const w of adopt.warnings ?? []) warn(`Ownership adoption: ${w}`);
|
|
1172
|
+
}
|
|
1173
|
+
const reader = owned.readerGrants;
|
|
1174
|
+
if (reader) {
|
|
1175
|
+
if (reader.granted) success(`Read-only grants: ${reader.granted} statement(s) applied for '${result.readerRole}'.`);
|
|
1176
|
+
// A reader that cannot read is a failure, not a warning to skip: db:query would connect and
|
|
1177
|
+
// then fail on every table. Same non-zero exit as an un-converged owner.
|
|
1178
|
+
for (const r of reader.refusals) { warn(`Read-only grants: ${r}`); ownershipIncomplete = true; }
|
|
1069
1179
|
}
|
|
1070
1180
|
};
|
|
1071
1181
|
|
|
@@ -1074,7 +1184,9 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
1074
1184
|
// store on a no-op re-provision (the footgun that reset a live password then locked the stage out).
|
|
1075
1185
|
const wroteAuth = result.passwordSet !== false;
|
|
1076
1186
|
const wroteAdmin = !!result.adminRole && result.adminPasswordSet !== false;
|
|
1077
|
-
|
|
1187
|
+
// A brand-new reader on an already-provisioned stage is NOT a no-op: its secret must be written.
|
|
1188
|
+
const wroteReader = !!result.readerRole && result.readerPasswordSet !== false;
|
|
1189
|
+
if (!wroteAuth && !wroteAdmin && !wroteReader) {
|
|
1078
1190
|
// Both roles preserved. Only a true no-op if the secrets backing them exist and name them;
|
|
1079
1191
|
// a role that exists WITH LOGIN but has no matching secret is a half-done prior provision that
|
|
1080
1192
|
// plain db:provision cannot recover — refuse with the --rotate remedy rather than report success.
|
|
@@ -1134,6 +1246,7 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
1134
1246
|
|
|
1135
1247
|
const authUrl = wroteAuth ? mint(result.loginRole, authPassword) : undefined;
|
|
1136
1248
|
const adminUrl = wroteAdmin && result.adminRole ? mint(result.adminRole, adminPassword) : undefined;
|
|
1249
|
+
const readerUrl = wroteReader && result.readerRole && readerPassword ? mint(result.readerRole, readerPassword) : undefined;
|
|
1137
1250
|
|
|
1138
1251
|
// Write the secrets directly to the SST secret store — never display them. One blob
|
|
1139
1252
|
// write updates every key together, so there is no window where the API side is the
|
|
@@ -1142,7 +1255,7 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
1142
1255
|
let plan: ProvisionSecretPlan;
|
|
1143
1256
|
try {
|
|
1144
1257
|
const secrets = { ...stageSecrets };
|
|
1145
|
-
plan = buildProvisionSecretPlan({ result, authUrl, adminUrl, existing: secrets });
|
|
1258
|
+
plan = buildProvisionSecretPlan({ result, authUrl, adminUrl, readerUrl, existing: secrets });
|
|
1146
1259
|
Object.assign(secrets, plan.updates);
|
|
1147
1260
|
await putSstSecrets(secretsCtx, secrets);
|
|
1148
1261
|
} catch (err: any) {
|
|
@@ -1175,6 +1288,11 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
1175
1288
|
info(' Ops: declare `new sst.Secret("AdminDatabaseUrl", "")` and link it to the Ops function —');
|
|
1176
1289
|
info(` operator tasks then run as ${result.adminRole}, not the Postgres master.`);
|
|
1177
1290
|
}
|
|
1291
|
+
if (result.readerRole) {
|
|
1292
|
+
info(' Read: declare `new sst.Secret("QUERY_DATABASE_URL", "")`, link it to the Ops function, and');
|
|
1293
|
+
info(' pass it to dbPlugin as `queryDatabaseUrl` — db:query then runs as the SELECT-only');
|
|
1294
|
+
info(` role (${result.readerRole}). Until you do, db:query keeps running on the operator connection.`);
|
|
1295
|
+
}
|
|
1178
1296
|
info(`Verify: everystack db:doctor --stage ${flags.stage}`);
|
|
1179
1297
|
|
|
1180
1298
|
// Credentials are done; ownership was left un-converged. Exit non-zero so CI and the operator act
|
package/src/cli/db-source.ts
CHANGED
|
@@ -196,7 +196,14 @@ export interface UrlProbeRunner {
|
|
|
196
196
|
end: () => Promise<void>;
|
|
197
197
|
}
|
|
198
198
|
|
|
199
|
-
/**
|
|
199
|
+
/**
|
|
200
|
+
* Thrown to abort the probe transaction; never escapes `probe`.
|
|
201
|
+
*
|
|
202
|
+
* The server-side twin (`runRolledBack` in `@everystack/server` plugin.ts) aborts the same way
|
|
203
|
+
* — a private value recognised by IDENTITY — but throws a drizzle `TransactionRollbackError`
|
|
204
|
+
* instance, because there it runs inside a drizzle transaction. This venue is raw postgres.js
|
|
205
|
+
* `sql.begin()`: no drizzle, no `TransactionRollbackError`, so the Symbol stays.
|
|
206
|
+
*/
|
|
200
207
|
const PROBE_ROLLBACK = Symbol('authz_probe_rollback');
|
|
201
208
|
|
|
202
209
|
/**
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The function/view/trigger CATCH-UP, as one call — what `db:apply` runs after a plan lands.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS. Applying a plan only ever changed tables, columns and grants. Everything
|
|
5
|
+
* built ON them — functions, views, materialized views, triggers — was a second command, and a
|
|
6
|
+
* second command is a thing a person forgets. One consumer deployed half a database that way
|
|
7
|
+
* (the apply said "success"; the views were a release behind). Then the same split produced two
|
|
8
|
+
* stages that disagreed about who may read what, to the people building this fix. So the second
|
|
9
|
+
* command stops being something a human runs: db:apply does it, every time, against the target
|
|
10
|
+
* it just applied to.
|
|
11
|
+
*
|
|
12
|
+
* WHAT IS NOT HERE. No DDL logic. `executeReconcile` (commands/db-reconcile.ts) is THE core that
|
|
13
|
+
* plans and applies the declared objects — db:check's from-scratch build, db:sync and
|
|
14
|
+
* db:reconcile --apply already route through it, and so does this. What this module owns is only
|
|
15
|
+
* the VENUE: run it on the connection the apply is already holding, or credential-free in the
|
|
16
|
+
* stage's ops Lambda, exactly as `db:reconcile --apply --stage` does.
|
|
17
|
+
*
|
|
18
|
+
* FAILURE IS A THROW. Every way the catch-up can fail — a refusal it will not apply over
|
|
19
|
+
* (drift, a blocked chain), an error from the DDL, an error from the ops Lambda — leaves this
|
|
20
|
+
* function as an exception. The caller decides what that means for its exit code and its
|
|
21
|
+
* wording; db:apply's answer is plain-output's one-line partial-failure report.
|
|
22
|
+
*
|
|
23
|
+
* ONE EXCEPTION, and the caller chooses it: a stage that speaks a different wire contract. See
|
|
24
|
+
* `onRefusal` — a caller that has already written asks for a throw, a caller that has not asks
|
|
25
|
+
* for an exit and lets the refusal print on its own terms. Both callers say which; there is no
|
|
26
|
+
* default to inherit by forgetting.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import type { QueryRunner } from './authz-contract';
|
|
30
|
+
import type { SessionRunner } from './session';
|
|
31
|
+
import { executeReconcile, type ReconcileRun } from './commands/db-reconcile';
|
|
32
|
+
import { loadDeclaredDerived, type DeclaredDerived } from './declared-derived';
|
|
33
|
+
import { modelContractPreflight } from './model-wire-lane';
|
|
34
|
+
import { MODEL_WIRE_CONTRACT, type ReconcileWirePayload } from '../wire';
|
|
35
|
+
import { resolveConfig, opsFunction } from './config';
|
|
36
|
+
import { invokeAction } from './aws';
|
|
37
|
+
import { currentGitRef } from './state-apply';
|
|
38
|
+
|
|
39
|
+
/** Where the catch-up runs. The two lanes db:apply itself already has. */
|
|
40
|
+
export type CatchUpVenue =
|
|
41
|
+
/** On a connection the caller holds (--database-url, or --stage --direct). */
|
|
42
|
+
| { kind: 'direct'; runner: QueryRunner; session: SessionRunner }
|
|
43
|
+
/** Credential-free in the stage's ops Lambda — the operator never holds a URL. */
|
|
44
|
+
| { kind: 'stage'; stage: string };
|
|
45
|
+
|
|
46
|
+
export interface CatchUpOptions {
|
|
47
|
+
/** The --models flag, as given. Resolved by the loader, exactly as every other verb resolves it. */
|
|
48
|
+
models?: string;
|
|
49
|
+
/**
|
|
50
|
+
* Test seam: the declared layer, instead of loading it from the barrel. Jest cannot
|
|
51
|
+
* dynamic-import a .ts barrel, so a suite that wants the REAL reconcile against a REAL
|
|
52
|
+
* database hands the descriptors over the way the loader would have.
|
|
53
|
+
*/
|
|
54
|
+
declared?: DeclaredDerived | null;
|
|
55
|
+
/** Called once it is known there is something to catch up — never for a checkout that declares none. */
|
|
56
|
+
onStart?: (declared: number) => void;
|
|
57
|
+
/**
|
|
58
|
+
* What a stage's wire-contract refusal does. THE CALLER DECIDES, because only the caller knows
|
|
59
|
+
* what has already been written.
|
|
60
|
+
*
|
|
61
|
+
* REQUIRED, with no default. `'exit'` prints the refusal and exits — the right answer when
|
|
62
|
+
* nothing has been written yet, because the refusal itself names the skew, says nothing was
|
|
63
|
+
* sent, and offers the `--direct` venue. `'throw'` hands the caller a `ModelContractError` to
|
|
64
|
+
* render instead — what
|
|
65
|
+
* db:apply's POST-plan catch-up needs, where the table and column changes have already committed
|
|
66
|
+
* and the operator is owed one sentence about that before the process ends.
|
|
67
|
+
*
|
|
68
|
+
* Hardcoding either one here is a bug: this function has two callers with opposite
|
|
69
|
+
* post-conditions, and the one that had written nothing spent a release telling operators to
|
|
70
|
+
* rerun the command that had just failed.
|
|
71
|
+
*
|
|
72
|
+
* It is required rather than defaulted for the same reason. A default is a hardcode that
|
|
73
|
+
* only LOOKS like a choice: a caller that forgets it inherits the wrong post-condition in
|
|
74
|
+
* silence, which is exactly the bug above. If a default ever has to come back, it defaults
|
|
75
|
+
* to `'throw'` — a caller wrongly handed an exception renders a clumsy error, a caller
|
|
76
|
+
* wrongly handed an exit loses the sentence its operator was owed.
|
|
77
|
+
*/
|
|
78
|
+
onRefusal: 'exit' | 'throw';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface CatchUpOutcome {
|
|
82
|
+
/** How many function/view/trigger objects the checkout declares. 0 = there was nothing to do. */
|
|
83
|
+
declared: number;
|
|
84
|
+
/** The reconcile that ran, or null when nothing was declared. */
|
|
85
|
+
run: ReconcileRun | null;
|
|
86
|
+
/** The DDL that actually landed — what the receipt folds into its change list. */
|
|
87
|
+
statements: string[];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Everything a caught-up run needs to say about itself when it refuses or throws. */
|
|
91
|
+
export class CatchUpError extends Error {}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Bring the declared functions, views, matviews and triggers in line with the checkout, on
|
|
95
|
+
* `venue`. Returns what landed; throws {@link CatchUpError} (or whatever the reconcile threw)
|
|
96
|
+
* when it did not.
|
|
97
|
+
*/
|
|
98
|
+
export async function catchUpDerived(
|
|
99
|
+
venue: CatchUpVenue,
|
|
100
|
+
opts: CatchUpOptions,
|
|
101
|
+
): Promise<CatchUpOutcome> {
|
|
102
|
+
const declared = opts.declared !== undefined ? opts.declared : await loadDeclaredDerived(opts.models);
|
|
103
|
+
const objects = declared?.objects ?? [];
|
|
104
|
+
if (objects.length === 0) return { declared: 0, run: null, statements: [] };
|
|
105
|
+
opts.onStart?.(objects.length);
|
|
106
|
+
|
|
107
|
+
const options = {
|
|
108
|
+
apply: true,
|
|
109
|
+
declared: objects,
|
|
110
|
+
renamedTables: declared?.renamedTables,
|
|
111
|
+
actor: process.env.USER ?? null,
|
|
112
|
+
gitRef: currentGitRef(),
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
let run: ReconcileRun;
|
|
116
|
+
if (venue.kind === 'direct') {
|
|
117
|
+
run = await executeReconcile(venue.runner, venue.session, options);
|
|
118
|
+
} else {
|
|
119
|
+
const config = await resolveConfig(venue.stage);
|
|
120
|
+
// The ops Lambda CREATES AND REPLACES database objects from these descriptors, so verify the
|
|
121
|
+
// deployed stage understands their shape before sending them — the same handshake
|
|
122
|
+
// db:reconcile --apply --stage runs, for the same payload.
|
|
123
|
+
//
|
|
124
|
+
// WHO renders a refusal is the CALLER's call, never this function's — see `onRefusal`. A
|
|
125
|
+
// post-plan catch-up asks to throw, because its operator is owed "your changes are safe"
|
|
126
|
+
// before the process ends. A catch-up that is the whole command has written nothing, and
|
|
127
|
+
// asks to exit: print the skew, say nothing was sent, exit.
|
|
128
|
+
await modelContractPreflight({
|
|
129
|
+
region: config.region,
|
|
130
|
+
fn: opsFunction(config),
|
|
131
|
+
directCommand: `everystack db:apply --stage ${venue.stage} --direct`,
|
|
132
|
+
onRefusal: opts.onRefusal,
|
|
133
|
+
})('db:apply');
|
|
134
|
+
const result: any = await invokeAction(
|
|
135
|
+
config.region,
|
|
136
|
+
opsFunction(config),
|
|
137
|
+
'db:reconcile',
|
|
138
|
+
{ ...options, modelContract: MODEL_WIRE_CONTRACT } satisfies ReconcileWirePayload,
|
|
139
|
+
);
|
|
140
|
+
if (result?.error) throw new CatchUpError(String(result.error));
|
|
141
|
+
run = {
|
|
142
|
+
plan: result.plan,
|
|
143
|
+
applied: result.applied,
|
|
144
|
+
statements: result.statements ?? [],
|
|
145
|
+
refusal: result.refusal ?? undefined,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// A refusal is not a soft outcome here. db:reconcile prints it and exits 1; the catch-up did
|
|
150
|
+
// not land, so db:apply must say so and exit the same way.
|
|
151
|
+
if (run.refusal) throw new CatchUpError(run.refusal);
|
|
152
|
+
|
|
153
|
+
return { declared: objects.length, run, statements: run.applied ? (run.statements ?? []) : [] };
|
|
154
|
+
}
|