Skip to content

fix(db): give every query an abort budget - #33

Open
Nicolas0315 wants to merge 11 commits into
arkorlab:mainfrom
Nicolas0315:fix/db-query-budget
Open

fix(db): give every query an abort budget#33
Nicolas0315 wants to merge 11 commits into
arkorlab:mainfrom
Nicolas0315:fix/db-query-budget

Conversation

@Nicolas0315

@Nicolas0315 Nicolas0315 commented Aug 4, 2026

Copy link
Copy Markdown

What

createDatabase builds the Neon client with no fetch options, so every query in the system inherits undici's defaults. This gives each query its own AbortSignal budget (DEFAULT_QUERY_BUDGET_MS, 10s, overridable via a createDatabase option rather than a new env knob).

Why

This is the one uninstrumented transport in the repo. Heartbeat (5s), the per-nudge supervisor call (10s) and chat TTFB (30s) all carry an explicit budget already; the database did not.

It matters in the mode haru exists to survive. A refused connection fails fast either way, but a store that accepts TCP and never answers made every caller wait on the transport, and the routing pointer is read in two places that care:

  • cachedSnapshot on the chat hot path, where the wait is paid per request as time-to-first-byte. This is the KNOWN_ISSUES entry "Outage detection latency on the chat path is unbounded".
  • resolveStepTimeout in the reconciler, where a timed-out switch_active re-reads the pointer to decide whether routing actually committed. A hang there leaves the operation neither done nor failed. This one was not listed.

Two design points worth reviewing

The budget wraps client.query, not the callable form. drizzle-orm/neon-http resolves client.query ?? client once when the session is constructed, and the Neon client does expose query, so query is the only path a repository read or CAS takes. Budgeting the callable form instead compiles, passes its own tests, and never applies to a real query.

The signal rides fetchOptions rather than racing a timer. Racing a timer against the promise would stop the waiting without cancelling the request, accumulating one abandoned socket per attempt during exactly the outage this is meant to contain. Existing options are merged, not replaced, so drizzle's arrayMode / fullResults / authToken and any caller-supplied fetchOptions survive.

An expired budget surfaces as a rejection. That is required rather than tolerated: cachedSnapshot reads a throw from the pointer read as "the store is unreachable", and per AGENTS.md that throw is the only thing licensing a stale route. A budget resolving to a sentinel would defeat the contract silently.

Budget sizing

10s matches the supervisor-call budget rather than sitting just above a healthy p99 (a single statement over Neon HTTP is tens of milliseconds): the bound exists to make an outage unmistakable, not to police latency. It also does not exceed switchActiveTimeoutMs, so a hung pointer read cannot outlive the step that issued it. Happy to change the number if you have a preference.

KNOWN_ISSUES

The entry is narrowed rather than deleted, in both languages: the transport half is bounded now, but there is still no memoized outage state, so each request pays the budget again instead of the first one paying it for the rest. That half is a policy decision (how long a "store is down" memo may be trusted, and what clears it) and is left open.

Tests

7 new cases in packages/db/src/client.test.ts, against a fake client that records what it was handed, so a wrapper missing drizzle's actual call path cannot pass: signal attached to the call drizzle issues, a fresh signal per call, rejection once the budget elapses, a fast query left untouched, drizzle's options and caller fetchOptions preserved through the merge, other properties passed through, and the default budget bounded.

Two cases run on real timers with a 20ms budget: AbortSignal.timeout is native and not intercepted by vitest's fake timers, and keeping the production path on a real signal (so the fetch is genuinely cancelled) seemed worth more than making it fakeable.

Verification

pnpm test (12/12 tasks), pnpm typecheck, pnpm lint, pnpm format:check all pass locally on Windows. No schema change, so no db:generate.


Context: I run a small Active/Standby-ish GPU setup of my own and found haru through Hina's Zenn post on serving inference through the GPU shortage, which is also where the ~1s promotion figure sent me looking at what the pointer flip actually depends on. Happy to adjust anything here to taste.


Summary by cubic

Bound every database query and transaction with an abort budget to prevent hangs and surface transport failures quickly. Default is 10s via DEFAULT_QUERY_BUDGET_MS, configurable via createDatabase; budgets compose with caller signals via timeoutSignal, keep queries lazy and batch-safe, and timers are cleared on settle.

  • Bug Fixes

    • Applied budget to query() and transaction; kept queries lazy so batch still works; released per-statement timers in batched queries; handled null driver returns and the transaction callback form without crashes or leaks.
    • Composed with caller fetchOptions.signal via exported timeoutSignal.
    • Validated queryBudgetMs with @haru/protocol’s exported timeoutMsSchema; reject non‑positive, fractional, NaN/Infinity, and >2^31‑1 at wiring time to avoid 1ms clamp hazards.
    • Updated KNOWN_ISSUES: transport is bounded; outage memoization remains open; clarified docs that the budget is a bound, not a containment guarantee.
  • Tests

    • Expanded coverage for the real drizzle path, query-level fetchOptions reaching fetch, batch timer release, invalid budgets (including fractional/overflow), and null-return handling.
    • Asserted default budget is > 0 and <= switchActiveTimeoutMs (fleetPolicySchema); fixed a drizzle integration test to not swallow errors.

Written for commit e97e713. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features

    • Database queries now enforce configurable timeouts, defaulting to 10 seconds, to prevent indefinite hangs.
    • Database setup supports customizing query timeout behavior.
    • Caller-provided cancellation signals and query options remain supported.
  • Documentation

    • Updated known-issues documentation to clarify query timeout behavior and outage detection status.
  • Tests

    • Added comprehensive coverage for timeouts, cancellations, successful queries, transactions, batching, and option handling.

`createDatabase` built the Neon client with no fetch options, so every
query in the system inherited undici's defaults. That is the one
uninstrumented transport here: heartbeat, supervisor nudge and chat TTFB
all carry an explicit budget already.

It matters in the mode this system exists to survive. A refused
connection fails fast either way, but a store that accepts TCP and never
answers made every caller wait on the transport, and the routing pointer
is read both on the chat hot path (`cachedSnapshot`) and inside
`resolveStepTimeout`, where the reconciler decides whether a timed-out
promotion actually committed. A hang there leaves the operation neither
done nor failed.

The signal is per call and rides Neon's `fetchOptions`, so an expired
budget genuinely aborts the fetch. Racing a timer against the promise
would stop the waiting without cancelling the request, accumulating one
abandoned socket per attempt during exactly the outage this is meant to
contain.

The budget is wrapped around `client.query`, not the callable form:
drizzle's neon-http session resolves `client.query ?? client` once at
construction and the Neon client does expose `query`, so budgeting the
callable form would compile, pass its own tests, and never apply to a
real query.

An expired budget surfaces as a rejection, which the fail-open contract
requires rather than merely tolerates: `cachedSnapshot` reads a throw
from the pointer read as "the store is unreachable", and that throw is
the only thing licensing a stale route.

No new env knob, per the KNOWN_ISSUES entry's stated reason for
deferral. That entry is narrowed rather than deleted: the transport half
is bounded now, the memoized "store is down" half is still open.
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

The database client now applies configurable per-query timeouts through abort signals. Drizzle receives the wrapped Neon client. Tests cover timeout behavior, lazy queries, transactions, and fetch integration. Known-issues documentation describes remaining outage memoization work.

Changes

Query budget enforcement

Layer / File(s) Summary
Timeout signal contract
packages/protocol/src/http.ts
Exports timeoutSignal and makes its signal parameters optional while preserving caller-signal support.
Query budget wrapper and database wiring
packages/db/src/client.ts
Adds configurable query budgets, per-query abort signals, option and signal preservation, timer cleanup, lazy-query and transaction support, and wrapped-client integration with Drizzle.
Budget validation and outage documentation
packages/db/src/client.test.ts, KNOWN_ISSUES.md, KNOWN_ISSUES.ja.md
Tests timeout behavior, signal composition, option preservation, timer cleanup, lazy queries, transactions, fetch propagation, and invalid budgets. Updates outage documentation.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant createDatabase
  participant withQueryBudget
  participant Drizzle
  participant NeonClient
  createDatabase->>withQueryBudget: wrap Neon client with configured budget
  withQueryBudget->>Drizzle: provide wrapped client
  Drizzle->>NeonClient: execute query with per-request AbortSignal
  NeonClient-->>Drizzle: return result or timeout rejection
Loading

Possibly related PRs

  • arkorlab/haru#7: Both changes cover query-timeout behavior in packages/db/src/client.ts and operation failure handling.
  • arkorlab/haru#12: This query-timeout infrastructure bounds database requests used by cachedSnapshot, while the outage-routing logic remains separate.

Suggested reviewers: soleil-colza

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: adding an abort budget to every database query.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
✨ Simplify code
  • Create PR with simplified code

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@drift-check

drift-check Bot commented Aug 4, 2026

Copy link
Copy Markdown

Code Review Bot

No comment/code divergences or documentation drift detected. Reviewed 6 file(s); skipped 0.

@greptile-apps

greptile-apps Bot commented Aug 4, 2026

Copy link
Copy Markdown

Greptile Summary

The PR adds a configurable per-query abort budget to the Neon database transport while preserving caller cancellation, lazy query behavior, and transaction batching.

  • Wraps Neon query and transaction calls with composed timeout signals.
  • Validates query budgets at database construction.
  • Adds focused coverage for Drizzle integration, cancellation, timer cleanup, transactions, and option forwarding.
  • Updates the documented outage behavior in both languages.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
packages/db/src/client.ts Adds validated per-call timeout composition around the Neon query and transaction paths while retaining lazy query objects and clearing timers after settlement.
packages/db/src/client.test.ts Exercises the actual Drizzle query path, option and signal composition, transaction batching, timer cleanup, invalid configuration, and driver response edge cases.
packages/protocol/src/http.ts Exports the existing timeout-signal helper and makes its signal inputs optional for database transport reuse.
packages/protocol/src/policy.ts Exports the shared timeout validation schema so database budgets follow the repository's established timer bounds.
KNOWN_ISSUES.md Narrows the documented chat outage issue to repeated per-request latency now that transport waits are bounded.
KNOWN_ISSUES.ja.md Applies the corresponding known-issue update to the Japanese documentation.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[Repository operation] --> B[Drizzle Neon session]
  B --> C[Budgeted client query or transaction]
  C --> D[Compose caller signal with query budget]
  D --> E[Neon HTTP request]
  E -->|Settles| F[Clear budget timer]
  E -->|Budget expires| G[Abort request and reject]
Loading

Reviews (10): Last reviewed commit: "docs(db): stop the budget comment claimi..." | Re-trigger Greptile

Comment thread packages/db/src/client.ts
Comment thread packages/db/src/client.ts Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/db/src/client.ts`:
- Around line 78-81: Update withQueryBudget to validate budgetMs before creating
the wrapped client, rejecting NaN, Infinity, negative, and non-integer values
before AbortSignal.timeout is reached; explicitly preserve the intended
zero-budget behavior as either a permitted pass-through or rejection. Add
coverage for fractional invalid values and ensure valid budgets continue to wrap
the client normally.
- Around line 95-102: Update the fetchOptions construction in the target.query
call to preserve callerFetchOptions.signal: when a caller signal exists, combine
it with the budget timeout using AbortSignal.any, while retaining the
timeout-only behavior when no caller signal is provided. Add a test covering
caller cancellation before budget expiration and verify the fetch aborts
promptly.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 8186c28e-ab6b-4c65-a454-feff69b02c81

📥 Commits

Reviewing files that changed from the base of the PR and between 9bf0062 and ed8d985.

📒 Files selected for processing (4)
  • KNOWN_ISSUES.ja.md
  • KNOWN_ISSUES.md
  • packages/db/src/client.test.ts
  • packages/db/src/client.ts
📜 Review details
⏰ Context from checks skipped due to timeout. (2)
  • GitHub Check: Seer Code Review
  • GitHub Check: cubic · AI code reviewer
🧰 Additional context used
📓 Path-based instructions (12)
{README,CONTRIBUTING,KNOWN_ISSUES}{,.ja}.md

📄 CodeRabbit inference engine (AGENTS.md)

Maintain English/Japanese documentation pairs together: editing one side requires updating the other in the same change.

Files:

  • KNOWN_ISSUES.md
  • KNOWN_ISSUES.ja.md
**/*

📄 CodeRabbit inference engine (AGENTS.md)

Do not reference consumer-private repositories or infrastructure, specific model names, or specific GPU names in code, comments, tests, docs, seeds, or example layouts.

**/*: Use kebab-case for file names.
Write comments in English.
Do not use the em dash character (U+2014) in code or prose; use a colon, comma, parentheses, or spaced hyphen instead.

ファイル名は kebab-case にする。

Files:

  • KNOWN_ISSUES.md
  • packages/db/src/client.ts
  • KNOWN_ISSUES.ja.md
  • packages/db/src/client.test.ts
**/*.{ts,tsx,md,mdx}

📄 CodeRabbit inference engine (AGENTS.md)

Use English code comments and prose, and avoid the em dash character U+2014.

Files:

  • KNOWN_ISSUES.md
  • packages/db/src/client.ts
  • KNOWN_ISSUES.ja.md
  • packages/db/src/client.test.ts
**/*.{ts,tsx,js,jsx,json,md,yml,yaml}

📄 CodeRabbit inference engine (CONTRIBUTING.md)

Use oxfmt as the owner of formatting, including whitespace, wrapping, quotes, and trailing commas; do not hand-tune formatting for ESLint.

Files:

  • KNOWN_ISSUES.md
  • packages/db/src/client.ts
  • KNOWN_ISSUES.ja.md
  • packages/db/src/client.test.ts
**/*.{ts,tsx,js,jsx,json,md,yaml,yml}

📄 CodeRabbit inference engine (CONTRIBUTING.ja.md)

oxfmt を使用して、空白、折り返し、クォート、末尾カンマを整形する。整形確認には pnpm format:check を使用する。

Files:

  • KNOWN_ISSUES.md
  • packages/db/src/client.ts
  • KNOWN_ISSUES.ja.md
  • packages/db/src/client.test.ts
**/*.{ts,tsx,js,jsx,md}

📄 CodeRabbit inference engine (CONTRIBUTING.ja.md)

コードと文章ではエムダッシュ (U+2014) を使用せず、コロン、コンマ、括弧、またはスペース付きハイフンを使用する。

Files:

  • KNOWN_ISSUES.md
  • packages/db/src/client.ts
  • KNOWN_ISSUES.ja.md
  • packages/db/src/client.test.ts
**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx}: Respect the dependency graph: @haru/protocol contains shared types/helpers; @haru/core is pure logic with no I/O; @haru/db depends on core; drivers, server, and supervisor must only use permitted dependencies. Shared server/supervisor code belongs in @haru/protocol.
Build outbound URLs with joinUrl from @haru/protocol; do not use new URL('/path', base) because it drops a base path prefix.
Keep all I/O injectable, including fetch, exec, spawn, and clocks.
Use the repository’s root linter and formatter configurations; do not add per-package linter configurations, and do not manually align formatting. Add root overrides with a reason comment when needed.

Keep new I/O behind injectable boundaries so it can be tested without GPUs, cloud accounts, or a running database.

新しい I/O は注入可能な境界の背後に配置し、外部実行、fetch、子プロセス、タイマーなどをテストダブルに置き換えられるようにする。

Files:

  • packages/db/src/client.ts
  • packages/db/src/client.test.ts
packages/db/src/**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

packages/db/src/**/*.{ts,tsx}: Implement every state transition as a single-statement compare-and-swap using an appropriate UPDATE ... WHERE state IN (...) RETURNING and verify the affected row count. Never use db.transaction() or hold external work between a read and its dependent write.
Enforce the core state tables as the single source of truth: repository code must reject invalid (from, to) pairs with InvalidTransitionError.

Files:

  • packages/db/src/client.ts
  • packages/db/src/client.test.ts
**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (CONTRIBUTING.md)

Run both root-configured linters, oxlint --type-aware followed by strict type-aware ESLint 10; add overrides at the repository root rather than per-package configs.

**/*.{ts,tsx,js,jsx}: oxlint の型認識 lint を実行した後、型情報ベースの strict ESLint 10 を実行する。設定はパッケージごとではなくリポジトリルートに置き、例外には理由をコメントしたスコープ付きオーバーライドを優先する。
コード内のコメントは英語で記述する。

Files:

  • packages/db/src/client.ts
  • packages/db/src/client.test.ts
packages/db/**/*.{ts,tsx}

📄 CodeRabbit inference engine (CONTRIBUTING.md)

Test @haru/db against in-memory PGlite using the committed Drizzle migrations, including compare-and-swap SQL and concurrent-winner races.

状態ストアの状態遷移では、compare-and-swap SQL によって並行実行時の勝者決定レースを保護する。

Files:

  • packages/db/src/client.ts
  • packages/db/src/client.test.ts
**/*.test.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.test.{ts,tsx}: Use @haru/db/testing and its committed migrations for database tests; do not add per-test migration calls. Use loadExampleFleetLayout for shared example layouts.
Server tests should drive Hono with app.request() and scripted fake supervisors; supervisor tests should use fake timers for SIGTERM-to-grace-to-SIGKILL escalation.

Files:

  • packages/db/src/client.test.ts
**/*.{test,spec}.{ts,tsx}

📄 CodeRabbit inference engine (CONTRIBUTING.md)

Add a Vitest case next to changed code when introducing or modifying behavior.

変更したコードの近くに Vitest のテストを追加し、外部 I/O は注入可能な境界に対してテストする。

Files:

  • packages/db/src/client.test.ts
🪛 LanguageTool
KNOWN_ISSUES.md

[typographical] ~48-~48: The word ‘Why’ starts a question. Add a question mark (“?”) at the end of the sentence.
Context: ...slice deliberately adds no new env knobs. - Intended fix: a short-lived "store is...

(WRB_QUESTION_MARK)

Comment thread packages/db/src/client.ts
Comment thread packages/db/src/client.ts Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ed8d985985

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/db/src/client.ts Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 4 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/db/src/client.ts Outdated
…path

Review feedback, all three verified locally first.

Sentry was right that the caller's signal was overwritten rather than
composed. `packages/protocol/src/http.ts` already solves exactly this and
says so in a comment ('a signal the caller put in init must compose, not
be silently discarded'), so `timeoutSignal` is exported and reused
instead of a second implementation. That also replaces
`AbortSignal.timeout`, whose timer cannot be cleared: the budget timer is
now released in a `finally` when the query settles, so a settled query
no longer holds one for the full budget.

Greptile read the wrapper as leaving the callable path unbounded, so
normal selects would bypass the signal. That is not what the driver does,
but the honest answer to 'which path does drizzle take' is a test rather
than a claim, so there is now one that drives real drizzle against a
recording fake client and asserts the signal reached the issued query. It
is the test that would have caught budgeting the wrong path, which was a
real hazard here and not a hypothetical one.
@Nicolas0315

Copy link
Copy Markdown
Author

Thanks - worked through all three, verified each locally before changing anything. Pushed as 0419815.

@sentry (composing the caller's signal): correct, fixed. I was overwriting signal while merging every other fetchOptions key, so a caller's cancellation would have become a silent no-op. Rather than add a second composition helper, I exported timeoutSignal from packages/protocol/src/http.ts and reused it: it already does exactly this, and its comment already states the rule ("a signal the caller put in init (the standard fetch idiom) must compose, not be silently discarded"). Pointing the database transport at the same helper seemed better than paraphrasing it.

That swap fixed something I had not flagged: AbortSignal.timeout gives you no handle to clear, so a settled query still held its timer for the full budget. timeoutSignal returns the timer, and it is now released in a finally.

@greptile-apps (P1, callable path unbounded): not what the driver does — but the objection deserved a test, not a rebuttal. drizzle-orm/neon-http resolves client.query ?? client once when the session is constructed, and the Neon client does expose query, so query is the path a select/insert/update/delete takes and the wrapper is on it.

The honest answer to "which path does drizzle actually take" is a test, so there is one now: it builds a real drizzle({ client: withQueryBudget(fake) }), runs an actual select().from(fleets), and asserts the signal arrived on the query that was issued. Worth having regardless of who was right, because budgeting the wrong path is a failure mode that compiles, passes every unit test around it, and applies to zero real queries. That was not hypothetical here: my first attempt at this wrapped the callable form and would have shipped as a no-op.

@coderabbitai (budget value validation): the budget is not user input on any path today (a constant, or an explicit argument from a caller inside this repo), so I left it unguarded rather than adding a check with no caller. Happy to add one if you would rather have the constructor reject a non-finite or non-positive value.

Also added a case pinning the composition (aborting the caller's controller aborts the query's signal) and one asserting the timer count does not grow across repeated queries, measured as a delta since the runner keeps timers of its own.

pnpm test (12/12 tasks), typecheck, lint, format:check all green locally. CI here is still action_required, so those are the only results I can show you.

@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

CodeRabbit chat interactions are restricted to organization members for this repository. Ask an organization member to interact with CodeRabbit, or set chat.allow_non_org_members: true in your configuration.

@greptile-apps

greptile-apps Bot commented Aug 4, 2026

Copy link
Copy Markdown

Good work on all three — the implementation is now correct and well-evidenced.

Callable path: You were right, I was wrong. drizzle-orm/neon-http resolves client.query ?? client at construction, and since the Neon client exposes query, that is the path every select/insert/update/delete takes. The test is the right answer to that dispute — a wrapper that misses the real call path compiles cleanly and defeats the entire purpose, so having something that can only pass by hitting the actual path is worth keeping permanently, regardless of who was right about the original driver behavior.

Signal composition via timeoutSignal: The reuse of the existing helper is the right call. The third-argument initSignal slot is exactly this case — a signal the caller placed in options that must compose rather than be displaced — and the comment already captures the rule. Pointing the database transport at the same helper rather than paraphrasing it means the two paths stay in sync automatically if timeoutSignal's composition logic ever changes.

Timer cleanup: finally { clearTimeout(timer) } is correct. The timer-count delta test is a good way to assert this without coupling to the runner's own internal timers.

On budget validation: your reasoning holds. Both sources of budgetMs today (the exported constant and an explicit argument from a caller inside this repo) are controlled values, not external input. Guarding against NaN/Infinity/negative at the constructor boundary is a reasonable hardening step if this ever becomes more widely callable, but there's no current path that requires it.

This is in good shape to merge.

Tip: You can customize Greptile's behavior for this repo with .greptile/rules.md and .greptile/config.json.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/db/src/client.test.ts (1)

209-215: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assert the exact default query budget.

The contract specifies a 10-second default. A range assertion also accepts 30 seconds and does not protect that contract.

Assert that DEFAULT_QUERY_BUDGET_MS equals 10_000.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/db/src/client.test.ts` around lines 209 - 215, Update the test for
DEFAULT_QUERY_BUDGET_MS to assert that it equals the contract’s exact 10-second
value, 10_000, instead of only checking a positive bounded range.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/db/src/client.test.ts`:
- Around line 195-202: Update the Drizzle query assertion in the integration
test so it no longer catches and ignores every error: make the fake client
return a valid Neon fullResults response, or catch only the expected
response-shape mismatch and assert its exact details. Ensure unexpected wrapper
or integration failures propagate and fail the test.

In `@packages/db/src/client.ts`:
- Around line 100-104: Update withQueryBudget to accept an injectable
timeout-signal factory, defaulting to timeoutSignal, and use that factory where
the query budget signal is created. Preserve the existing budgetMs,
callerFetchOptions.signal, and returned signal/timer behavior while allowing
tests to provide a fake clock.

---

Outside diff comments:
In `@packages/db/src/client.test.ts`:
- Around line 209-215: Update the test for DEFAULT_QUERY_BUDGET_MS to assert
that it equals the contract’s exact 10-second value, 10_000, instead of only
checking a positive bounded range.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 0c843926-2df4-40c7-a7f7-998d62b69d56

📥 Commits

Reviewing files that changed from the base of the PR and between ed8d985 and 0419815.

📒 Files selected for processing (3)
  • packages/db/src/client.test.ts
  • packages/db/src/client.ts
  • packages/protocol/src/http.ts
📜 Review details
⏰ Context from checks skipped due to timeout. (2)
  • GitHub Check: Seer Code Review
  • GitHub Check: cubic · AI code reviewer
🧰 Additional context used
📓 Path-based instructions (11)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx}: Respect the dependency graph: @haru/protocol contains shared types/helpers; @haru/core is pure logic with no I/O; @haru/db depends on core; drivers, server, and supervisor must only use permitted dependencies. Shared server/supervisor code belongs in @haru/protocol.
Build outbound URLs with joinUrl from @haru/protocol; do not use new URL('/path', base) because it drops a base path prefix.
Keep all I/O injectable, including fetch, exec, spawn, and clocks.
Use the repository’s root linter and formatter configurations; do not add per-package linter configurations, and do not manually align formatting. Add root overrides with a reason comment when needed.

Keep new I/O behind injectable boundaries so it can be tested without GPUs, cloud accounts, or a running database.

新しい I/O は注入可能な境界の背後に配置し、外部実行、fetch、子プロセス、タイマーなどをテストダブルに置き換えられるようにする。

Files:

  • packages/protocol/src/http.ts
  • packages/db/src/client.test.ts
  • packages/db/src/client.ts
**/*

📄 CodeRabbit inference engine (AGENTS.md)

Do not reference consumer-private repositories or infrastructure, specific model names, or specific GPU names in code, comments, tests, docs, seeds, or example layouts.

**/*: Use kebab-case for file names.
Write comments in English.
Do not use the em dash character (U+2014) in code or prose; use a colon, comma, parentheses, or spaced hyphen instead.

ファイル名は kebab-case にする。

Files:

  • packages/protocol/src/http.ts
  • packages/db/src/client.test.ts
  • packages/db/src/client.ts
**/*.{ts,tsx,md,mdx}

📄 CodeRabbit inference engine (AGENTS.md)

Use English code comments and prose, and avoid the em dash character U+2014.

Files:

  • packages/protocol/src/http.ts
  • packages/db/src/client.test.ts
  • packages/db/src/client.ts
**/*.{ts,tsx,js,jsx,json,md,yml,yaml}

📄 CodeRabbit inference engine (CONTRIBUTING.md)

Use oxfmt as the owner of formatting, including whitespace, wrapping, quotes, and trailing commas; do not hand-tune formatting for ESLint.

Files:

  • packages/protocol/src/http.ts
  • packages/db/src/client.test.ts
  • packages/db/src/client.ts
**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (CONTRIBUTING.md)

Run both root-configured linters, oxlint --type-aware followed by strict type-aware ESLint 10; add overrides at the repository root rather than per-package configs.

**/*.{ts,tsx,js,jsx}: oxlint の型認識 lint を実行した後、型情報ベースの strict ESLint 10 を実行する。設定はパッケージごとではなくリポジトリルートに置き、例外には理由をコメントしたスコープ付きオーバーライドを優先する。
コード内のコメントは英語で記述する。

Files:

  • packages/protocol/src/http.ts
  • packages/db/src/client.test.ts
  • packages/db/src/client.ts
**/*.{ts,tsx,js,jsx,json,md,yaml,yml}

📄 CodeRabbit inference engine (CONTRIBUTING.ja.md)

oxfmt を使用して、空白、折り返し、クォート、末尾カンマを整形する。整形確認には pnpm format:check を使用する。

Files:

  • packages/protocol/src/http.ts
  • packages/db/src/client.test.ts
  • packages/db/src/client.ts
**/*.{ts,tsx,js,jsx,md}

📄 CodeRabbit inference engine (CONTRIBUTING.ja.md)

コードと文章ではエムダッシュ (U+2014) を使用せず、コロン、コンマ、括弧、またはスペース付きハイフンを使用する。

Files:

  • packages/protocol/src/http.ts
  • packages/db/src/client.test.ts
  • packages/db/src/client.ts
packages/db/src/**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

packages/db/src/**/*.{ts,tsx}: Implement every state transition as a single-statement compare-and-swap using an appropriate UPDATE ... WHERE state IN (...) RETURNING and verify the affected row count. Never use db.transaction() or hold external work between a read and its dependent write.
Enforce the core state tables as the single source of truth: repository code must reject invalid (from, to) pairs with InvalidTransitionError.

Files:

  • packages/db/src/client.test.ts
  • packages/db/src/client.ts
**/*.test.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.test.{ts,tsx}: Use @haru/db/testing and its committed migrations for database tests; do not add per-test migration calls. Use loadExampleFleetLayout for shared example layouts.
Server tests should drive Hono with app.request() and scripted fake supervisors; supervisor tests should use fake timers for SIGTERM-to-grace-to-SIGKILL escalation.

Files:

  • packages/db/src/client.test.ts
**/*.{test,spec}.{ts,tsx}

📄 CodeRabbit inference engine (CONTRIBUTING.md)

Add a Vitest case next to changed code when introducing or modifying behavior.

変更したコードの近くに Vitest のテストを追加し、外部 I/O は注入可能な境界に対してテストする。

Files:

  • packages/db/src/client.test.ts
packages/db/**/*.{ts,tsx}

📄 CodeRabbit inference engine (CONTRIBUTING.md)

Test @haru/db against in-memory PGlite using the committed Drizzle migrations, including compare-and-swap SQL and concurrent-winner races.

状態ストアの状態遷移では、compare-and-swap SQL によって並行実行時の勝者決定レースを保護する。

Files:

  • packages/db/src/client.test.ts
  • packages/db/src/client.ts
🔇 Additional comments (2)
packages/db/src/client.ts (1)

79-82: Validate budgetMs before creating the proxy.

The previous finding remains. withQueryBudget accepts invalid budgets and defers their normalization to the timer API. Require a finite non-negative integer. Define whether zero disables the budget or aborts immediately.

packages/protocol/src/http.ts (1)

7-18: LGTM!

Comment thread packages/db/src/client.test.ts Outdated
Comment thread packages/db/src/client.ts Outdated
Review raised a P1 that the signal never reaches fetch, because
`fetchOptions` would be construction-time only and the fake was inventing
a parameter the driver ignores. Checked it against the installed
@neondatabase/serverless 1.1.0 rather than arguing: `HTTPQueryOptions`
declares `fetchOptions`, the published examples pass it directly to
`query()`, and intercepting `neonConfig.fetchFunction` shows the signal
arriving at the fetch layer as the same object. So the budget does apply.

The objection was still pointing at a real gap. Every other case here
asserts what the wrapper hands a FAKE, which only means something while
the fake reproduces the driver's contract, and nothing was checking that.
A fake inventing an ignored parameter is exactly how this would have
become a silent no-op.

So the probe that settled it is now a test: it drives the real driver
with an intercepted fetch and asserts query-level `fetchOptions` reaches
it. If a future release moves the option to construction time, this fails
instead of the budget going quietly inert. `neonConfig` is process-global,
so the stub is restored in a `finally`.
@Nicolas0315

Copy link
Copy Markdown
Author

Checked the P1 against the installed driver rather than arguing it. Pushed as 18995ed.

@chatgpt-codex-connector (P1, signal never reaches fetch): not what 1.1.0 does — verified three ways.

  1. HTTPQueryOptions in @neondatabase/serverless@1.1.0 declares fetchOptions?: Record<string, any>, documented as "any options in fetchOptions are merged in to the options passed to fetch". It is a query option, not construction-only.
  2. The package's own examples pass it that way: "example 5: fetchOptions option direct to query() function".
  3. Behaviour, which is the part that actually settles it. Intercepting neonConfig.fetchFunction and issuing sql.query("select 1", [], { fetchOptions: { signal, priority: "high" } }) against 1.1.0:
fetch received keys : method, body, headers, signal, priority
signal delivered    : true
same signal object  : true
priority delivered  : high

The signal arrives at the fetch layer as the same object.

That said, the objection was aimed at a real gap, so I would rather close it than win it. Every other case in this file asserts what the wrapper hands a fake, which only means anything while the fake reproduces the driver's contract — and nothing was checking that. A fake that invents a parameter the driver ignores is precisely how this would have become a silent no-op, which is the failure mode this PR exists to prevent elsewhere.

So the probe above is now a test: it drives the real driver with an intercepted fetch and asserts query-level fetchOptions reaches it. If a future release moves the option to construction time, that fails loudly instead of the budget going quietly inert. (neonConfig is process-global, so the stub is restored in a finally.)

@coderabbitai (inject the timeout-signal clock): leaving it as-is, and the reasoning may be worth disagreeing with. The wrapper deliberately delegates to timeoutSignal from @haru/protocol so there is one composition-and-cleanup implementation in the repo rather than two; a factory parameter would add an injection seam whose only consumer is a test. The timing cases run on a 20ms real budget and the file completes in ~36ms, so there is no wall-clock cost to trade for it.

The one place you have a point is the timer-leak assertion: reading process.getActiveResourcesInfo() is coarse, which is why it measures a delta across five queries rather than an absolute count. If you would rather see that expressed as an injected clock I will take the seam, but I did not want to widen the API for it unprompted.

pnpm test (12/12 tasks), typecheck, lint, format:check green locally. CI is still action_required here, so local results remain the only evidence I can show.

@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

CodeRabbit chat interactions are restricted to organization members for this repository. Ask an organization member to interact with CodeRabbit, or set chat.allow_non_org_members: true in your configuration.

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create an environment for this repo.

Comment thread packages/db/src/client.ts Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 18995ed738

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/db/src/client.ts Outdated
The async wrapper was a real bug and the review caught it. Drizzle's
batch path calls client.query() per statement and hands the resulting
lazy NeonQueryPromise objects straight to client.transaction(), which
reads queryData and opts off them. Awaiting inside the wrapper collapsed
each one to a bare Promise, so batch would have lost the query data with
nothing else failing.

Confirmed against @neondatabase/serverless 1.1.0 rather than reasoning
about it: a direct query() returns NeonQueryPromise with keys
execute/queryData/opts, and the same call through an async wrapper
returns a plain Promise with none of them.

The wrapper is synchronous now and returns the driver's object untouched;
the budget timer is released by wrapping that object's execute in place.
transaction is budgeted too, since it is the single HTTP request the
batch path actually sends, and budgeting only query would have left the
one call that goes over the wire unbounded.

Two regression tests. One asserts the returned object still carries
queryData and matches the driver's own constructor, verified to fail
(expected undefined to be defined) when the wrapper is reverted to async.
The other asserts transaction receives a signal while keeping the
caller's isolationLevel.

Also stopped casting a caller-supplied signal blindly: a truthy
non-AbortSignal in fetchOptions would have thrown inside AbortSignal.any,
so it is instanceof-checked before composing.
@Nicolas0315

Copy link
Copy Markdown
Author

Both of last night's findings were right. Fixed in 976f09d.

@chatgpt-codex-connector (P2, lazy queries for batch): a real bug, thank you. Verified against @neondatabase/serverless@1.1.0 before changing anything:

raw query()   -> NeonQueryPromise | keys: execute,queryData,opts
async wrapper -> Promise          | keys: (none)

Drizzle's batch calls client.query() per statement and hands those lazy objects straight to client.transaction(), which reads queryData off them. Awaiting inside the wrapper collapsed each one to a bare promise, so batch would have lost the query data with nothing else failing. This repo does not call db.batch() today, which is exactly why it would have sat there until someone did.

The wrapper is synchronous now and returns the driver's object untouched; the timer is released by wrapping that object's execute in place rather than by replacing the promise. transaction is budgeted too, since it is the single HTTP request the batch path actually sends and budgeting only query would have left the one call that goes over the wire unbounded.

Regression test asserts the returned object still carries queryData and matches the driver's own constructor. Checked that it can fail: reverting the wrapper to async makes it fail with expected undefined to be defined, and nothing else in the file notices.

@sentry (unvalidated signal cast): correct. fetchOptions is Record<string, unknown>, so a truthy non-AbortSignal would have thrown inside AbortSignal.any. It is instanceof-checked before composing now.


Worth saying plainly: this PR argues that a wrapper can compile, pass its own tests and still not apply to a real query, and I then shipped a second instance of that in the same file. The lazy-object contract was invisible to every fake-based test here. Both of these were caught by review reading the driver rather than the diff, which is the part I had not done thoroughly enough.

pnpm test (12/12 tasks), typecheck, lint, format:check green locally. CI here is still action_required.

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create an environment for this repo.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

♻️ Duplicate comments (1)
packages/db/src/client.ts (1)

153-156: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Inject the timeout-signal factory so the budget clock is testable.

withQueryBudget reaches timeoutSignal directly through budgetedOptions, so every budget uses a real timer. Tests cannot substitute the clock. The suite compensates with wall-clock waits on a 20 ms budget, which is a known source of CI flakes on loaded runners. The coding guidelines require injectable clocks.

Accept a factory parameter and default it to timeoutSignal. Thread it through budgetedOptions.

♻️ Proposed change
 export function withQueryBudget<T extends NeonQueryClient>(
   client: T,
   budgetMs: number,
+  createTimeoutSignal: typeof timeoutSignal = timeoutSignal,
 ): T {
 function budgetedOptions(
   options: Record<string, unknown> | undefined,
   budgetMs: number,
+  createTimeoutSignal: typeof timeoutSignal = timeoutSignal,
 ): { merged: Record<string, unknown>; timer: ReturnType<typeof setTimeout> } {
   const callerFetchOptions =
     (options?.fetchOptions as Record<string, unknown> | undefined) ?? {};
   const existing = callerFetchOptions.signal;
-  const { signal, timer } = timeoutSignal(
+  const { signal, timer } = createTimeoutSignal(
     budgetMs,
     undefined,
     existing instanceof AbortSignal ? existing : undefined,
   );

As per coding guidelines, "Keep all I/O injectable, including fetch, exec, spawn, and clocks." <coding_guidelines>

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/db/src/client.ts` around lines 153 - 156, Update withQueryBudget to
accept an injectable timeout-signal factory parameter defaulting to
timeoutSignal, then pass that factory through to budgetedOptions so callers and
tests can control the budget clock without real timers.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/db/src/client.test.ts`:
- Around line 261-278: Extend the transaction wrapper tests around
withQueryBudget to verify timer cleanup on both resolved and rejected
transaction calls. Use the file’s existing timer-counting helper to capture the
initial pending-timer count and assert it is restored after each transaction
settles, while preserving the existing fetchOptions and caller-option
assertions.
- Around line 254-255: Remove the `.catch()` subscriptions for the `direct` and
`budgeted` lazy query objects in the test, leaving them unobserved so the test
continues to verify that no execution occurs. Preserve the existing assertions
and timer behavior without adding another rejection guard.
- Around line 238-251: The test should also verify that withQueryBudget applies
an abort signal to Neon’s lazy query options. In the budgeted query assertions,
read budgeted.opts?.fetchOptions?.signal and assert it is an AbortSignal, while
retaining the existing queryData assertions that validate the lazy
NeonQueryPromise shape.

In `@packages/db/src/client.ts`:
- Around line 68-79: Validate budgetMs at the budgetedOptions boundary before
calling timeoutSignal, rejecting non-finite, non-positive, or non-integer values
with an error that identifies the invalid configuration; explicitly treat 0 as
rejected rather than “no budget.”
- Around line 108-111: Guard result for null or undefined before accessing
execute in the lazy-result handling block, then preserve the existing
direct-result path and ensure the timer cleanup still runs when no executable
result is returned. Update the surrounding logic rather than relying on the
unchecked cast alone.
- Around line 101-121: Update clearTimerWhenSettled() and the withQueryBudget()
lazy-query handling so per-query timers do not keep the runtime open when
execute is never invoked. Immediately unref each timer where supported, and
ensure timers are cleared when their owning query promise is consumed or
abandoned, including multiple query objects passed to client.transaction().

---

Duplicate comments:
In `@packages/db/src/client.ts`:
- Around line 153-156: Update withQueryBudget to accept an injectable
timeout-signal factory parameter defaulting to timeoutSignal, then pass that
factory through to budgetedOptions so callers and tests can control the budget
clock without real timers.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: ba71e91e-4381-4877-b776-7da882fa99e0

📥 Commits

Reviewing files that changed from the base of the PR and between 18995ed and 976f09d.

📒 Files selected for processing (2)
  • packages/db/src/client.test.ts
  • packages/db/src/client.ts
📜 Review details
⏰ Context from checks skipped due to timeout. (2)
  • GitHub Check: Seer Code Review
  • GitHub Check: cubic · AI code reviewer
🧰 Additional context used
📓 Path-based instructions (11)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx}: Respect the dependency graph: @haru/protocol contains shared types/helpers; @haru/core is pure logic with no I/O; @haru/db depends on core; drivers, server, and supervisor must only use permitted dependencies. Shared server/supervisor code belongs in @haru/protocol.
Build outbound URLs with joinUrl from @haru/protocol; do not use new URL('/path', base) because it drops a base path prefix.
Keep all I/O injectable, including fetch, exec, spawn, and clocks.
Use the repository’s root linter and formatter configurations; do not add per-package linter configurations, and do not manually align formatting. Add root overrides with a reason comment when needed.

Keep new I/O behind injectable boundaries so it can be tested without GPUs, cloud accounts, or a running database.

新しい I/O は注入可能な境界の背後に配置し、外部実行、fetch、子プロセス、タイマーなどをテストダブルに置き換えられるようにする。

Files:

  • packages/db/src/client.test.ts
  • packages/db/src/client.ts
packages/db/src/**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

packages/db/src/**/*.{ts,tsx}: Implement every state transition as a single-statement compare-and-swap using an appropriate UPDATE ... WHERE state IN (...) RETURNING and verify the affected row count. Never use db.transaction() or hold external work between a read and its dependent write.
Enforce the core state tables as the single source of truth: repository code must reject invalid (from, to) pairs with InvalidTransitionError.

Files:

  • packages/db/src/client.test.ts
  • packages/db/src/client.ts
**/*.test.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.test.{ts,tsx}: Use @haru/db/testing and its committed migrations for database tests; do not add per-test migration calls. Use loadExampleFleetLayout for shared example layouts.
Server tests should drive Hono with app.request() and scripted fake supervisors; supervisor tests should use fake timers for SIGTERM-to-grace-to-SIGKILL escalation.

Files:

  • packages/db/src/client.test.ts
**/*

📄 CodeRabbit inference engine (AGENTS.md)

Do not reference consumer-private repositories or infrastructure, specific model names, or specific GPU names in code, comments, tests, docs, seeds, or example layouts.

**/*: Use kebab-case for file names.
Write comments in English.
Do not use the em dash character (U+2014) in code or prose; use a colon, comma, parentheses, or spaced hyphen instead.

ファイル名は kebab-case にする。

Files:

  • packages/db/src/client.test.ts
  • packages/db/src/client.ts
**/*.{ts,tsx,md,mdx}

📄 CodeRabbit inference engine (AGENTS.md)

Use English code comments and prose, and avoid the em dash character U+2014.

Files:

  • packages/db/src/client.test.ts
  • packages/db/src/client.ts
**/*.{test,spec}.{ts,tsx}

📄 CodeRabbit inference engine (CONTRIBUTING.md)

Add a Vitest case next to changed code when introducing or modifying behavior.

変更したコードの近くに Vitest のテストを追加し、外部 I/O は注入可能な境界に対してテストする。

Files:

  • packages/db/src/client.test.ts
**/*.{ts,tsx,js,jsx,json,md,yml,yaml}

📄 CodeRabbit inference engine (CONTRIBUTING.md)

Use oxfmt as the owner of formatting, including whitespace, wrapping, quotes, and trailing commas; do not hand-tune formatting for ESLint.

Files:

  • packages/db/src/client.test.ts
  • packages/db/src/client.ts
**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (CONTRIBUTING.md)

Run both root-configured linters, oxlint --type-aware followed by strict type-aware ESLint 10; add overrides at the repository root rather than per-package configs.

**/*.{ts,tsx,js,jsx}: oxlint の型認識 lint を実行した後、型情報ベースの strict ESLint 10 を実行する。設定はパッケージごとではなくリポジトリルートに置き、例外には理由をコメントしたスコープ付きオーバーライドを優先する。
コード内のコメントは英語で記述する。

Files:

  • packages/db/src/client.test.ts
  • packages/db/src/client.ts
packages/db/**/*.{ts,tsx}

📄 CodeRabbit inference engine (CONTRIBUTING.md)

Test @haru/db against in-memory PGlite using the committed Drizzle migrations, including compare-and-swap SQL and concurrent-winner races.

状態ストアの状態遷移では、compare-and-swap SQL によって並行実行時の勝者決定レースを保護する。

Files:

  • packages/db/src/client.test.ts
  • packages/db/src/client.ts
**/*.{ts,tsx,js,jsx,json,md,yaml,yml}

📄 CodeRabbit inference engine (CONTRIBUTING.ja.md)

oxfmt を使用して、空白、折り返し、クォート、末尾カンマを整形する。整形確認には pnpm format:check を使用する。

Files:

  • packages/db/src/client.test.ts
  • packages/db/src/client.ts
**/*.{ts,tsx,js,jsx,md}

📄 CodeRabbit inference engine (CONTRIBUTING.ja.md)

コードと文章ではエムダッシュ (U+2014) を使用せず、コロン、コンマ、括弧、またはスペース付きハイフンを使用する。

Files:

  • packages/db/src/client.test.ts
  • packages/db/src/client.ts
🔇 Additional comments (1)
packages/db/src/client.ts (1)

52-62: LGTM!

Comment thread packages/db/src/client.test.ts
Comment thread packages/db/src/client.test.ts Outdated
Comment thread packages/db/src/client.test.ts Outdated
Comment thread packages/db/src/client.ts
Comment thread packages/db/src/client.ts
Comment thread packages/db/src/client.ts Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 976f09d37d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/db/src/client.ts

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 2 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread packages/db/src/client.ts
Comment thread packages/db/src/client.ts
Comment thread packages/db/src/client.test.ts Outdated
The previous commit fixed batch by keeping query() lazy, and in doing so
introduced the opposite leak: a batched statement's timer was only
cleared from the execute hook, and batch hands its lazy queries to
transaction() without executing them individually. Measured before
changing anything - building three lazy queries left three 60s timers
outstanding, and nothing ever cleared them.

Each build now tags its timer onto the lazy object, and the transaction
wrapper releases them alongside its own. Same probe after the fix: three
timers on build, zero after transaction().

Two other findings from the same round, both correct:

- budgetMs went straight into setTimeout, which coerces 0, negatives,
  NaN and Infinity to a 1ms delay rather than refusing them, so a
  mistyped queryBudgetMs would have aborted every query almost instantly
  and read as a total outage. It now throws a RangeError at the call.
- The execute probe read a property off an unchecked value, so a driver
  returning null would crash inside the wrapper and strand the timer.
  Null is handled and the timer cleared.

Also removed the .catch() calls in the laziness test: a lazy Neon query
is a thenable, so subscribing to it called execute and fired the very
request the test says it never runs.
@Nicolas0315

Copy link
Copy Markdown
Author

All four correct. Fixed in d59c989, and one of them was a bug my own previous fix introduced.

@chatgpt-codex-connector and @cubic-dev-ai (batch-built query timers): right, and this was self-inflicted. The commit that kept query() lazy moved the timer cleanup into the lazy object's execute — and batch is precisely the path that never executes them individually. Measured before touching anything:

after building 3 lazy queries : 3
after transaction()           : 0   (before this fix: still 3)

Each build now tags its timer onto the lazy object and the transaction wrapper releases them alongside its own. Test asserts both halves of that probe.

Worth stating: fixing the collapse introduced the leak, so this file has now produced the same class of defect twice in a row. Both times it was the interaction with a lazy driver object that the fake-based tests could not see.

@coderabbitai (validate budgetMs): right, and I was wrong to defer it earlier. I argued it had no caller, but queryBudgetMs is a public createDatabase option, so it does. setTimeout coerces 0, negatives, NaN and Infinity to a 1ms delay instead of refusing them, which would abort every query almost immediately and read as a total store outage — the worst possible failure for a knob whose whole job is telling an outage apart from latency. It throws a RangeError at the call now.

@coderabbitai (guard result before reading execute): right. A driver returning null would have crashed inside the wrapper and stranded the timer. Handled, timer cleared.

@coderabbitai (the .catch() calls execute the query the test says it never runs): right, and this is the sharpest one. A lazy Neon query is a thenable, so subscribing at all calls execute and fires the request through the stubbed fetchFunction. The comment claimed the opposite. The subscriptions are gone; nothing is subscribed, which is what makes the assertion mean what it says.

pnpm test (12/12), typecheck (12/12), lint (12/12), format:check green locally. CI here is still action_required.

@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

CodeRabbit chat interactions are restricted to organization members for this repository. Ask an organization member to interact with CodeRabbit, or set chat.allow_non_org_members: true in your configuration.

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create an environment for this repo.

@cubic-dev-ai

cubic-dev-ai Bot commented Aug 5, 2026

Copy link
Copy Markdown

All four correct. Fixed in d59c989, and one of them was a bug my own previous fix introduced.

@chatgpt-codex-connector and @cubic-dev-ai (batch-built query timers): right, and this was self-inflicted. The commit that kept query() lazy moved the timer cleanup into the lazy object's execute — and batch is precisely the path that never executes them individually. Measured before touching anything:

...

@Nicolas0315 Confirmed against the code — all four land the way you describe:

  • Batch timer leak: the lazy query now carries its timer on the BUDGET_TIMER symbol, and the transaction wrapper's finally walks queries and clears each tagged timer. That's the fix matching your probe (3 on build → 0 after transaction()). The regression test measures both halves, and matching constructors via constructor.name pins the lazy shape.
  • budgetMs validation: budgetedOptions throws RangeError for non-finite and <= 0, before setTimeout can coerce them to 1ms. The test covers 0, -1, NaN, Infinity.
  • Null guard: clearTimerWhenSettled checks null/undefined before touching execute, clears the timer, and returns. The no-driver-result test verifies no crash and no stranded timer.
  • .catch() executing the lazy query: the laziness test subscribes to nothing now, so the "never runs" claim is actually true. The .finally-wrapped execute is the only path that fires.

The self-inflicted framing is fair — both defects came from the lazy-object contract hiding behind fake-based tests, and the real-driver probe is what makes that class visible now. Nothing further from me on this round; the N+1-style "same failure twice" note in your reply is the right takeaway to carry forward.

One small thing if you touch this file again: the timer tagging via a module-level Symbol is fine, but it lives on the public lazy object shape. Should a future driver stop returning a lazy execute-bearing object, the tag just never gets set and the transaction loop no-ops — which is the silent kind of failure this file has now bitten twice. Worth a comment on withQueryBudget's transaction noting that the cleanup loop is only meaningful while the driver returns execute-bearing lazies, so a reader doesn't assume that invariant.

Tip: get faster answers by chatting with cubic’s review copilot in the review UI

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 2 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread packages/db/src/client.ts Outdated
Comment thread packages/db/src/client.test.ts Outdated
…back form

Neon documents two shapes for transaction(): an array of queries, and a
callback returning them. The per-statement cleanup added in the previous
commit iterated the first argument unconditionally, so the callback form
threw 'queries is not iterable' from inside finally, AFTER the request
had already succeeded. A working transaction came back as a failure.

Reproduced before fixing: transaction(txn => [txn]) threw;
both forms now return their result.

The first attempt at the guard was itself wrong in a way the test caught:
an early  inside  overwrites the value  produced, so
the callback form resolved to undefined instead of the driver's result.
The cleanup is conditional now rather than early-returning.

The internal client type claimed transaction only took an array, which is
the lie the wrapper then acted on; it matches the driver's real signature
now. Worth noting the type error only surfaced in build and typecheck --
vitest does not typecheck, so the suite was green while this was wrong.

Also shortened the budget in the laziness test from 5s to 20ms: that test
deliberately never executes the query, so nothing cleared the timer and it
added a real 5s tail to the run.
@Nicolas0315

Copy link
Copy Markdown
Author

All three from this round were right, and the P2 was a genuine crash. Fixed in 4826681.

@cubic-dev-ai (P2, callback form of transaction): correct, and it turned a working transaction into a failure. Neon documents two shapes — an array of queries, and a callback returning them. The per-statement cleanup I added in the previous commit iterated the first argument unconditionally, so the callback form threw TypeError: queries is not iterable from inside finally, after the request had already succeeded. Reproduced before touching anything:

callback form -> THROWS: TypeError: queries is not iterable

Both forms return their result now, and there is a regression test for the callback shape.

Two things fell out of fixing it that are worth recording:

  • My first guard was an early return inside the finally, which overwrites the value try produced — the callback form then resolved to undefined instead of the driver's result. The new test caught it immediately.
  • The internal client type declared transaction as taking only an array. That was the assumption the wrapper acted on, so the type was the bug's accomplice. It matches the driver's real signature now. Notably this only surfaced in build/typecheck: vitest does not typecheck, so the suite was green the whole time it was wrong.

@cubic-dev-ai (P3, abandoned 5s timer in the laziness test): correct. That test deliberately never executes the query, so nothing clears the timer and it was adding a real 5s tail to the suite. Budget dropped to 20ms; the file now runs in ~50ms.

@cubic-dev-ai (P3, two fake-based tests sitting in the driver-contract block): correct — that block's whole purpose is pinning real-driver assumptions and its header comment says so, so putting fake-based cases there undermines the distinction. Will move them; flagging it here rather than silently leaving it.


One housekeeping note: the commit message on 4826681 is mangled. I passed it through a shell heredoc and the backticks around return, finally and try were interpreted away, so a couple of sentences read as if words are missing. The diff is correct; only the message suffers. I would rather leave a broken message than force-push over a branch you may already be reviewing, so it stays as-is unless you would prefer I rewrite the history.

build, typecheck, lint, test (12/12 each) and format:check green locally. CI here is still action_required.

@cubic-dev-ai

cubic-dev-ai Bot commented Aug 5, 2026

Copy link
Copy Markdown

All three from this round were right, and the P2 was a genuine crash. Fixed in 4826681.

@cubic-dev-ai (P2, callback form of transaction): correct, and it turned a working transaction into a failure. Neon documents two shapes — an array of queries, and a callback returning them. The per-statement cleanup I added in the previous commit iterated the first argument unconditionally, so the callback form threw TypeError: queries is not iterable from inside finally, after the request had already succeeded. Reproduced before touching anything:

...

@Nicolas0315 All three land as you describe, and the callback-form fix looks right in the diff.

P2, callback form of transaction: The cleanup loop is now gated on Array.isArray(queries), so the callback form skips the per-statement walk entirely and returns the driver's result. The early-return-in-finally hazard you caught — the first guard overwriting the value try produced — is also handled: the finally block runs the conditional cleanup and only the clearTimeout(timer) line before the try value propagates. No return in the finally, so the driver result isn't clobbered. And the interface now admits the callback shape (unknown[] | ((...args) => unknown)), which closes the type-accomplice gap. All consistent with what you said, and the callback-form regression test pins it.

P3, abandoned 5s timer: The laziness test now uses a 20ms budget, and the comment explains why (never executes, so nothing clears the timer — a long one would add a real tail). Matches your ~50ms suite figure.

P3, fake-based tests in the driver-contract block: Fair call on my part — that block's header explicitly promises real-driver assumptions, and the fake-based cases (invalid budget, callback form, transaction-options, null-return) do blur that line. Moving them keeps the block honest about what it pins. No argument from me; worth doing on the next touch-up.

On the mangled commit message: perfectly reasonable to leave it. git commit --amend to fix the message wouldn't need a force-push if the branch tip is yours and no one pulled from that exact SHA, but since you've already shared the branch for review, optimizing for reviewer stability over a clean message is the pragmatic call. It doesn't affect the diff or CI, and the message content is still legible enough that it's not worth the history churn.

Nothing further from me on this round — the callback form was the last real defect in that file, and the typecheck-catches-what-vitest-misses point is a good one to keep in mind going forward.

Tip: get faster answers by chatting with cubic’s review copilot in the review UI

Four cases exercising withQueryBudget against a fake had drifted into the
"driver contract the budget depends on" block, whose stated purpose is
pinning what the REAL driver does. That block's header comment draws
exactly this distinction, so fake-based cases sitting inside it undermine
the separation it exists to make: a reader cannot tell which assertions
would survive a driver change.

They move back to the withQueryBudget suite. The contract block now holds
only the three cases that drive the real Neon client: laziness, the
per-statement timers batch leaves behind, and query-level fetchOptions
reaching fetch.

No assertions changed; 17 tests still pass.
@Nicolas0315

Copy link
Copy Markdown
Author

@coderabbitai review

The three change requests above were addressed across the commits that followed them; the current head is 08c2ffe. Requesting a fresh pass so the review state reflects the branch as it stands.

@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

CodeRabbit chat interactions are restricted to organization members for this repository. Ask an organization member to interact with CodeRabbit, or set chat.allow_non_org_members: true in your configuration.

The default-budget case asserted a range (> 0, <= 30s). The constant's own
comment claims something narrower and load-bearing: the budget does not
exceed `switchActiveTimeoutMs`, so a hung pointer read cannot outlive the
step that issued it. The range never checked that, and would have stayed
green all the way to a 30s default.

Pinning the literal 10_000 instead would only restate the source and would
fail on any deliberate tuning with no bug present. The relationship is the
part worth protecting, so it is asserted against the live policy default
(`fleetPolicySchema.parse({})`) rather than a second hardcoded number.

Verified both directions: raising the default to 10_001 fails with
"expected 10001 to be less than or equal to 10000", while the old range
assertion accepted it.
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Credits must be used to enable repository wide code reviews.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 1 file (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread packages/db/src/client.test.ts
The guard rejected NaN, Infinity, zero and negatives, but the comment
above it names the real hazard: a value setTimeout turns into ~1ms aborts
every query and reads as a total outage. Two values did exactly that and
passed. A fractional budget (0.5) rounds up to 1ms, and anything past
2^31-1 is clamped to 1ms.

`timeoutMsSchema` in @haru/protocol already encodes both bounds, and its
comment states the reason. Every other millisecond budget in the repo
goes through it; this one did not. It is exported and reused rather than
restated, so the two cannot drift.

The check also moves from `budgetedOptions` to `withQueryBudget`. The
schema comment says the point is that "misconfiguration is a config-time
error", and validating per query reported a bad `queryBudgetMs` from
whatever statement happened to run first instead of from the wiring that
set it.

Verified: with the old finite-and-positive check restored, the two added
cases fail; the wiring-time assertion holds because the test now calls
the constructor rather than a query.
The case wrapped the drizzle call in a bare catch commented "response-
shape mismatch only", but the catch accepted anything. A wrapper that
threw AFTER recording the call would leave the recorded-call assertions
satisfied and the failure invisible, which is the opposite of what this
case exists to prove.

The fake now returns a `fullResults` envelope, so drizzle completes and
the query is simply awaited.

Verified: with the fake rejecting after the call is recorded, the case
now fails ("promise rejected ... instead of resolving"); with the catch
it passed.
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Credits must be used to enable repository wide code reviews.

@Nicolas0315

Copy link
Copy Markdown
Author

Three commits addressing the remaining review comments, plus one they led me to that nobody had flagged.

a147eb1 - the default-budget case asserted a range (> 0, <= 30s). CodeRabbit's point that a range does not protect the documented default is right, though pinning the literal 10_000 would only restate the source and fail on any deliberate tuning. What the constant's comment actually claims is a relationship: the budget does not exceed switchActiveTimeoutMs, so a hung pointer read cannot outlive the step that issued it. That is now asserted against the live policy default. Raising the default to 10_001 fails it; the old range accepted everything up to 30s.

b1f7dda - the budgetMs guard rejected NaN, Infinity, zero and negatives, but two values that setTimeout turns into ~1ms got through: a fractional budget, and anything past 2^31-1. timeoutMsSchema in @haru/protocol already bounds both ends and its comment gives the reason; this was the only millisecond budget in the repo not going through it, so it is exported and reused rather than restated. The check also moved to withQueryBudget, since the schema's stated point is that misconfiguration should be a config-time error.

65f3f28 - the drizzle integration case wrapped its query in a bare catch commented "response-shape mismatch only" that accepted anything, so a wrapper throwing after the call was recorded would have stayed invisible.

Each change was checked by breaking it: old guard restored, old range restored, fake made to reject after recording. All gates green locally (build, typecheck, lint, test, format:check).

One note for whenever this lands relative to #27: git reports the two as conflict-free, but 15dd5e7 there removes transaction from HaruDatabase and adds a no-restricted-syntax ban on .transaction( calls. This branch does not trip it, because the driver-level call is bound to a local first. That is luck rather than design on my part, and worth knowing since the selector cannot tell a banned db.transaction() from the Neon batch call drizzle itself makes.

…have

The comment said the default budget "does not exceed
`switchActiveTimeoutMs`, so a hung pointer read cannot outlive the step
that issued it". The relationship holds; the conclusion does not. The
step timeout runs from `stepStartedAt`, while this budget restarts at
every query, so a read issued partway through a step expires after that
step's deadline. With both values at 10_000 there is no margin at all,
so only a read issued exactly at step entry is covered.

The repo already solves this for the other transport, and the contrast is
worth stating rather than papering over: `withSupervisor` caps each call
to what remains of the ABSOLUTE `stepDeadlineMs`, which is why
`StepContext` documents that "a call issued with 1ms left cannot run for
its full per-call timeout on top". Giving the database transport the same
property means threading that deadline down into it, which is a larger
change than bounding the transport at all, so the comment now states the
weaker guarantee it actually provides and names the gap.

Assertion unchanged: the budget staying inside the tightest step budget
is still worth pinning, and the test comment now describes it as a bound
rather than containment.
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Credits must be used to enable repository wide code reviews.

@greptile-apps

greptile-apps Bot commented Aug 7, 2026

Copy link
Copy Markdown

Want your agent to iterate on Greptile's feedback? Start a greploop in Claude Code and it will work through the open comments and keep going until this PR reviews clean.

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