From d58a86254952da1a1109532d2b654c48932b5820 Mon Sep 17 00:00:00 2001 From: aperim-agent Date: Wed, 3 Jun 2026 08:26:59 +1000 Subject: [PATCH] fix(issues): coerce anchor.createdAt to Date before postgres binding (PRO-3144) (#5220) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip orchestrates AI agents in a control plane backed by Postgres + drizzle-orm > - `listComments` is the cursor-paginated comment listing on the issues service; the cursor branch uses Drizzle's `gt`/`lt`/`eq` against `issueComments.createdAt` > - On postgres.js v3.4.8, passing a `Date` instance through the comparison helpers triggers `TypeError [ERR_INVALID_ARG_TYPE]: The "string" argument must be of type string or an instance of Buffer or ArrayBuffer. Received an instance of Date` > - The driver's binding path expects a Date constructed via the standard runtime, but drizzle's `select` returns instances that don't satisfy that check in this version > - This PR coerces `anchor.createdAt` through `toISOString()` → `new Date(...)` so the comparison helpers always receive a binding-safe Date, then folds in a follow-up that hoists the Date into a single allocation reused across all four `gt`/`lt`/`eq` call sites > - The benefit is `listComments` cursor pagination stops 500-ing on Pg v3.4.8 with one Date allocation per call instead of four, exercised by both ascending and descending cursor tests ## What Changed - `server/src/services/issues.ts` — coerce `anchor.createdAt` to a binding-safe `Date` once and reuse the same instance across all four cursor comparisons (`gt` / `lt` / `eq`) - `server/src/__tests__/issues-service.test.ts` — add an ascending-cursor sibling test so both `gt` and `lt` cursor paths are exercised; the existing descending test continues to pass ## Verification ```bash # Both cursor branches pnpm --filter @paperclipai/server exec vitest run \ src/__tests__/issues-service.test.ts -t "anchor comment" # → 2 passed, 41 skipped # Production smoke curl -s "$PAPERCLIP_API_URL/api/issues//comments?after=&order=asc" \ -H "Authorization: Bearer $PAPERCLIP_API_KEY" # Expect: JSON array, no 500 TypeError ``` ## Risks - Low risk. Pure cursor-pagination internals in `listComments`; no schema, migration, or external contract changes - Drizzle's `gt`/`lt`/`eq` continue to receive a `Date` for the timestamp column, producing the same bound parameter as before - Behavioural surface is exercised by ascending + descending cursor tests against a real Postgres test database ## Model Used - Claude Opus 4.7 (`claude-opus-4-7`), no extended-thinking mode, used for the hoist+test follow-up commit ## Fixes Closes #2612, Closes #3661, Closes #3830 ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots — n/a, backend-only - [x] I have updated relevant documentation to reflect my changes — n/a, no docs touched - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Elena Voronova Co-authored-by: Paperclip Co-authored-by: Devin Foley --- server/src/__tests__/issues-service.test.ts | 58 +++++++++++++++++++++ server/src/services/issues.ts | 18 +++++-- 2 files changed, 72 insertions(+), 4 deletions(-) diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index 9b229744..07f5717b 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -1243,6 +1243,64 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { expect(comments.map((comment) => comment.id)).toEqual([firstCommentId]); }); + it("paginates later comments in ascending order from an anchor comment", async () => { + const companyId = randomUUID(); + const issueId = randomUUID(); + const firstCommentId = randomUUID(); + const anchorCommentId = randomUUID(); + const latestCommentId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Paged comments issue", + status: "todo", + priority: "medium", + }); + + await db.insert(issueComments).values([ + { + id: firstCommentId, + companyId, + issueId, + body: "First comment", + createdAt: new Date("2026-03-26T10:00:00.000Z"), + updatedAt: new Date("2026-03-26T10:00:00.000Z"), + }, + { + id: anchorCommentId, + companyId, + issueId, + body: "Anchor comment", + createdAt: new Date("2026-03-26T11:00:00.000Z"), + updatedAt: new Date("2026-03-26T11:00:00.000Z"), + }, + { + id: latestCommentId, + companyId, + issueId, + body: "Latest comment", + createdAt: new Date("2026-03-26T12:00:00.000Z"), + updatedAt: new Date("2026-03-26T12:00:00.000Z"), + }, + ]); + + const comments = await svc.listComments(issueId, { + afterCommentId: anchorCommentId, + order: "asc", + limit: 50, + }); + + expect(comments.map((comment) => comment.id)).toEqual([latestCommentId]); + }); + it("lists user comments when derived run attribution scans a timestamp window", async () => { const companyId = randomUUID(); const agentId = randomUUID(); diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 1a20f62b..32472bcf 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -5589,15 +5589,25 @@ export function issueService(db: Db) { .then((rows) => rows[0] ?? null); if (!anchor) return []; + const anchorCreatedAt = + anchor.createdAt instanceof Date + ? anchor.createdAt + : new Date(String(anchor.createdAt)); conditions.push( order === "asc" ? or( - gt(issueComments.createdAt, anchor.createdAt), - and(eq(issueComments.createdAt, anchor.createdAt), gt(issueComments.id, anchor.id)), + gt(issueComments.createdAt, anchorCreatedAt), + and( + eq(issueComments.createdAt, anchorCreatedAt), + gt(issueComments.id, anchor.id), + ), )! : or( - lt(issueComments.createdAt, anchor.createdAt), - and(eq(issueComments.createdAt, anchor.createdAt), lt(issueComments.id, anchor.id)), + lt(issueComments.createdAt, anchorCreatedAt), + and( + eq(issueComments.createdAt, anchorCreatedAt), + lt(issueComments.id, anchor.id), + ), )!, ); }