@everystack/server 0.4.10 → 0.4.12

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.
Files changed (3) hide show
  1. package/package.json +3 -3
  2. package/src/db.ts +19 -0
  3. package/src/plugin.ts +77 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/server",
3
- "version": "0.4.10",
3
+ "version": "0.4.12",
4
4
  "description": "Server runtime primitives for Lambda — event adapters, routing, SSR, image processing",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "Scalable Technology, Inc. <licensing@scalable.technology>",
@@ -159,9 +159,9 @@
159
159
  "sst": "4.13.1",
160
160
  "ts-jest": "29.4.9",
161
161
  "typescript": "5.9.3",
162
- "@everystack/cli": "0.4.24",
162
+ "@everystack/pg-tools": "0.4.1",
163
163
  "@everystack/auth": "0.4.0",
164
- "@everystack/pg-tools": "0.4.0"
164
+ "@everystack/cli": "0.4.28"
165
165
  },
166
166
  "scripts": {
167
167
  "test": "jest",
package/src/db.ts CHANGED
@@ -110,6 +110,25 @@ export function getAdminDatabaseUrl(): string | null {
110
110
  return null;
111
111
  }
112
112
 
113
+ /**
114
+ * The OPERATOR connection the privileged write lanes run on: the provisioned admin
115
+ * (migrator/owner) credential when present, else the linked SST Postgres master. Never the
116
+ * least-privilege authenticator (`DATABASE_URL`) — these lanes run DDL the authenticator
117
+ * cannot. Reports which source won so the caller can log the venue (not the credential).
118
+ *
119
+ * This is the resolution the `--direct` write lanes and `db:swap --stage` share (the
120
+ * stage-write-lanes foundation): the ops action returns this URL to an IAM-gated caller,
121
+ * which holds it in process memory and never prints it. Returns null when no operator
122
+ * connection is configured at all.
123
+ */
124
+ export function getOperatorDatabaseUrl(): { url: string; source: 'admin' | 'master' } | null {
125
+ const admin = getAdminDatabaseUrl();
126
+ if (admin) return { url: admin, source: 'admin' };
127
+ const master = getMasterDatabaseUrl();
128
+ if (master) return { url: master, source: 'master' };
129
+ return null;
130
+ }
131
+
113
132
  export function getJwtSecret(): Uint8Array {
114
133
  // PascalCase Secret (SST_RESOURCE_JwtSecret → { value })
115
134
  const pascal = sstLink('JwtSecret')?.value as string | undefined;
package/src/plugin.ts CHANGED
@@ -625,6 +625,25 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
625
625
  return { host: db.host, port: db.port, database: db.database, username: db.username, password: db.password };
626
626
  };
627
627
 
628
+ // --- db:operator-url ---
629
+ // The stage-write-lanes foundation (brick 1): resolve the OPERATOR connection
630
+ // (admin/migrator, else the linked master — never the least-privilege authenticator)
631
+ // and return it to the IAM-gated caller. The CLI's `--direct` write verbs and
632
+ // `db:swap --stage` invoke this, hold the URL in process memory, execute CLI-side with
633
+ // an unbounded clock, and never print it. The URL travels only in the Lambda invoke
634
+ // response — the same in-process channel db:psql uses for its components.
635
+ actions['db:operator-url'] = async () => {
636
+ const { getOperatorDatabaseUrl } = await import('./db');
637
+ const operator = getOperatorDatabaseUrl();
638
+ if (!operator) {
639
+ return {
640
+ error:
641
+ 'no operator connection available — provision an admin credential (ADMIN_DATABASE_URL / AdminDatabaseUrl) or link an sst.aws.Postgres component for the master fallback.',
642
+ };
643
+ }
644
+ return { url: operator.url, source: operator.source };
645
+ };
646
+
628
647
  // --- db:query ---
629
648
  actions['db:query'] = async (payload, _ctx) => {
630
649
  const { sql: querySql } = payload as { sql: string };
@@ -1015,13 +1034,68 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
1015
1034
  }
1016
1035
  };
1017
1036
 
1037
+ // --- db:backfill — the one-shot data-job lane, stage-side (stage-write-lanes brick 4) ---
1038
+ // db:backfill was direct-connection only: the sanctioned agent path for a committed,
1039
+ // content-addressed, idempotent data job ended at "paste SQL into psql". This runs the
1040
+ // same backfill core on the operator connection: the CLI ships the *.sql files, this plans
1041
+ // (by content identity, against everystack.backfill_log), and under apply runs the pending
1042
+ // jobs in order and records each. A least-privileged operator never holds a URL for a small
1043
+ // data job; the --direct lane (CLI-side) is the escape for jobs too big for the 900s clock.
1044
+ actions['db:backfill'] = async (payload, _ctx) => {
1045
+ const p = (payload ?? {}) as {
1046
+ files?: { file: string; sql: string }[];
1047
+ apply?: boolean;
1048
+ markApplied?: string;
1049
+ actor?: string | null;
1050
+ gitRef?: string | null;
1051
+ };
1052
+ try {
1053
+ const {
1054
+ planBackfills, readBackfillLog, executeBackfills, markBackfillApplied,
1055
+ } = await import('@everystack/cli/backfill');
1056
+ const { sql } = await import('drizzle-orm');
1057
+ const runner = async (query: string): Promise<any[]> => {
1058
+ const result: any = await opsDb.execute(sql.raw(query));
1059
+ return Array.isArray(result) ? result : result?.rows ?? [];
1060
+ };
1061
+ const files = p.files ?? [];
1062
+ const opts = { actor: p.actor ?? null, gitRef: p.gitRef ?? null };
1063
+
1064
+ // Adopt hatch: record a file as applied WITHOUT running it.
1065
+ if (p.markApplied) {
1066
+ const file = files.find((f) => f.file.endsWith(`/${p.markApplied}`) || f.file === p.markApplied);
1067
+ if (!file) return { error: `--mark-applied names a file not shipped: ${p.markApplied}` };
1068
+ const identity = await markBackfillApplied(runner, file as any, opts);
1069
+ return { markedApplied: file.file, identity };
1070
+ }
1071
+
1072
+ const plan = await (async () => planBackfills(files as any, await readBackfillLog(runner)))();
1073
+ const planOut = {
1074
+ applied: plan.applied,
1075
+ pending: plan.pending.map(({ sql: _sql, ...rest }: any) => rest),
1076
+ blocked: plan.blocked,
1077
+ };
1078
+ if (!p.apply) return { plan: planOut, applied: false };
1079
+
1080
+ const result = await executeBackfills(runner, plan, opts);
1081
+ return {
1082
+ plan: planOut,
1083
+ applied: true,
1084
+ ran: result.ran,
1085
+ failed: result.failed ?? null,
1086
+ };
1087
+ } catch (err: any) {
1088
+ return { error: err?.message || String(err) };
1089
+ }
1090
+ };
1091
+
1018
1092
  // --- db:refresh — the credential-free DATA lane's refresh half (the reconcile-lane twin) ---
1019
1093
  // db:reconcile deploys the derived DEFINITION but no-ops on an unchanged def, so a pure
1020
1094
  // DATA-changed matview refresh had no credential-free path. This runs the refresh HERE on
1021
1095
  // the operator connection: the CLI ships the declared matview identities in dependency order,
1022
1096
  // this REFRESHes each. A least-privileged operator never holds a URL for the routine refresh.
1023
1097
  actions['db:refresh'] = async (payload, _ctx) => {
1024
- const p = (payload ?? {}) as { matviews?: string[] };
1098
+ const p = (payload ?? {}) as { matviews?: string[]; verifyNonempty?: boolean };
1025
1099
  try {
1026
1100
  const { executeRefresh } = await import('@everystack/cli/refresh');
1027
1101
  const { sql } = await import('drizzle-orm');
@@ -1029,8 +1103,8 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
1029
1103
  const result: any = await opsDb.execute(sql.raw(query));
1030
1104
  return Array.isArray(result) ? result : result?.rows ?? [];
1031
1105
  };
1032
- const run = await executeRefresh(runner, { matviews: p.matviews ?? [] });
1033
- return { refreshed: run.refreshed, statements: run.statements, failed: run.failed ?? null };
1106
+ const run = await executeRefresh(runner, { matviews: p.matviews ?? [], verifyNonempty: p.verifyNonempty });
1107
+ return { refreshed: run.refreshed, statements: run.statements, failed: run.failed ?? null, empty: run.empty ?? null };
1034
1108
  } catch (err: any) {
1035
1109
  return { error: err?.message || String(err) };
1036
1110
  }