Skip to content

Commit 5891b79

Browse files
ralyodioclaude
andauthored
sync: /save answered 502 because Turso will not parse a bare HAVING (#385)
`/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>
1 parent 3ee4755 commit 5891b79

2 files changed

Lines changed: 89 additions & 0 deletions

File tree

apps/pwa/src/routes/settings-sync.mjs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,9 +125,22 @@ async function insertRevision({ userId, body, digest, host, version, ifRevision
125125
SELECT ?, ?, COALESCE(MAX(revision),0) + 1, ?, ?, ?, ?, ?, ?
126126
FROM settings_snapshots WHERE user_id = ?
127127
RETURNING revision`
128+
// GROUP BY user_id is not decoration. A bare HAVING on an implicit
129+
// single-group aggregate is accepted by the SQLite that backs a `file:`
130+
// database and *rejected by Turso's parser*:
131+
//
132+
// SQL string could not be parsed: near HAVING, "None": syntax error
133+
//
134+
// So this statement worked in every test and threw on every deployment,
135+
// which is what `/save` returning 502 actually was. `--force` sends
136+
// ifRevision: null and takes the branch above, which is why forcing was the
137+
// only way to save. The rows always exist here — the caller sets ifRevision
138+
// to null when the account has no current revision — so grouping by the
139+
// user cannot lose the row the HAVING is meant to test.
128140
: `INSERT INTO settings_snapshots (id,user_id,revision,digest,host,version,size,body,created_at)
129141
SELECT ?, ?, COALESCE(MAX(revision),0) + 1, ?, ?, ?, ?, ?, ?
130142
FROM settings_snapshots WHERE user_id = ?
143+
GROUP BY user_id
131144
HAVING COALESCE(MAX(revision),0) = ?
132145
RETURNING revision`;
133146
const args = [row.id, userId, digest, host, version, size, body, row.created_at, userId];
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
// SQL this app writes, against the parser that actually runs it.
2+
//
3+
// The app is developed against a `file:` database and deployed against Turso.
4+
// Those are not the same parser, and the gap is silent in exactly the wrong
5+
// direction: SQLite accepts things Turso rejects, so a statement can pass every
6+
// test here and throw on every request in production.
7+
//
8+
// That is not hypothetical. `/save` answered 502 for its entire life because
9+
// insertRevision() used a bare HAVING on an implicit single-group aggregate:
10+
//
11+
// INSERT INTO settings_snapshots (…) SELECT …
12+
// FROM settings_snapshots WHERE user_id = ?
13+
// HAVING COALESCE(MAX(revision),0) = ?
14+
//
15+
// SQLite treats the whole result as one group and runs it. Turso answers
16+
// `SQL string could not be parsed: near HAVING, "None": syntax error`. The
17+
// test suite covered that path, passed, and proved nothing about the deployment.
18+
//
19+
// So this checks the source text rather than the behaviour. It cannot catch
20+
// every divergence, only the one that has already cost something — which is the
21+
// bar for a guard like this.
22+
import assert from "node:assert/strict";
23+
import fs from "node:fs";
24+
import path from "node:path";
25+
import test from "node:test";
26+
import { fileURLToPath } from "node:url";
27+
28+
const SRC = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "src");
29+
30+
/** Every .mjs under src/, recursively. */
31+
function sources(dir) {
32+
const out = [];
33+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
34+
const full = path.join(dir, entry.name);
35+
if (entry.isDirectory()) out.push(...sources(full));
36+
else if (entry.name.endsWith(".mjs")) out.push(full);
37+
}
38+
return out;
39+
}
40+
41+
/**
42+
* Blank out comments, keeping offsets so reported line numbers stay true.
43+
*
44+
* Necessary because "having" is an ordinary English word and this codebase
45+
* writes long comments. Matching it inside prose flagged four files that
46+
* contain no SQL at all.
47+
*/
48+
function withoutComments(text) {
49+
return text
50+
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "))
51+
.replace(/(^|[^:])\/\/[^\n]*/g, (m, lead) => lead + " ".repeat(m.length - lead.length));
52+
}
53+
54+
test("no HAVING without a GROUP BY — Turso refuses to parse it", () => {
55+
const offenders = [];
56+
57+
for (const file of sources(SRC)) {
58+
const text = withoutComments(fs.readFileSync(file, "utf8"));
59+
60+
// Case-sensitive on purpose: SQL keywords are written uppercase here, so
61+
// this matches the keyword and not the English word.
62+
for (const match of text.matchAll(/\bHAVING\b/g)) {
63+
const before = text.slice(0, match.index);
64+
// SQL lives in template literals, so the opening backtick is the left
65+
// edge of the statement this HAVING belongs to.
66+
const start = before.lastIndexOf("`");
67+
const statement = text.slice(start === -1 ? 0 : start, match.index);
68+
if (!/\bGROUP\s+BY\b/i.test(statement)) {
69+
offenders.push(`${path.relative(SRC, file)}:${before.split("\n").length}`);
70+
}
71+
}
72+
}
73+
74+
assert.deepEqual(offenders, [],
75+
`HAVING with no GROUP BY parses on SQLite and fails on Turso:\n ${offenders.join("\n ")}`);
76+
});

0 commit comments

Comments
 (0)