GLOOK-41: translate DATE_ADD for SQLite — org report 500s when a report has spend data - #67
Conversation
GLOOK-41. src/lib/report/org.ts builds `DATE_ADD(?, INTERVAL 1 DAY)` for the
Claude Code spend window. translateSQL had rules for DATE_SUB but none for
DATE_ADD, so the statement reached better-sqlite3 verbatim and failed with
`near "1": syntax error` — a 500 on GET /api/report/<id>/org for any SQLite
deployment whose report carries spend data.
The query sits behind `if (ccStart && ccEnd && ...)`, so SQLite installs with
no spend data skipped it entirely and MySQL was never affected. `npm run seed`
populates a spend window, so the documented seed + dev:mock workflow hit it
every time.
Adds three rules mirroring the existing DATE_SUB pair: the two NOW() forms
first (NOW() is rewritten earlier into datetime('now','localtime'), whose comma
would split a general pattern), then the general expression form. The captured
expression is spliced verbatim so a bound `?` stays a bound parameter.
The regression test drives the real SQLite driver rather than a mock — the
existing suites around getOrgReport stayed green through this bug precisely
because they never exercised translateSQL.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
msogin
left a comment
There was a problem hiding this comment.
Multi-agent review — 4 independent reviewers
Reviewed by Senior Dev (holistic), Security Engineer (holistic), a minimalist scope gate, and the standard Smartling fullstack review. Reviewers did not see each other's findings. Every claim below was re-verified against real better-sqlite3 before posting. No fixes were applied — this is comment-only.
The fix is correct, and the central claim holds
translateSQL('… DATE_ADD(?, INTERVAL 1 DAY)') → datetime(?, '+1 days'), with the ? intact. I confirmed the rewrite is ?-count-preserving (no positional-parameter shift, which would have been the real cross-report data risk), that the function-form replacement keeps $&/$1 literal, and that no call site in the repo reaches translateSQL with attacker-influenced text inside the SQL string. Against the real stored committed_at format the translated clause returns the correct exclusive-end-of-day window, and org.ts has no other MySQL-isms — the endpoint is genuinely unblocked. Test 1 fails on revert. Suite: 1134 pass (dev-usage-card.test.tsx fails pre-existing, unrelated).
Highest-consensus finding
All four reviewers independently flagged that the "hostile string" test cannot fail — translateSQL never sees params, so interpolation is impossible by signature. The claim it's cited for is true; this test just isn't the evidence. Worth editing the PR description too, since it presents that test as the injection guard.
Three of four flagged sqlite.ts:414 as unreachable — :396 rewrites every NOW() first.
One finding only the Smartling review caught, and it's the one I'd fix before merge: :412 drops 'localtime', so NOW() and DATE_ADD(NOW(), …) disagree by the UTC offset (verified: 4h skew). The pre-existing DATE_SUB rules share it and do have live callers.
🔴 Rethink — the root cause under most of these findings
translateSQL is a whitelist rewriter with a passthrough default: anything it doesn't match reaches the driver verbatim and 500s at request time with a token-level parse error. This PR is the second iteration of that incident — a MySQL-ism ships, mocked suites stay green because they never call translateSQL, users find it as near "1": syntax error. Adding a fourth regex moves the boundary rather than closing it. Still unhandled: DATE_SUB(?, …) (no general form — the asymmetry is new as of this PR), INTERVAL n HOUR|MONTH|MINUTE, INTERVAL ? DAY, DATE_ADD(DATE(?), …).
Two cheap pieces would convert every future gap from a production 500 into a loud, greppable failure:
// end of translateSQL()
if (/\bINTERVAL\b|\bDATE_ADD\b|\bDATE_SUB\b/i.test(s)) {
throw new Error(`translateSQL: untranslated MySQL date expression: ${sql}`);
}plus a unit test scanning SQL template literals under src/lib/** for DATE_ADD|DATE_SUB|INTERVAL and asserting translateSQL leaves no INTERVAL behind — so new offenders fail CI, not production. translateSQL is pure; exporting it makes all of this testable without standing up a driver.
Open questions for the author
1. Scope — the reviewers genuinely disagree. The minimalist gate argues rule :412 and Tests 3–4 serve zero callers (grep -rn "DATE_ADD" → one non-test hit, org.ts:234; no DATE_ADD(NOW( anywhere) and should be deleted, leaving ~+40/−0 with identical regression protection. The other two reviewers propose fixing those same lines. Both are defensible — deleting is cheaper and the omitted case fails loudly; keeping preserves the DATE_SUB symmetry the file already uses. Your call, not mine.
2. Why DATE_ADD at all? endStr is already normalized to a bare YYYY-MM-DD by toIso() before binding (org.ts:229-234). Computing the exclusive end date in JS and binding it directly drops the dialect dependency from this query entirely — and makes the translator change unnecessary for the reported bug.
Deferred — real, but outside this diff
- Latent format mismatch.
datetime()emits space-separated output;committed_atstoresT-separated ISO-Z, compared bytewise. Verified('2026-04-01T00:00:01Z' < '2026-04-01 12:00:00')→0, though it's chronologically earlier. The current caller is safe only because its boundary lands on midnight. Any future non-midnight boundary returns wrong rows with no error.strftime('%Y-%m-%dT%H:%M:%SZ', …)would keep comparisons bytewise-ordered. DATE_SUBlocaltimebug — same as:412, but with live callers inuntracked.ts,epic-stats.ts,epic-summary.ts.CLAUDE.md:54still lists the translator as handling "INSERT IGNORE,ON DUPLICATE KEY UPDATE, andNOW()" — already missingLEFTandDATE_SUB, nowDATE_ADD. That stale inventory is plausibly part of why this gap went unnoticed.
Assessment
Ready to merge? With fixes — the localtime bug at :412 and the dead rule at :414 are cheap now and neither has a production caller yet. Everything else is a suggestion or a follow-up. Both persona reviewers returned "safe to merge: yes" on the core change.
🤖 Generated with Claude Code
| // Order matters: NOW() is rewritten above into datetime('now','localtime'), | ||
| // which itself contains a comma — so the NOW() forms get dedicated rules | ||
| // before the general one, exactly as DATE_SUB does. | ||
| s = s.replace(/DATE_ADD\s*\(\s*datetime\('now','localtime'\)\s*,\s*INTERVAL\s+(\d+)\s+DAY\s*\)/gi, |
There was a problem hiding this comment.
🟡 warning — this rule drops 'localtime', so NOW() and DATE_ADD(NOW(), …) disagree by the UTC offset
NOW() is rewritten at :396 to datetime('now','localtime'), but this rule emits datetime('now', '+N days') — no localtime. Verified against better-sqlite3 on a UTC-4 host:
NOW() -> 2026-08-18 16:27:20 (localtime)
DATE_ADD(NOW(), 0 DAY) -> 2026-08-18 20:27:20 (UTC)
A single query mixing both gets two different "now"s, and on any non-UTC host a day-boundary window shifts by the offset.
(_match, days) => `datetime('now', 'localtime', '+${days} days')`Note the pre-existing DATE_SUB rules at :402-406 carry the identical bug, and unlike this one they do have live callers — the 90-day windows in src/lib/projects/untracked.ts:195,245, epic-stats.ts:130,167, epic-summary.ts:187,220. Those are outside this PR's diff, but worth a follow-up.
| // before the general one, exactly as DATE_SUB does. | ||
| s = s.replace(/DATE_ADD\s*\(\s*datetime\('now','localtime'\)\s*,\s*INTERVAL\s+(\d+)\s+DAY\s*\)/gi, | ||
| (_match, days) => `datetime('now', '+${days} days')`); | ||
| s = s.replace(/DATE_ADD\s*\(\s*NOW\(\)\s*,\s*INTERVAL\s+(\d+)\s+DAY\s*\)/gi, |
There was a problem hiding this comment.
🔵 suggestion — unreachable rule (flagged independently by 3 of 4 reviewers)
:396 globally replaces every NOW() before any DATE_ADD rule runs, so no NOW() token survives to reach this line. Probed across casings and whitespace shapes (DATE_ADD(NOW(),…), date_add(now(), …), spaced variants): the rule at :412 fires in every case, this one in none. The test at sqlite-date-add.test.ts:76 passes via :412, so nothing covers this rule.
The comment at :409-411 — "the NOW() forms get dedicated rules before the general one" — is true only of :412, and reads as if this line were load-bearing. The cited precedent, DATE_SUB at :405, is dead for the same reason ("Also handle case where NOW() hasn't been translated yet" describes a case that cannot occur), so the symmetry argument replicates a pre-existing wart rather than a working pattern.
Either delete :413-415, or move the NOW() replacement below the DATE_* rules so the ordering comment becomes real.
| // General form, e.g. the org spend-window query's DATE_ADD(?, INTERVAL 1 DAY). | ||
| // The captured expression is spliced through verbatim so a bound `?` stays a | ||
| // bound parameter — never interpolate the caller's value into the SQL. | ||
| s = s.replace(/DATE_ADD\s*\(\s*([^,()]+?)\s*,\s*INTERVAL\s+(\d+)\s+DAY\s*\)/gi, |
There was a problem hiding this comment.
🟡 warning — passthrough default: everything this regex misses still 500s, silently accepted or not
The core rewrite is correct and I verified the parameterization claim holds (see the test-file comment). Two separate problems with the shape of the pattern:
1. It matches only integer literals, unit DAY, and paren/comma-free expressions. Everything else falls through verbatim and reproduces GLOOK-41 exactly. Verified non-matching — output identical to input:
DATE_ADD(DATE(x), INTERVAL 1 DAY) DATE_ADD(?, INTERVAL ? DAY)
DATE_ADD(?, INTERVAL 1 MONTH) DATE_ADD(?, INTERVAL 2 HOUR)
Also note DATE_SUB still has no general-expression form — the asymmetry with DATE_ADD is new as of this PR. The next person writing AND committed_at >= DATE_SUB(?, INTERVAL 7 DAY) reproduces this incident, and the mocked suites will stay green exactly as they did here.
2. The expression capture [^,()]+? is quote-blind, and the replacement injects ' characters. This is the first DATE_* rule whose capture isn't anchored to a literal NOW()/datetime(…), so it rewrites matching text inside string literals — and because it splices in '+N days', it terminates the enclosing literal and restructures the statement:
-- in:
WHERE msg = 'DATE_ADD(a' AND b = 'c , INTERVAL 9 DAY)' AND ts < DATE_ADD(?, INTERVAL 1 DAY)
-- out:
WHERE msg = 'datetime(a' AND b = 'c, '+9 days')' AND ts < datetime(?, '+1 days')Not reachable today — I traced every dynamic-SQL site (mcp/queries.ts, chat/tools.ts, report/org.ts, team-pulse/data.ts, teams/service.ts, jira/service.ts, report-runner/skip-classifier.ts) and every interpolation is a generated ? list or an allowlist-checked identifier. This is latent-class widening, not a live vulnerability. But it rests on an unwritten invariant ("no caller puts untrusted text in the SQL string") that nothing enforces, in a reporting app whose tables store commit messages and Jira summaries.
Anchoring to the shapes actually used closes both the literal case and keeps the fix:
s = s.replace(/DATE_ADD\s*\(\s*(\?|[A-Za-z_][A-Za-z0-9_.]*)\s*,\s*INTERVAL\s+(\d+)\s+DAY\s*\)/gi,
(_match, expr, days) => `datetime(${expr}, '+${days} days')`);Verified: still rewrites AND committed_at < DATE_ADD(?, INTERVAL 1 DAY) correctly, and leaves the hostile literal above untouched. Anything else falls through to a loud prepare() error, which is the right failure mode.
| let dbPath: string; | ||
|
|
||
| beforeAll(() => { | ||
| dbPath = path.join(os.tmpdir(), `glooker-glook41-${process.pid}-${Date.now()}.db`); |
There was a problem hiding this comment.
🔵 suggestion — predictable temp path, and four DB handles are never closed
path.join(os.tmpdir(), \glooker-glook41-${process.pid}-${Date.now()}.db`)is a guessable name in the shared temp dir, andnew Database(path)opens withO_CREATfollowing symlinks. Every sibling test already uses the safe primitive —cc-apply-breakdowns.test.ts:19, cc-breakdown-schema.test.ts:18, prompt-loader.test.ts:8, logger.test.ts:9all usefs.mkdtempSync`. This file is the only one that doesn't.
Separately, makeDb() at :37 opens a fresh handle per test (4 total), each re-running the full schema, ~20 ALTER migrations and seed inserts; none is closed, and afterAll then unlinks the file plus -wal/-shm with all four still open.
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'glooker-glook41-'));
dbPath = path.join(tmpDir, 'test.db');
// afterAll, replacing the three-suffix unlink loop:
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* already gone */ }Or set SQLITE_PATH=':memory:' as src/lib/__tests__/integration/cc-spend-end-to-end.test.ts:36 does, which removes the temp-file and WAL cleanup entirely. The env-var save/restore in this file is correct as written — keep it.
| expect(String(rows[0].boundary)).toMatch(/^2026-03-19/); | ||
| }); | ||
|
|
||
| it('keeps the bound parameter a parameter rather than interpolating it', async () => { |
There was a problem hiding this comment.
🟡 warning — this test cannot fail, and the PR description cites it as the injection guard
All four reviewers flagged this independently. translateSQL(sql: string): string receives only the SQL string — params are bound separately at sqlite.ts:352,362. Interpolating a bound ? is impossible by signature, not by care, so no change to translateSQL could make this test fail for the reason its name states.
What it actually asserts is that better-sqlite3 binds parameters and that SQLite's datetime() returns NULL on unparseable input — both properties of the driver, not of this diff.
To be clear, the underlying safety claim is true — I verified the rewrite is ?-count-preserving (the replacement introduces no ?, expr is spliced verbatim), so there's no positional-parameter shift, and the replacement being a function means $&/$1 inside expr stay literal. The problem is that this test isn't the evidence for it, while the genuinely untested surface — how translateSQL transforms adversarial SQL strings (see the sqlite.ts:419 comment) — has no direct test at all, because translateSQL isn't exported.
Two things worth doing:
- Rename to what it verifies: "returns NULL for an unparseable date rather than erroring", and drop the "rather than interpolating it" claim from the test name and the PR body.
- If the parameterization guarantee is worth asserting, export
translateSQLand assert the rewrite directly:expect(translateSQL('… DATE_ADD(?, INTERVAL 1 DAY)')).toBe("… datetime(?, '+1 days')"). That's pure and far cheaper to test than standing up a real driver.
One caveat on the toBeNull() assertion: it enshrines silent-NULL as intended behavior. In org.ts:234 a NULL boundary makes committed_at < NULL unknown for every row, so the spend window returns empty with a 200 instead of an error. I confirmed that's fail-closed rather than widening (and the window is scoped by bound report_id IN (?,…), not by the date, so no report boundary can be crossed) — but it's worth being deliberate about.
| `SELECT DATE_ADD(?, INTERVAL 1 DAY) AS boundary`, | ||
| ['2026-03-18'], | ||
| ) as [any[], any]; | ||
| expect(String(rows[0].boundary)).toMatch(/^2026-03-19/); |
There was a problem hiding this comment.
🔵 suggestion — assertion is loose enough to miss a real dialect difference
toMatch(/^2026-03-19/) passes for any time-of-day. The thing worth pinning is that the boundary lands at exact midnight rather than carrying a time component — a genuine MySQL/SQLite difference. Confirmed: datetime('2026-03-18','+1 days') returns exactly 2026-03-19 00:00:00.
expect(String(rows[0].boundary)).toBe('2026-03-19 00:00:00');This is the one test in the file that unambiguously earns its place — it drives the real driver, fails on revert, and pins the only rule with a live caller. Tightening it here also lets the separate exclusive-end-of-day test at :62 go away (see that comment).
| }); | ||
|
|
||
| it('gives the org spend-window clause exclusive-end-of-day semantics', async () => { | ||
| const db = await makeDb(); |
There was a problem hiding this comment.
🟣 question — does this test belong to this PR?
Two reviewers reached opposite conclusions, so flagging rather than asserting:
Against: the exclusive-end-of-day semantics come from the < operator in org.ts:234, which this PR doesn't touch, plus SQLite's TEXT comparison. It doesn't touch commit_analyses or getOrgReport — it's a synthetic SELECT 1 … WHERE — so it isn't integration coverage of the caller either. The one real thing it pins (midnight boundary) is better covered by tightening :48.
For: it's the only case here expressing the caller's actual semantics, and a dialect layer is a bad place to undertest.
Either way, the inputs are wrong. It uses space-separated timestamps, but production committed_at is GitHub's ISO-8601 2026-03-31T23:59:59Z (src/lib/github.ts:742 → src/lib/report-runner.ts:411), compared as TEXT against datetime()'s space-separated output. If you keep the test, use the real format:
expect(await inWindow('2026-03-31T23:59:59Z')).toBe(true);
expect(await inWindow('2026-04-01T00:00:00Z')).toBe(false);I verified the real format still yields correct results here — 'T' (0x54) sorts above ' ' (0x20), which keeps the boundary exclusive because this boundary is midnight. That's luck, not design; see the summary for the latent case.
| `SELECT DATE_ADD(NOW(), INTERVAL 7 DAY) AS boundary`, | ||
| ) as [any[], any]; | ||
| expect(rows).toHaveLength(1); | ||
| expect(String(rows[0].boundary)).toMatch(/^\d{4}-\d{2}-\d{2}/); |
There was a problem hiding this comment.
🔵 suggestion — this assertion can't detect a sign flip in the rule it covers
toMatch(/^\d{4}-\d{2}-\d{2}/) only checks the output looks like a date — it pins neither sign nor magnitude.
That matters specifically here: the rule at sqlite.ts:412 was written by copying the DATE_SUB block eight lines above, and flipping '+${days} days' to '-${days} days' is exactly the slip that copying invites. With that flip, datetime('now','-7 days') still matches this regex and all four tests stay green — the suite would ship a DATE_ADD that subtracts. Test 1 pins the sign, but for the general rule, a different code path.
The test also can't tell which of the two rules fired, so it doesn't verify what its name claims. Comparing both rewrites in one statement fixes both problems — and would currently fail, surfacing the localtime bug flagged at sqlite.ts:412:
`SELECT DATE_ADD(NOW(), INTERVAL 0 DAY) AS added, NOW() AS raw`
// expect(rows[0].added).toBe(rows[0].raw)Counterpoint worth weighing: grep -rn "DATE_ADD" returns exactly one non-test hit — org.ts:234 — and there is no DATE_ADD(NOW( anywhere in the repo. If you take the parsimony route and drop rule :412, this test goes with it.
…ime, loud guard Review findings on PR #67, in order of severity. localtime (live callers): DATE_SUB(NOW(), ...) emitted datetime('now', '-N days'), dropping the 'localtime' that NOW() itself is rewritten with, so NOW() and DATE_SUB(NOW(), ...) disagreed by the host's UTC offset. Six callers rely on this (projects/untracked.ts, epic-stats.ts, epic-summary.ts). Now emits datetime('now', 'localtime', '-N days'). Dead rule: the DATE_ADD(NOW(), ...) rules are deleted rather than fixed. NOW() is rewritten before any DATE_* rule, so the raw-NOW form was unreachable, and DATE_ADD(NOW( appears nowhere in the codebase. Deleting removes the localtime skew and an untested path in one move; the guard below makes the omission loud. String literals: anchoring the capture to ?/identifier is NOT sufficient. The text DATE_ADD(a , INTERVAL 9 DAY) inside a literal still matches an identifier capture, and the replacement injects quotes, terminating the literal and restructuring the statement. All token rewrites now run per non-literal chunk via outsideStringLiterals(), which handles doubled-quote escapes. DATE_SUB gains the general form DATE_ADD had, so the asymmetry introduced by the previous commit is gone. Passthrough default (root cause): translateSQL is a whitelist rewriter, and anything unmatched previously reached the driver verbatim and 500d at request time. It now throws, naming the statement. Only non-literal text is inspected so SQL-looking data (commit messages, Jira summaries) cannot trip it. New mysql-date-expressions.test.ts scans src/lib SQL and fails CI for any date expression translateSQL cannot handle, closing the loop that was missing when GLOOK-41 shipped. Tests: translateSQL is exported, so the rewrite is asserted directly rather than inferred through a driver. Dropped the hostile-string test, since translateSQL never receives params and so could not fail for the stated reason. Boundary assertion tightened from a prefix match to exactly 2026-03-19 00:00:00. Window test now uses the stored ISO-8601 T...Z format rather than space-separated. Temp DB via mkdtempSync with one shared handle instead of a guessable path and four unclosed handles. 119 suites / 1141 tests pass; tsc clean. (dev-usage-card.test.tsx and two tsc errors come from an uncommitted local deletion of usage-card.tsx, unrelated.) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thorough review — thanks. All findings addressed in 119 suites / 1141 tests pass, Fixed
Dead rule at Your fix for the string-literal case doesn't actually close it. I implemented the suggested anchor and wrote a test asserting the hostile literal was left alone — the test failed. So the rewrites now run per non-literal chunk ( Literal byte-identical, real expression rewritten. The anchored capture is kept as well — belt and braces. Passthrough default — the 🔴 rethink. Implemented both pieces. Worth noting the guard makes the remaining gaps you listed ( Tests.
Q2 — why
|
Closes GLOOK-41.
GET /api/report/<id>/orgreturns 500 on SQLite whenever the report has a Claude Code spend window. The Org Summary page renders "Error: Internal Server Error" and no data.Root cause
src/lib/report/org.ts:234builds MySQL-only SQL for the spend window:translateSQL()insrc/lib/db/sqlite.tshad rules forDATE_SUB(NOW(), INTERVAL n DAY)— which is why the six uses inprojects/*work — but none forDATE_ADD. The statement reached better-sqlite3 verbatim:Why it went unnoticed
The query sits behind
if (ccStart && ccEnd && reportIds.length > 0). SQLite installs without spend data skip it entirely, and MySQL was never affected. Butnpm run seedpopulates a spend window, so the documentednpm run seedthennpm run dev:mockworkflow hits it every time.The existing suites referencing
getOrgReport(report-org.test.ts,org-route-model-gating.test.ts,cc-apply-breakdowns.test.ts) mock the database, so they stayed green throughout.Fix
Three rules mirroring the existing
DATE_SUBpair:datetime('now','localtime')form and the rawNOW()form first —NOW()is rewritten earlier intranslateSQL, and its replacement contains a comma that would split a general patternorg.tsneedsThe captured expression is spliced through verbatim, so a bound
?stays a bound parameter — the caller's value is never interpolated into SQL. There is a test asserting exactly that with a hostile string.Tests
New
src/lib/__tests__/unit/sqlite-date-add.test.tsdrives the real SQLite driver viacreateSQLiteDB()rather than a mock — the whole point, since mocked tests are what let this through. Four cases: the translation works; the bound parameter stays bound; the org query's exclusive-end-of-day semantics hold at the boundary; and theNOW()variant survives the earlier rewrite.All four were confirmed failing before the fix, with the same
near "1": syntax errorseen at runtime.119 suites / 1140 tests / 9 snapshots green (was 118 / 1136).
tsc --noEmitclean.Verification
Reproduced against a seeded SQLite DB, then confirmed fixed end-to-end —
GET /api/report/<id>/orgnow returns 200 with 8 developers, 16 model-usage rows, and a populated spend window. The Spend tab renders in mock mode.Pre-existing since
1263c3c(2026-04-23). Unrelated to GLOOK-38; found while recording mock-mode footage for a demo video.🤖 Generated with Claude Code