fix(v2): give the keyset cursor's timestamp an explicit SQL type - #6636
Conversation
Handing back the `nextCursor` from any timestamp-sorted v2 list and passing it straight in returned 500. The keyset compares millisecond-truncated timestamps on both sides, and the bound cursor value went out as a bare placeholder — which Postgres types as `unknown`. `date_trunc` is overloaded across `timestamp`, `timestamptz`, and `interval`, so `date_trunc(unknown, unknown)` matched no single candidate and the statement failed outright. The value was already validated; it just carried no type. Cast it to the column's own SQL type inside `timestampKey`, so all twelve call sites across six modules inherit the fix. Derived from the column rather than hardcoded, which keeps a `timestamptz` column's offset honoured too. The millisecond truncation is unchanged — it is what stops the page's own last row being re-admitted.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
PR SummaryMedium Risk Overview
Tests assert the cast in v2 API conventions docs (skill + command copies) record this as a fifth incident class, extend the “no caller 500” checklist for SQL-function arguments, and note that SQL-shape changes need real DB execution—not only unit tests. Reviewed by Cursor Bugbot for commit f27c7d6. Configure here. |
Greptile SummaryThe PR fixes timestamp-based keyset pagination by explicitly casting bound cursor timestamps to each column’s PostgreSQL type.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains.
|
| Filename | Overview |
|---|---|
| apps/sim/lib/api/list-query.ts | Adds a column-derived SQL cast around validated timestamp cursor parameters so PostgreSQL can resolve the overloaded date_trunc call. |
| apps/sim/lib/api/list-query.test.ts | Pins timestamp and timestamptz cast rendering and adds a guard against bare bound parameters inside SQL functions. |
| .agents/skills/v2-api-conventions/SKILL.md | Documents the timestamp-cursor incident and the requirement to type bound values passed to SQL functions. |
Reviews (2): Last reviewed commit: "fix(v2): give the keyset cursor's timest..." | Re-trigger Greptile
|
@cursor review |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit f27c7d6. Configure here.
The defect
Every v2 collection hands back a
nextCursor. Passing that cursor straight back returned 500INTERNAL_ERRORon every list whose sort includes a timestamp key — which is the default sort on five of them.The keyset compares millisecond-truncated timestamps on both sides:
$nwent out as a bare placeholder, which Postgres types asunknown.date_truncis overloaded acrosstimestamp,timestamptz, andinterval, sodate_trunc(unknown, unknown)matches no single candidate and the statement fails withfunction date_trunc(unknown, unknown) is not unique.The cursor value was never unvalidated — a non-string or unparseable date is already rejected as a 400 by
KeysetKey.bind. It simply reached SQL with no type. The old TSDoc claimedsql.param(date, column)bound it "through the column so drizzle's own timestamp encoder serializes it"; the encoder does run, but drizzle still emits an untyped$n, and nothing about a placeholder tells Postgres what it is. The file's own unit test asserted the untyped form and passed.Not a regression from the recent v2 work
git log -S"date_trunc" -- apps/sim/lib/api/list-query.ts, and the same forsql.param(date, column), both point at a single commit: #5273, the original v2 PR. v2 keyset pagination has never worked past page one on a timestamp-sorted list. #6620 made it reachable and uniform — it gave four previously-unpaginated collections working cursors — which is how it surfaced, but it did not introduce it.The fix
Cast the bound value to the column's own SQL type, inside
timestampKey, so all twelve call sites across six modules inherit it rather than each patching itself:Derived from the column rather than hardcoded
timestamp: every column reaching this today istimestampwithout time zone, but atimestamptzcolumn would need itsZoffset honoured rather than ignored, and the derivation keeps that correct for free. It is read insidebindrather than at construction because every caller builds its sort map at module scope.The millisecond truncation is unchanged. It is load-bearing: Postgres stores microseconds, a cursor value round-trips through a millisecond-only JS
Date, and comparing the raw column against the truncated value re-admits the page's own last row. Verified against a real database that a row stored at…568999is still correctly excluded by a cursor stamped…568.Affected sorts
Every keyset containing a timestamp key — 7 collections, 15 sorts. Default sorts in bold.
workflowsposition,createdAt,updatedAtname,runCounttablescreatedAt,updatedAtnamefilesuploadedAt,updatedAtname,sizecredentialscreatedAt,updatedAtdisplayNamesecrets(shares the credentials keyset)createdAt,updatedAtnameknowledgecreatedAt,updatedAtnamecustom-toolscreatedAt,updatedAttitleNot affected at all:
logs,skills,billing-logs,mcp-servers,audit-logs— none of them build a keyset over a truncated timestamp.numberKeyandtextKeycarry no equivalent exposure. Their bound values only ever sit as one side of a comparison against a typed column or expression (sort_order > $1,coalesce(size_bytes, size) > $1), where Postgres infers the type from the comparison.date_truncis the only place a keyset passes a bound value into a function — which is exactly why this stayed invisible everywhere else.I swept every
sql.param(call site and every SQL-function call with an interpolated operand acrossapps/andpackages/. The others either already carry an explicit cast by hand (::jsonb,::text,::vector— seven sites, each author having hit the same wall independently) or are single-candidate and resolve fine (websearch_to_tsquery(regconfig, text),lower,make_interval). This was the only live instance.Verification
keysetAfterSQL throughPgDialectand executing it: 4 of 5 sort shapes failed withfunction date_trunc(unknown, unknown) is not unique; thename(text-key) control passed. After the fix, 5/5 pass and return the correct rows, including the microsecond-truncation case above.timestamptzfixture renderscast($1 as timestamp with time zone)); and a class guard asserting no bound value is left bare inside any function call, so a future key that wraps its value is caught.bun run type-check,bunx turbo run lint:check,bun run check:api-validation:strict,bun run check:openapiall pass.lib/apisuites: 723 tests green; the six modules that calltimestampKeyplus their dependents: 4,470 green.Skill
.agents/skills/v2-api-conventions/SKILL.mdstates "500 is never caller-reachable" and keeps an incident log against it. This class is added, along with the generalisation it produces — if a bound value is an argument to a SQL function rather than one side of a comparison, write its type down — and the reason it survived review: the generated SQL was well-formed and every assertion passed, so only a database could reject it. Projections regenerated withscripts/sync-skills.ts.Note for a follow-up (not in this PR)
lib/data-drains/sources/cursor.ts(timeCursorPredicate) appliesdate_trunc('milliseconds', …)to the column but not to the bound cursor value. It does not hit this bug — a row-constructor comparison types the parameter from the left-hand element — but that asymmetry is the same onetimestampKey's TSDoc argues re-admits the page's own last row. Worth a look separately.