Skip to content

sync: /save answered 502 because Turso will not parse a bare HAVING - #385

Merged
ralyodio merged 1 commit into
mainfrom
fix/save-502-having
Aug 13, 2026
Merged

sync: /save answered 502 because Turso will not parse a bare HAVING#385
ralyodio merged 1 commit into
mainfrom
fix/save-502-having

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

/save returned 502 every time unless you passed --force. One missing clause, invisible to the test suite.

What was happening

insertRevision() builds two statements. --force sends ifRevision: null and inserts unconditionally — that path always worked. The ordinary path carries a precondition, written as a HAVING on an implicit single-group aggregate:

INSERT INTO settings_snapshots (…) SELECTFROM settings_snapshots WHERE user_id = ?
HAVING COALESCE(MAX(revision),0) = ?

SQLite treats the whole result as one group and runs it. Turso refuses to parse it:

SQL string could not be parsed: near HAVING, "None": syntax error

The route threw, Railway returned 502, and the CLI reported what it saw.

Reproduced from the CLI:

command result
moshcode save could not save: the app returned 502
moshcode save --force saved 2 files … (revision 5)

#380 added retries for "the app does not answer". They could never help — the statement is deterministically unparseable, so every attempt failed identically.

Why the tests missed it

They run against file:, a different engine from the deployment. And test/settings-sync.test.mjs does cover this path — stale ifRevision → 409, current → 200. It passed the whole time, because on SQLite the statement is valid.

That is the part worth fixing beyond the one line.

The fix

GROUP BY user_id makes it parse on both engines. It cannot change the answer: the caller sets ifRevision to null when the account has no current revision (if (!current) ifRevision = null), so there is always at least one row for this user to group.

Verified against the real database, not just locally:

  • bare form → syntax error on Turso
  • grouped form → parses, and inserts nothing when the precondition fails, which is the conflict reported as 409

The guard

test/sql-portability.test.mjs fails on any HAVING in src/ without a GROUP BY. Static rather than behavioural on purpose — the behaviour is correct on the engine the tests use, so only reading the SQL can catch this class.

It matches the uppercase keyword with comments stripped: "having" is an ordinary English word, and my first version flagged four files that contain no SQL at all because their comments said "rather than having…". Confirmed to fail when the GROUP BY is removed and pass when it is present.

Verification

  • PWA suite: 473 tests, 473 pass, 0 fail (deps needed pnpm install --ignore-workspace; they are not installed by a root install, which is why this suite does not run in CI).
  • Prod, after a service restart cleared a separate wedge: unauthenticated and bogus-key requests answer 401 in ~0.13s where they previously hung 15s.

🤖 Generated with Claude Code

`/save` failed with 502 every time unless you passed --force. The cause is one
missing clause, and it survived because it is invisible to the test suite.

insertRevision() builds two statements. The --force path sends ifRevision: null
and inserts unconditionally. The ordinary path carries a precondition and
expressed it as a HAVING on an implicit single-group aggregate:

  INSERT INTO settings_snapshots (…) SELECT …
  FROM settings_snapshots WHERE user_id = ?
  HAVING COALESCE(MAX(revision),0) = ?

SQLite treats the whole result as one group and runs that happily. Turso's
parser rejects it outright:

  SQL string could not be parsed: near HAVING, "None": syntax error

The route threw, the platform returned 502, and the CLI reported exactly what
it saw. #380 added retries for "the app does not answer", which could never
help: the statement is deterministically unparseable, so every attempt failed
the same way.

The tests could not catch it. They run against `file:` — a different engine
from the deployment — and test/settings-sync.mjs does cover the precondition,
with a stale ifRevision refused as 409 and a current one accepted. It passed
throughout, because on SQLite the statement is valid.

Adding GROUP BY user_id makes it parse on both. It cannot change the answer:
the caller sets ifRevision to null when the account has no current revision, so
there is always at least one row for this user to group.

Verified against the real database rather than only locally — the bare form
fails to parse on Turso, the grouped form parses and inserts nothing when the
precondition does not hold, which is the conflict the caller reports as 409.

test/sql-portability.test.mjs now fails on any HAVING in src/ with no GROUP BY.
Static rather than behavioural on purpose: the behaviour is correct on the
engine the tests use, so only reading the SQL can catch this. It matches the
uppercase keyword with comments stripped, because "having" is also an English
word and four files that contain no SQL said it in prose.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

ThreatCrush Security Scan

97 finding(s)

HIGH/CRITICAL: 5 | MEDIUM: 41 | LOW: 51

Severity Rule Location
HIGH js-ssrf-outbound-request apps/pwa/public/sw.js:45
HIGH tls-verification-disabled apps/pwa/src/lib/moshpit-gateway.mjs:299
HIGH sh-remote-script-execution install.sh:79
HIGH sh-remote-script-execution install.sh:83
HIGH tls-verification-disabled src/dns.mjs:741
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:61
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:75
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:101
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:265
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:269
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:314
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:499
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:675
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:677
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:736
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:782
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:852
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:955
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:1063
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:1199
MEDIUM js-unescaped-html-sink apps/pwa/src/routes/moshpit.mjs:1419
MEDIUM js-timing-unsafe-mac-compare apps/pwa/src/routes/settings-sync.mjs:192
MEDIUM js-dynamic-code-execution apps/pwa/test/apikey-mask.test.mjs:129
MEDIUM sql-template-interpolation apps/pwa/test/credits-webhook-event-match.test.mjs:111
MEDIUM sql-template-interpolation apps/pwa/test/credits-webhook-event-match.test.mjs:131
MEDIUM sql-template-interpolation apps/pwa/test/moshpit-terms.test.mjs:192
MEDIUM sql-string-concatenation src/cli-schema.mjs:128
MEDIUM sql-string-concatenation src/cli-schema.mjs:417
MEDIUM sql-string-concatenation src/cli-schema.mjs:543
MEDIUM js-timing-unsafe-mac-compare src/settings-sync.mjs:325
MEDIUM insecure-temp-file test/dns-disable-restore.test.mjs:93
MEDIUM insecure-temp-file test/dns-disable-restore.test.mjs:310
MEDIUM insecure-temp-file test/news.test.mjs:505
MEDIUM insecure-temp-file test/plugins.test.mjs:152
MEDIUM insecure-temp-file test/pty.test.mjs:28
MEDIUM insecure-temp-file test/pty.test.mjs:31
MEDIUM insecure-temp-file test/pty.test.mjs:40
MEDIUM insecure-temp-file test/pty.test.mjs:42
MEDIUM insecure-temp-file test/pty.test.mjs:47
MEDIUM insecure-temp-file test/pty.test.mjs:48
MEDIUM insecure-temp-file test/pty.test.mjs:49
MEDIUM insecure-temp-file test/tabs.test.mjs:8
MEDIUM insecure-temp-file test/tabs.test.mjs:13
MEDIUM insecure-temp-file test/tabs.test.mjs:14
MEDIUM insecure-temp-file test/tabs.test.mjs:22
MEDIUM insecure-temp-file test/trust.test.mjs:240
LOW secret-generic-credential apps/pwa/test/apikey-bearer-scheme.test.mjs:30
LOW secret-generic-credential apps/pwa/test/apikey-mask.test.mjs:38
LOW secret-generic-credential apps/pwa/test/apikey-reveal.test.mjs:35
LOW secret-generic-credential apps/pwa/test/approvals-context.test.mjs:28

…and 47 more. Full results in the Security tab.

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio merged commit 5891b79 into main Aug 13, 2026
4 checks passed
@ralyodio
ralyodio deleted the fix/save-502-having branch August 13, 2026 14:03
@ralyodio ralyodio mentioned this pull request Aug 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant