[object Object]

← back to Wallco Ai

harden(db): psqlExecLocal throws on psql error (ON_ERROR_STOP=1)

fe9624d25797bca1b66ab930a502dc4b99ae58bb · 2026-05-29 12:36:17 -0700 · Steve Abrams

psqlExecLocal ran psql with no ON_ERROR_STOP, so a SQL error printed the
message but psql exited 0 — execSync didn't throw and the caller mistook the
failure for success. That masked the prod incident where all_designs rejected
every UPDATE (no replica identity, in an update-publishing publication) yet the
curator returned applied:N and persisted nothing.

Now the write path runs ON_ERROR_STOP=1 and throws a clean Error carrying
psql's stderr + exitCode. ON_ERROR_STOP stops on ERROR only (NOTICE/WARNING
pass), so boot-time CREATE ... IF NOT EXISTS is unaffected. Reads (psqlQuery)
left lenient. No DDL routes through psqlExecLocal (110 callers are DML in
try/catch'd handlers). Tested Mac2: success clean, missing-table/syntax throw
with the ERROR line, NOTICE no-throw, server boots, write persists+restores.

Files touched

Diff

commit fe9624d25797bca1b66ab930a502dc4b99ae58bb
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri May 29 12:36:17 2026 -0700

    harden(db): psqlExecLocal throws on psql error (ON_ERROR_STOP=1)
    
    psqlExecLocal ran psql with no ON_ERROR_STOP, so a SQL error printed the
    message but psql exited 0 — execSync didn't throw and the caller mistook the
    failure for success. That masked the prod incident where all_designs rejected
    every UPDATE (no replica identity, in an update-publishing publication) yet the
    curator returned applied:N and persisted nothing.
    
    Now the write path runs ON_ERROR_STOP=1 and throws a clean Error carrying
    psql's stderr + exitCode. ON_ERROR_STOP stops on ERROR only (NOTICE/WARNING
    pass), so boot-time CREATE ... IF NOT EXISTS is unaffected. Reads (psqlQuery)
    left lenient. No DDL routes through psqlExecLocal (110 callers are DML in
    try/catch'd handlers). Tested Mac2: success clean, missing-table/syntax throw
    with the ERROR line, NOTICE no-throw, server boots, write persists+restores.
---
 lib/db.js | 34 ++++++++++++++++++++++++++++------
 1 file changed, 28 insertions(+), 6 deletions(-)

diff --git a/lib/db.js b/lib/db.js
index af44320..bce7582 100644
--- a/lib/db.js
+++ b/lib/db.js
@@ -38,13 +38,35 @@ function pgEsc(v) {
   return "'" + String(v).replace(/'/g, "''") + "'";
 }
 
-// Behavior-identical to psqlQuery (stdin-fed). Previously used `-c ${JSON.stringify(sql)}`
-// argv form, which broke any multi-line SQL: JSON.stringify converts real newlines into the
-// literal two characters `\n`, and psql then interprets `\n` as a backslash meta-command
-// (like `\d`, `\l`) — yielding `invalid command \nINSERT`. Stdin-feed avoids all shell
-// escaping and supports INSERT...RETURNING + multi-statement batches identically.
+// Stdin-fed write path (UPDATE/DELETE/INSERT...RETURNING/multi-statement).
+//
+// HARDENED 2026-05-29: runs psql with `ON_ERROR_STOP=1` so a SQL error makes
+// psql exit non-zero and we THROW. Without it, psql's default is to print the
+// error, exit 0, and the caller mistakes the failure for success. That false
+// success masked a real prod incident: `all_designs` rejected EVERY UPDATE for
+// lack of a replica identity (it was in an update-publishing publication), yet
+// the cactus curator kept returning `applied:N` and silently persisted nothing.
+// On error we surface psql's stderr (the actual `ERROR: ...` line) so callers —
+// most of which already sit in try/catch — get an actionable message and the
+// route returns a real 500 instead of a lie. `ON_ERROR_STOP` stops on ERROR
+// only, never on NOTICE/WARNING, so boot-time `CREATE ... IF NOT EXISTS`
+// (NOTICE: already exists) is unaffected. Reads (psqlQuery) stay lenient by
+// design. See feedback_cactus_curator_bulk_delete_forkstorm memory.
+const PSQL_CMD_STRICT = `${PSQL_CMD} -v ON_ERROR_STOP=1`;
 function psqlExecLocal(sql) {
-  return execSync(PSQL_CMD, { input: sql, encoding: 'utf8', maxBuffer: 200_000_000 }).trim();
+  try {
+    return execSync(PSQL_CMD_STRICT, { input: sql, encoding: 'utf8', maxBuffer: 200_000_000 }).trim();
+  } catch (e) {
+    const stderr = (e.stderr ? e.stderr.toString() : '').trim();
+    const stdout = (e.stdout ? e.stdout.toString() : '').trim();
+    const detail = (stderr || stdout || e.message || 'unknown psql error')
+      .split('\n').filter(Boolean).slice(0, 4).join(' | ').slice(0, 600);
+    const err = new Error(`psql write failed (exit ${e.status == null ? '?' : e.status}): ${detail}`);
+    err.isPsqlError = true;
+    err.psqlStderr = stderr;
+    err.exitCode = e.status;
+    throw err;
+  }
 }
 
 module.exports = {

← f6e3188 cactus-curator: haiku-vision chips + Fix-now heal pipeline w  ·  back to Wallco Ai  ·  cactus-curator: per-card Bad/Digital/Unpublish animate out + 0b5f501 →