@everystack/server 0.4.30 → 0.4.32

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/src/plugin.ts CHANGED
@@ -13,6 +13,10 @@ import type {
13
13
  APIGatewayProxyStructuredResultV2,
14
14
  } from 'aws-lambda';
15
15
  import type { ApplyPlanOptions } from '@everystack/cli/apply';
16
+ // The Model-derived wire payloads, declared once in @everystack/model/wire and re-exported by
17
+ // @everystack/cli/wire (the CLI is the only @everystack/* package this bundle can resolve the
18
+ // declaration through — see packages/cli/src/wire.ts). Type-only, so nothing new is bundled.
19
+ import type { ReconcileWirePayload } from '@everystack/cli/wire';
16
20
 
17
21
  /**
18
22
  * The stage lane's apply options, with `checkDescent` made REQUIRED. The fast-forward rule is the
@@ -30,6 +34,7 @@ import {
30
34
  responseToResult,
31
35
  log,
32
36
  } from './index';
37
+ import { splitSqlStatements, stripSqlComments } from './sql-lex';
33
38
 
34
39
  // --- Plugin Types ---
35
40
 
@@ -208,12 +213,19 @@ export function createPluginLambdaHandler(
208
213
  // post-write alarm). Stamped on BOTH branches, so a stage whose DB init is failing still
209
214
  // tells the CLI its contract — an absent field means the deployed server predates the
210
215
  // handshake. Old servers simply omit it; the CLI reads absent as "redeploy the stage".
216
+ //
217
+ // `modelContract` is the same handshake for the MODEL-DERIVED payloads (db:provision,
218
+ // db:converge-ownership, db:reconcile). Two INDEPENDENT numbers, deliberately: applyContract
219
+ // versions db:apply's persisted plan artifact, modelContract versions an ephemeral payload
220
+ // computed and consumed in one round trip. A CLI checks whichever one its verb rides.
211
221
  const { APPLY_CONTRACT_VERSION } = await import('@everystack/cli/apply');
222
+ const { MODEL_WIRE_CONTRACT } = await import('@everystack/cli/wire');
223
+ const contracts = { applyContract: APPLY_CONTRACT_VERSION, modelContract: MODEL_WIRE_CONTRACT };
212
224
  try {
213
225
  const { actions } = await ensureInitialized();
214
- return { ok: true, mode: httpEnabled ? 'http' : 'ops', actions: Object.keys(actions).sort(), applyContract: APPLY_CONTRACT_VERSION };
226
+ return { ok: true, mode: httpEnabled ? 'http' : 'ops', actions: Object.keys(actions).sort(), ...contracts };
215
227
  } catch (error) {
216
- return { ok: false, error: String(error), applyContract: APPLY_CONTRACT_VERSION };
228
+ return { ok: false, error: String(error), ...contracts };
217
229
  }
218
230
  }
219
231
  const { ctx, actions } = await ensureInitialized();
@@ -487,6 +499,56 @@ export function ssrPlugin(options: SsrPluginOptions = {}): (ctx: PluginContext)
487
499
 
488
500
  // --- Database Actions Plugin ---
489
501
 
502
+ /**
503
+ * Run `fn` inside a transaction that ALWAYS rolls back, and return what `fn` returned.
504
+ *
505
+ * Three actions need exactly this: `db:query` (read-only by construction), `db:authz:probe`
506
+ * (writes on purpose, keeps none) and the console sandbox.
507
+ *
508
+ * The abort is a private object thrown by THIS function and recognised by IDENTITY. Nothing
509
+ * else can reference `signal`, so `err !== signal` is exact: the only error this swallows is
510
+ * its own, and every other error propagates untouched — a failed statement is still a failure.
511
+ *
512
+ * Why not `tx.rollback()`. drizzle's `rollback()` is nothing but `throw new
513
+ * TransactionRollbackError()` — there is no database call behind it, and postgres.js rolls the
514
+ * transaction back because the callback THREW, whatever it threw (proven live in
515
+ * `__tests__/tx-rollback-live.test.ts`). So the method buys nothing, and it costs: the console
516
+ * sandbox binds the expression's `db` to this very `tx`, so an expression can call
517
+ * `db.rollback()` — indistinguishable from ours under any check on the ERROR CLASS, which
518
+ * turned a failed expression into a silent `{ result: undefined }` — or replace it,
519
+ * `db.rollback = () => {}`, after which the callback returns normally and the driver COMMITS.
520
+ * Throwing our own value keeps the mechanism out of the work's reach entirely.
521
+ *
522
+ * The class stays drizzle's `TransactionRollbackError` so the abort reads correctly in
523
+ * drizzle's own taxonomy and in logs; identity, not the class, is what decides.
524
+ *
525
+ * `value!` is honest: `signal` is thrown only after the assignment above it.
526
+ *
527
+ * The direct-connection twin of `db:authz:probe` — `createUrlProbeRunner`'s `probe()` in
528
+ * `packages/cli/src/cli/db-source.ts` — keeps its own identity-Symbol version of this on
529
+ * purpose: it runs on raw postgres.js `sql.begin()`, with no drizzle and no
530
+ * `TransactionRollbackError` in reach.
531
+ */
532
+ async function runRolledBack<T>(
533
+ db: any,
534
+ fn: (tx: any) => Promise<T>,
535
+ config?: Record<string, unknown>,
536
+ ): Promise<T> {
537
+ const { TransactionRollbackError } = await import('drizzle-orm');
538
+ // Per call, and never handed to `fn` — the work cannot reference it, so it cannot forge it.
539
+ const signal = new TransactionRollbackError();
540
+ let value!: T;
541
+ try {
542
+ await db.transaction(async (tx: any) => {
543
+ value = await fn(tx);
544
+ throw signal; // any throw aborts the transaction; this one is ours
545
+ }, config);
546
+ } catch (err: unknown) {
547
+ if (err !== signal) throw err;
548
+ }
549
+ return value;
550
+ }
551
+
490
552
  export interface DbPluginOptions {
491
553
  /** Absolute path to drizzle migrations folder. Optional — a db:sync app has no migrations;
492
554
  * migrate/db:reset then answer with a clear error while provision/console/psql still work. */
@@ -520,6 +582,23 @@ export interface DbPluginOptions {
520
582
  /** Optional read-replica URL for db:query. When unset, db:query runs on the operator
521
583
  * connection. (No env fallback — pass it explicitly if you have a replica.) */
522
584
  readDatabaseUrl?: string;
585
+ /**
586
+ * The SELECT-only connection `db:query` runs on — the `reader` login `everystack db:provision
587
+ * --with-reader` creates (QUERY_DATABASE_URL). Takes precedence over `readDatabaseUrl`.
588
+ *
589
+ * WHY it exists, and what it is NOT: db:query already runs its statement inside an explicit
590
+ * `READ ONLY` transaction that is always rolled back, so a plain statement cannot write. What
591
+ * that transaction does not fence is a function that opens its OWN connection (`dblink_exec`,
592
+ * `pg_background`): on a database where such an extension exists, a "read-only" query issued on
593
+ * the OPERATOR connection can still reach the operator's write privilege. Pointing db:query at a
594
+ * role that HAS no write privilege closes that side channel by construction.
595
+ *
596
+ * UNSET IS A SUPPORTED, DOCUMENTED FALLBACK, NOT AN ERROR: db:query then runs on the operator
597
+ * connection, protected by the read-only transaction alone. That is the pre-existing behaviour and
598
+ * it is fine for a database without those extensions — but the caveat is real, so it is stated in
599
+ * `db:psql --help` and docs/cli.md rather than buried here.
600
+ */
601
+ queryDatabaseUrl?: string;
523
602
  /** pgSettings callback for console auth context — applied via SET LOCAL when user is present */
524
603
  pgSettings?: (user: Record<string, unknown> | null) => Record<string, string>;
525
604
  /**
@@ -582,6 +661,30 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
582
661
  // connection can be least-privilege and RLS-gated.
583
662
  const opsDb = ctx.adminDb ?? ctx.db;
584
663
 
664
+ /**
665
+ * The Model-derived wire-contract gate — run FIRST in every action that consumes a payload the
666
+ * CLI computed from the Models barrel (db:provision, db:converge-ownership, db:reconcile).
667
+ * Returns the action's `{ error }` when the caller's contract does not match this server's,
668
+ * and null when it does. See model-wire.ts for why missing is a refusal and not a default.
669
+ *
670
+ * PRE-DISPATCH by construction: it resolves no connection and runs no SQL, so calling it
671
+ * before any other work in the action is what keeps a skewed payload away from the database.
672
+ * A `@everystack/cli/wire` that will not load is ALSO a refusal — a server that cannot state
673
+ * its own contract must not accept a payload on the caller's say-so.
674
+ */
675
+ const modelWireGate = async (verb: string, payload: unknown): Promise<{ error: string } | null> => {
676
+ const { modelWireRefusal, modelWireUnavailableRefusal } = await import('./model-wire');
677
+ let enforced: number;
678
+ try {
679
+ ({ MODEL_WIRE_CONTRACT: enforced } = await import('@everystack/cli/wire'));
680
+ } catch (err) {
681
+ return { error: modelWireUnavailableRefusal(verb, err) };
682
+ }
683
+ const declared = (payload as { modelContract?: unknown } | null | undefined)?.modelContract;
684
+ const refusal = modelWireRefusal(verb, declared, enforced);
685
+ return refusal ? { error: refusal } : null;
686
+ };
687
+
585
688
  // Resolve the logical-backup dependencies (bucket + operator URL) once —
586
689
  // shared by db:backup/db:backups/db:restore and by db:apply's auto-snapshot
587
690
  // before a destructive staged apply. Returns `{ error }` when unconfigured.
@@ -692,34 +795,142 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
692
795
  };
693
796
 
694
797
  // --- db:query ---
798
+ //
799
+ // READ-ONLY IS ENFORCED BY POSTGRESQL, NOT BY THIS FILE.
800
+ //
801
+ // The guard used to be a keyword blocklist over the statement text. A red team against a
802
+ // real PostgreSQL 16 proved that is not a security boundary, and cannot be made into one:
803
+ //
804
+ // - The blocklist is structurally incomplete. `WITH x AS (DELETE FROM users RETURNING 1)
805
+ // SELECT count(*) FROM x`, `SELECT ... INTO new_table`, `EXPLAIN ANALYZE <write>`,
806
+ // `COPY ... FROM`, `CALL <procedure>`, `MERGE`, and `DO $$ ... DELETE ... $$` all begin
807
+ // with a word outside the list and write for real. Postgres' grammar is open; a
808
+ // blocklist over it is a guess.
809
+ // - Checking one string and executing another is a bypass by construction. The old code
810
+ // tested `stripSqlComments(statement)` and ran the ORIGINAL, and comment removal used
811
+ // to delete the comment with no replacement — so `INSERT/**/INTO users VALUES (1)`
812
+ // checked as `INSERTINTO ...` (no match) and executed as a plain INSERT.
813
+ // - `\s` after a keyword is not a word boundary. `TRUNCATE"users"` and
814
+ // `UPDATE"users"SET name=1` are valid SQL with NO whitespace after the keyword.
815
+ //
816
+ // So the statement now runs inside an explicit READ ONLY transaction that ALWAYS rolls
817
+ // back. Postgres refuses every write above AFTER parsing, from inside the engine, where no
818
+ // text-level trick reaches. Two properties of that transaction are load-bearing:
819
+ //
820
+ // 1. It must be an explicit transaction block with the access mode ON THE BLOCK — not a
821
+ // session-level `SET SESSION CHARACTERISTICS` and not only a role-level
822
+ // `default_transaction_read_only`. Verified: under session characteristics alone, a
823
+ // `CALL` to a procedure that does `SET default_transaction_read_only = off; COMMIT;
824
+ // DELETE ...` escapes and deletes rows for real. Inside a real `BEGIN READ ONLY`
825
+ // block the same CALL errors.
826
+ // 2. Exactly ONE statement, still. Verified: inside a read-only block,
827
+ // `SET TRANSACTION READ WRITE` succeeds as the FIRST statement (no snapshot taken
828
+ // yet) and a following write commits. The one-statement rule is what forecloses that.
829
+ //
830
+ // The forced rollback (`runRolledBack`, shared with `db:authz:probe` below and the console
831
+ // sandbox) is belt and braces: nothing a read-only transaction could still have changed
832
+ // survives the call.
833
+ //
834
+ // ACCEPTED NARROWING: a few quasi-read constructs Postgres itself refuses inside ANY
835
+ // explicit transaction, or specifically inside a READ ONLY one, now fail here where they
836
+ // used to run under the old autocommit execution: `VACUUM`/bare `ANALYZE`/`REINDEX`/
837
+ // `CHECKPOINT` (forbidden in a transaction block, full stop) and `SELECT ... FOR UPDATE`/
838
+ // `FOR SHARE`/`nextval()` (forbidden specifically under read-only). None of these are
839
+ // writes this guard exists to stop; they're a real capability loss for an operator who
840
+ // used `db:query` for one of them. Use a direct connection (`db:psql` without `-c`, or
841
+ // `db:exec`) for those.
695
842
  actions['db:query'] = async (payload, _ctx) => {
696
843
  const { sql: querySql } = payload as { sql: string };
697
844
  if (!querySql) {
698
845
  return { error: 'SQL query is required' };
699
846
  }
847
+ // Exactly one statement, always — see property 2 above, and: a `^`-anchored check
848
+ // against the raw payload is defeated by a leading `-- comment` and by a second
849
+ // `;`-joined statement, which Postgres' simple query protocol runs regardless of what
850
+ // this code inspected. Comment-aware, dollar-quote-aware splitting closes both.
851
+ const statements = splitSqlStatements(querySql);
852
+ if (statements.length !== 1) {
853
+ return {
854
+ // Distinct from the `!querySql` case above: a payload WAS supplied, it just carried
855
+ // no executable statement (e.g. all comments) — telling the two apart matters to a
856
+ // caller branching on the message ("you forgot sql" vs "your sql was empty content").
857
+ error: statements.length === 0
858
+ ? 'SQL query contained no executable statement (comments/whitespace only)'
859
+ : `db:query runs exactly one statement (found ${statements.length}) — a second statement can otherwise re-arm the read-only transaction and write. Use db:exec for a multi-statement script.`,
860
+ };
861
+ }
862
+ const [statement] = statements;
863
+ // ADVISORY PRE-CHECK ONLY — NOT the security boundary.
864
+ // Its whole job is a fast, friendly error for the obvious cases before a connection is
865
+ // even opened. The read-only transaction below is what actually enforces read-only, and
866
+ // it catches everything this list misses. Do not add patterns here believing you are
867
+ // closing a hole; a hole here is not a hole in the guard. `\b`, not `\s`, so
868
+ // `TRUNCATE"users"` is still recognised.
700
869
  const writePatterns = [
701
- /^INSERT\s/i, /^UPDATE\s/i, /^DELETE\s/i, /^DROP\s/i,
702
- /^CREATE\s/i, /^ALTER\s/i, /^TRUNCATE\s/i, /^GRANT\s/i, /^REVOKE\s/i,
870
+ /^INSERT\b/i, /^UPDATE\b/i, /^DELETE\b/i, /^DROP\b/i,
871
+ /^CREATE\b/i, /^ALTER\b/i, /^TRUNCATE\b/i, /^GRANT\b/i, /^REVOKE\b/i,
703
872
  ];
704
- if (writePatterns.some(p => p.test(querySql.trim()))) {
873
+ if (writePatterns.some(p => p.test(stripSqlComments(statement).trim()))) {
705
874
  return {
706
875
  error: 'Write operations are not allowed via db:query. Use db:migrate, seed, or console for write operations.',
707
876
  };
708
877
  }
878
+ let queryDb: any = opsDb;
879
+ let adhocClient: { end: () => Promise<void> } | undefined;
880
+ const { sql } = await import('drizzle-orm');
709
881
  try {
710
- const { sql } = await import('drizzle-orm');
711
- let queryDb = opsDb;
712
- const readUrl = options.readDatabaseUrl;
882
+ // The SELECT-only reader first (a role with no write privilege to reach), then a read
883
+ // replica, then the operator connection — the documented fallback, fenced only by the
884
+ // read-only transaction below.
885
+ const readUrl = options.queryDatabaseUrl || options.readDatabaseUrl;
713
886
  if (readUrl) {
714
887
  const { drizzle } = await import('drizzle-orm/postgres-js');
715
888
  const postgres = (await import('postgres')).default;
716
- queryDb = drizzle(postgres(readUrl, { max: 1, idle_timeout: 20, connect_timeout: 10 }));
889
+ const client = postgres(readUrl, { max: 1, idle_timeout: 20, connect_timeout: 10 });
890
+ adhocClient = client;
891
+ queryDb = drizzle(client);
717
892
  }
718
- const result: any = await queryDb.execute(sql.raw(querySql));
719
- const rows = Array.isArray(result) ? result : result.rows || [];
893
+ // Never commit, not even a read — runRolledBack forces the rollback.
894
+ const rows = await runRolledBack<any[]>(
895
+ queryDb,
896
+ async (tx: any) => {
897
+ const result: any = await tx.execute(sql.raw(statement));
898
+ return Array.isArray(result) ? result : result?.rows ?? [];
899
+ },
900
+ { accessMode: 'read only' },
901
+ );
720
902
  return { rows };
721
903
  } catch (err: any) {
722
904
  return { error: err.message || String(err) };
905
+ } finally {
906
+ // A SESSION-level advisory lock survives ROLLBACK — `SELECT pg_advisory_lock(1)` is a
907
+ // read as far as the read-only transaction is concerned, and the lock outlives the
908
+ // transaction that took it. The ops connection is one long-lived connection per
909
+ // container (db.ts: `max: 1`), so a lock left behind here would be held until the
910
+ // container dies. Release everything this statement could have taken. Best-effort by
911
+ // design: a failure here must not mask the query's own result.
912
+ //
913
+ // THIS RELEASE ASSUMES ONE PHYSICAL CONNECTION. `pg_advisory_unlock_all()` only frees
914
+ // locks held by the backend it runs on, and session state (`SET search_path`, a temp
915
+ // table) is likewise per-backend. If `opsDb` were a pool, the release could land on a
916
+ // different connection than the one that took the lock — leaking it — and leftover
917
+ // session state could surface in an unrelated later call. `createAdminDb` pins the
918
+ // operator connection to `max: 1` for exactly these two reasons; the read-replica
919
+ // client built above is `max: 1` and closed per invocation for the same reason.
920
+ try {
921
+ await queryDb.execute(sql.raw('SELECT pg_advisory_unlock_all()'));
922
+ } catch {
923
+ // connection already gone / mock connection — nothing to release
924
+ }
925
+ // The read-replica client is built per invocation and used once; closing it is both
926
+ // the leak fix and a second guarantee that no session state outlives the call.
927
+ if (adhocClient) {
928
+ try {
929
+ await adhocClient.end();
930
+ } catch {
931
+ // already closed
932
+ }
933
+ }
723
934
  }
724
935
  };
725
936
 
@@ -736,19 +947,16 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
736
947
  return { error: 'db:authz:probe requires { setup, read } SQL strings' };
737
948
  }
738
949
  const { sql } = await import('drizzle-orm');
739
- const PROBE_ROLLBACK = Symbol('authz_probe_rollback');
740
- let rows: any[] = [];
950
+ let rows: any[];
741
951
  try {
742
- await opsDb.transaction(async (tx: any) => {
952
+ // Every probe write is discarded — runRolledBack forces the rollback.
953
+ rows = await runRolledBack<any[]>(opsDb, async (tx: any) => {
743
954
  await tx.execute(sql.raw(setup));
744
955
  const result: any = await tx.execute(sql.raw(read));
745
- rows = Array.isArray(result) ? result : result?.rows ?? [];
746
- throw PROBE_ROLLBACK; // discard every probe write
956
+ return Array.isArray(result) ? result : result?.rows ?? [];
747
957
  });
748
958
  } catch (err: unknown) {
749
- if (err !== PROBE_ROLLBACK) {
750
- return { error: (err as any)?.message || String(err) };
751
- }
959
+ return { error: (err as any)?.message || String(err) };
752
960
  }
753
961
  return { rows };
754
962
  };
@@ -824,6 +1032,12 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
824
1032
  // real connectivity BEFORE the authenticator password is set — the ordering that
825
1033
  // keeps a BYO-database app from sawing off its own ops plane (see provision.ts).
826
1034
  actions['db:provision'] = async (payload, _ctx) => {
1035
+ // FIRST, before any connection work: this payload's searchPath — and the declaredOwners /
1036
+ // declaredObjects its type accepts — are computed by the CLI from the Models barrel, so a
1037
+ // shape skew between that CLI and the one bundled here must refuse, not ride through.
1038
+ const skew = await modelWireGate('db:provision', payload);
1039
+ if (skew) return skew;
1040
+
827
1041
  const { sql } = await import('drizzle-orm');
828
1042
  const { runProvision } = await import('./provision');
829
1043
 
@@ -880,6 +1094,12 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
880
1094
  // is safely in the secret store: credentials (db:provision) → secret write (CLI) → this. A hard
881
1095
  // kill here strands nothing — the worst case is an un-converged owner db:reconcile refuses on.
882
1096
  actions['db:converge-ownership'] = async (payload, _ctx) => {
1097
+ // FIRST, before the master client is opened: declaredOwners/declaredObjects come straight
1098
+ // from the CLI's read of the Models barrel, and this action REASSIGNS OWNERSHIP from them —
1099
+ // a misread field here changes which role a SECURITY DEFINER object runs as.
1100
+ const skew = await modelWireGate('db:converge-ownership', payload);
1101
+ if (skew) return skew;
1102
+
883
1103
  const { sql } = await import('drizzle-orm');
884
1104
  const { convergeOwnership } = await import('./provision');
885
1105
  const { getAdoptMasterUrl } = await import('./db');
@@ -1034,8 +1254,11 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
1034
1254
  * later descent ignores this row's git_ref. */
1035
1255
  dirty?: boolean;
1036
1256
  descentVerdict?: { ok: boolean; reason?: string };
1037
- /** When the CLI honoured a --attest-lineage-lost waiver, the note to record in the
1038
- * schema_log outcome — the durable audit of the deliberate descent waiver. */
1257
+ /** What the CLI's descent step decided, to record in the schema_log outcome
1258
+ * (`applied — <note>`): a honoured --attest-lineage-lost waiver, or a PROVEN DRIFT
1259
+ * origin (`descent: drift-origin proven (never-declared | hand-edited since … | applied
1260
+ * from a DIRTY worktree at …)`) — the
1261
+ * record that makes this row the origin every later apply descends from. */
1039
1262
  descentAttested?: string;
1040
1263
  authorityVerdict?: { ok: boolean; reason?: string };
1041
1264
  stage?: string;
@@ -1096,7 +1319,8 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
1096
1319
  gitRef: gitRef ?? null,
1097
1320
  dirty: dirty === true,
1098
1321
  checkDescent: async () => (descentVerdict.ok ? { ok: true as const } : { ok: false as const, reason: descentVerdict.reason ?? 'descent refused' }),
1099
- // A honoured --attest-lineage-lost waiver is recorded in the schema_log outcome.
1322
+ // The CLI's descent record (a honoured waiver, or a proven-drift origin) lands in the
1323
+ // schema_log outcome; the witness query matches `applied%`, so the row stays a witness.
1100
1324
  ...(descentAttested ? { descentNote: () => descentAttested } : {}),
1101
1325
  ...(destructive && authorityVerdict
1102
1326
  ? { verifyAuthority: async () => (authorityVerdict.ok ? { ok: true as const } : { ok: false as const, reason: authorityVerdict.reason ?? 'authority refused' }) }
@@ -1144,18 +1368,16 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
1144
1368
  // barrel), and this introspects → plans → applies, emitting its own SET search_path. The DDL
1145
1369
  // batch and its provenance/schema_log land in one transaction, same as the direct path.
1146
1370
  actions['db:reconcile'] = async (payload, _ctx) => {
1147
- const p = (payload ?? {}) as {
1148
- declared?: any[];
1149
- renamedTables?: any;
1150
- apply?: boolean;
1151
- baseline?: boolean;
1152
- rebaseline?: boolean;
1153
- rebuild?: boolean;
1154
- overwriteDrift?: boolean;
1155
- only?: string[];
1156
- actor?: string | null;
1157
- gitRef?: string | null;
1158
- };
1371
+ // FIRST, before the catalog introspection: `declared` is the derived layer compiled by the
1372
+ // CLI from the Models barrel, and this action CREATES AND REPLACES database objects from it.
1373
+ // It used to be `any[]` on the wire — no shape, no version, nothing to disagree about.
1374
+ const skew = await modelWireGate('db:reconcile', payload);
1375
+ if (skew) return skew;
1376
+
1377
+ // ReconcileWirePayload is the SHARED declaration (@everystack/model/wire, re-exported by
1378
+ // @everystack/cli/wire) — the same type the CLI builds this payload against, so the two
1379
+ // sides no longer agree by convention alone.
1380
+ const p = (payload ?? {}) as ReconcileWirePayload;
1159
1381
  try {
1160
1382
  const { executeReconcile } = await import('@everystack/cli/reconcile');
1161
1383
  const { sql } = await import('drizzle-orm');
@@ -1163,6 +1385,10 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
1163
1385
  const result: any = await opsDb.execute(sql.raw(query));
1164
1386
  return Array.isArray(result) ? result : result?.rows ?? [];
1165
1387
  };
1388
+ // No `as any` on this call any more: `p.declared` is DerivedDescriptor[], which IS the
1389
+ // CLI's SourceObject[] (one declaration, in @everystack/model/wire), so the reconcile
1390
+ // core's options type accepts it directly. The cast used to hide exactly the kind of
1391
+ // mismatch this whole contract exists to catch.
1166
1392
  const run = await executeReconcile(runner, sessionOverOpsDb(sql), {
1167
1393
  declared: p.declared,
1168
1394
  renamedTables: p.renamedTables,
@@ -1174,7 +1400,7 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
1174
1400
  only: p.only,
1175
1401
  actor: p.actor ?? null,
1176
1402
  gitRef: p.gitRef ?? null,
1177
- } as any);
1403
+ });
1178
1404
  return { plan: run.plan, applied: run.applied, statements: run.statements, refusal: run.refusal ?? null };
1179
1405
  } catch (err: any) {
1180
1406
  return { error: err?.message || String(err) };
@@ -1303,6 +1529,13 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
1303
1529
  // DATA-changed matview refresh had no credential-free path. This runs the refresh HERE on
1304
1530
  // the operator connection: the CLI ships the declared matview identities in dependency order,
1305
1531
  // this REFRESHes each. A least-privileged operator never holds a URL for the routine refresh.
1532
+ // NOT guarded by MODEL_WIRE_CONTRACT, deliberately. The payload is a flat list of
1533
+ // `schema.name` identity strings plus one boolean — there is no descriptor here, so there is
1534
+ // no field that could be renamed or reinterpreted between two CLI versions, and a name this
1535
+ // database does not have fails loudly as a REFRESH against a missing relation. Contrast
1536
+ // db:reconcile's `declared[]`, whose seventeen fields decide whether an object is rebuilt and
1537
+ // under whose ownership. IF this payload ever grows a descriptor shape, it joins the guarded
1538
+ // set — add the modelWireGate call here and bump MODEL_WIRE_CONTRACT.
1306
1539
  actions['db:refresh'] = async (payload, _ctx) => {
1307
1540
  const p = (payload ?? {}) as { matviews?: string[]; verifyNonempty?: boolean };
1308
1541
  try {
@@ -1394,9 +1627,6 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
1394
1627
  ...options.consoleAuth,
1395
1628
  });
1396
1629
 
1397
- // Sentinel for sandbox rollback
1398
- const SANDBOX_ROLLBACK = Symbol('sandbox_rollback');
1399
-
1400
1630
  async function evalExpression(db: any) {
1401
1631
  const models = createModels(db, ctx.schema);
1402
1632
  const evalContext: Record<string, unknown> = {
@@ -1440,15 +1670,10 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
1440
1670
  }
1441
1671
 
1442
1672
  if (sandbox) {
1443
- try {
1444
- await opsDb.transaction(async (tx: any) => {
1445
- await applySettings(tx);
1446
- result = await evalExpression(tx);
1447
- throw SANDBOX_ROLLBACK;
1448
- });
1449
- } catch (err: unknown) {
1450
- if (err !== SANDBOX_ROLLBACK) throw err;
1451
- }
1673
+ result = await runRolledBack(opsDb, async (tx: any) => {
1674
+ await applySettings(tx);
1675
+ return evalExpression(tx);
1676
+ });
1452
1677
  } else {
1453
1678
  result = await opsDb.transaction(async (tx: any) => {
1454
1679
  await applySettings(tx);
@@ -1456,14 +1681,7 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
1456
1681
  });
1457
1682
  }
1458
1683
  } else if (sandbox) {
1459
- try {
1460
- await opsDb.transaction(async (tx: any) => {
1461
- result = await evalExpression(tx);
1462
- throw SANDBOX_ROLLBACK;
1463
- });
1464
- } catch (err: unknown) {
1465
- if (err !== SANDBOX_ROLLBACK) throw err;
1466
- }
1684
+ result = await runRolledBack(opsDb, (tx: any) => evalExpression(tx));
1467
1685
  } else {
1468
1686
  result = await evalExpression(opsDb);
1469
1687
  }