@everystack/cli 0.4.82 → 0.4.84

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.
@@ -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
- declaredOwners?: Array<{ owner: string; schema: string }>;
310
- declaredObjects?: Array<{ identity: string; schema: string; name: string; kind: 'function' | 'view' | 'materialized view'; secdef: boolean; currentOwner: string; declaredOwner: string }>;
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?: Array<{ owner: string; schema: string }>;
326
- declaredObjects?: Array<{ identity: string; schema: string; name: string; kind: 'function' | 'view' | 'materialized view'; secdef: boolean; currentOwner: string; declaredOwner: string }>;
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: Array<{ owner: string; schema: string }>;
486
- declaredObjects: Array<{ identity: string; schema: string; name: string; kind: 'function' | 'view' | 'materialized view'; secdef: boolean; currentOwner: string; declaredOwner: string }>;
526
+ declaredOwners: DeclaredOwner[];
527
+ declaredObjects: Adoptee[];
487
528
  } {
488
529
  const seen = new Set<string>();
489
- const declaredOwners: Array<{ owner: string; schema: string }> = [];
490
- const declaredObjects: Array<{ identity: string; schema: string; name: string; kind: 'function' | 'view' | 'materialized view'; secdef: boolean; currentOwner: string; declaredOwner: string }> = [];
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: Array<{ owner: string; schema: string }> = [];
898
- let declaredObjects: Array<{ identity: string; schema: string; name: string; kind: 'function' | 'view' | 'materialized view'; secdef: boolean; currentOwner: string; declaredOwner: string }> = [];
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({ authPassword, adminPassword, searchPath, rotationPairs: !!flags['rotation-pairs'] }, {
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
- result = await invokeAction(config.region, opsFunction(config), 'db:provision', { authPassword, adminPassword, searchPath, rotationPairs: !!flags['rotation-pairs'] });
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
- if (!declaredOwners.length && !declaredObjects.length) return;
1030
- const ownerPayload = { ...ownershipPayload, adminRole: result.adminRole };
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
- owned = await invokeAction(config.region, opsFunction(config), 'db:converge-ownership', ownerPayload);
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
- if (!wroteAuth && !wroteAdmin) {
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
@@ -196,7 +196,14 @@ export interface UrlProbeRunner {
196
196
  end: () => Promise<void>;
197
197
  }
198
198
 
199
- /** Thrown to abort the probe transaction; never escapes `probe`. */
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
+ }