Skip to content

fix: stabilize records keyset pagination across timestamp formats#6

Merged
Kzoeps merged 7 commits intofix/tap-ack-issuefrom
fix/stalling-issue
Apr 14, 2026
Merged

fix: stabilize records keyset pagination across timestamp formats#6
Kzoeps merged 7 commits intofix/tap-ack-issuefrom
fix/stalling-issue

Conversation

@Kzoeps
Copy link
Copy Markdown
Collaborator

@Kzoeps Kzoeps commented Apr 10, 2026

Summary

  • fix keyset pagination for records by normalizing indexed_at comparisons per database dialect (SQLite normalized text timestamps, Postgres typed timestamptz comparisons)
  • update GraphQL cursor timestamp serialization to UTC RFC3339Nano for more stable ordering and fewer same-second collisions
  • add regression tests covering forward and backward keyset pagination when stored SQLite timestamps and cursor timestamp formats differ

Validation

  • go test ./internal/database/repositories ./internal/graphql/schema
  • go test ./...
  • verified staging pagination for org.hypercerts.claim.activity on https://hyperindex-staging.up.railway.app/graphql no longer stalls or repeats cursors across pages

Summary by CodeRabbit

  • Bug Fixes

    • Keyset pagination now consistently normalizes timestamps so cursor navigation works across differently formatted stored dates; cursor sorting/ordering is more reliable.
  • Chores

    • Centralized pagination helpers to reduce duplication and improve maintainability.
  • Tests

    • Added tests validating keyset pagination with non‑RFC3339 timestamp formats.
  • Documentation

    • Added local pre-commit linting instructions and a make target to install Git hooks.
  • CI

    • Workflow now runs for pull requests regardless of target branch.

@vercel
Copy link
Copy Markdown

vercel bot commented Apr 10, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
hyperindex-atproto-client Ready Ready Preview, Comment Apr 14, 2026 9:39am

Request Review

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 10, 2026

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3baab0d4-28f1-4087-998c-abac974a1be8

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Centralized handling and normalization of the indexed_at sort field for keyset pagination, updated related queries and tests, changed GraphQL cursor timestamp formatting to UTC RFC3339Nano, added a pre-commit hook and Makefile target, modified DPoP key handling/serialization, and adjusted TAP consumer runOnce signature.

Changes

Cohort / File(s) Summary
Database repository (keyset helpers & queries)
internal/database/repositories/records.go
Introduced helpers to normalize the indexed_at sort field and cursor expressions; replaced hardcoded indexed_at ORDER BY/WHERE and raw placeholders across keyset pagination query builders to use normalized expressions.
Database tests (keyset behavior)
internal/database/repositories/records_test.go
Added TestRecordsRepository_KeysetPagination_NormalizesIndexedAtFormats to verify keyset pagination works when indexed_at is stored in non‑RFC3339 formats (forward and backward flows).
Database filter tests (sort expr expectations)
internal/database/repositories/records_filter_test.go
Updated expected sort expressions to use the normalized strftime('%Y-%m-%dT%H:%M:%fZ', indexed_at) form in existing tests.
GraphQL cursor formatting
internal/graphql/schema/builder.go
Changed cursor sort-field formatting to rec.IndexedAt.UTC().Format(time.RFC3339Nano) for cursor generation (RFC3339Nano UTC).
OAuth DPoP key handling (crypto changes)
internal/oauth/dpop.go
Reworked JWK serialization/parsing to use fixed-size coordinate/scalar normalization, added defensive fallbacks, helpers for coordinate sizing/normalization, and stricter parsing/validation of public/private keys (avoids direct big.Int assignment).
OAuth tests
internal/oauth/dpop_test.go
Adjusted tests to compare normalized/raw byte representations and use PublicKey.Equal / PrivateKey.Equal instead of big.Int scalar comparisons; added error handling around Bytes() calls.
TAP consumer runOnce signature
internal/tap/consumer.go
Changed runOnce return ordering from (error, bool) to (bool, error) and updated call sites/return logic to preserve reconnect semantics.
Pre-commit hook
.githooks/pre-commit
Added a pre-commit hook that formats staged .go files with gofmt and runs golangci-lint on affected packages; supports bypass via SKIP_GOLANGCI=1.
Makefile hook install target
Makefile
Added .PHONY hooks-install and a hooks-install target that sets core.hooksPath to .githooks.
Docs: README
README.md
Documented the new make hooks-install step and the tracked local pre-commit hook with SKIP_GOLANGCI bypass.
CI workflow trigger
.github/workflows/ci.yml
Removed pull_request.branches: [main] filter so the workflow runs for PRs targeting any branch.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰
I nudge timestamps into tidy rows,
I hop through cursors where the keyset goes,
UTC and nano sparkle bright,
Hooks and keys kept snug tonight,
A little rabbit’s code-approved delight.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 27.78% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the primary change: stabilizing keyset pagination for records across different timestamp formats, which is the core focus of the PR.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/stalling-issue

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 and usage tips.

@Kzoeps
Copy link
Copy Markdown
Collaborator Author

Kzoeps commented Apr 13, 2026

@coderabbitai please review this

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 13, 2026

@Kzoeps: Sure! I'll start reviewing the PR right away.

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
internal/database/repositories/records_test.go (1)

520-605: Convert this regression into a table-driven test.

The new coverage is useful, but this shape makes it harder to extend with the mixed-format permutations that matter here. A case table would remove the duplicated setup/assert logic and make it straightforward to add "YYYY-MM-DD HH:MM:SS" + RFC3339 rows in the same dataset.

As per coding guidelines, "Use table-driven tests for test functions with a test slice containing name, input, expected output, and error expectation fields".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/database/repositories/records_test.go` around lines 520 - 605,
Convert TestRecordsRepository_KeysetPagination_NormalizesIndexedAtFormats into a
table-driven test: create a slice of cases (fields: name, function to call or
direction flag, inputCursor []string, expectedURIs []string) and iterate with
t.Run per case; keep the common setup (calls to insertTestRecord and
sqlDB.ExecContext to set indexed_at strings) outside the loop, then call
repo.GetByCollectionSortedWithKeysetCursor or
repo.GetByCollectionReversedWithKeysetCursor based on the case; replace the
duplicated assertions with a single check comparing returned URIs to
expectedURIs and include cases that use both RFC3339 ("2026-01-15T12:00:00Z")
and SQL datetime ("2026-01-15 12:00:00") cursor formats to cover mixed-format
permutations.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/database/repositories/records.go`:
- Around line 611-629: The ORDER BY expression for indexed_at must match the
keyset predicate normalization on SQLite: update the code path that builds sort
expressions (e.g., RecordsRepository.buildSortExpr / buildSortExpr) so that when
sortField == "indexed_at" and r.db.Dialect() is SQLite it returns the same
normalized expression as keysetSortFieldExpr ("strftime('%Y-%m-%dT%H:%M:%fZ',
indexed_at)") instead of the raw column; apply the same change wherever ORDER BY
indexed_at is constructed (including the other build/ordering helpers referenced
around the keyset code) so ordering and keyset comparisons are consistent.

---

Nitpick comments:
In `@internal/database/repositories/records_test.go`:
- Around line 520-605: Convert
TestRecordsRepository_KeysetPagination_NormalizesIndexedAtFormats into a
table-driven test: create a slice of cases (fields: name, function to call or
direction flag, inputCursor []string, expectedURIs []string) and iterate with
t.Run per case; keep the common setup (calls to insertTestRecord and
sqlDB.ExecContext to set indexed_at strings) outside the loop, then call
repo.GetByCollectionSortedWithKeysetCursor or
repo.GetByCollectionReversedWithKeysetCursor based on the case; replace the
duplicated assertions with a single check comparing returned URIs to
expectedURIs and include cases that use both RFC3339 ("2026-01-15T12:00:00Z")
and SQL datetime ("2026-01-15 12:00:00") cursor formats to cover mixed-format
permutations.
🪄 Autofix (Beta)

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: defaults

Review profile: CHILL

Plan: Pro

Run ID: ba0a6a7b-0bbf-4624-9eb1-4b3971e82910

📥 Commits

Reviewing files that changed from the base of the PR and between 3a06bdd and 11c7221.

📒 Files selected for processing (3)
  • internal/database/repositories/records.go
  • internal/database/repositories/records_test.go
  • internal/graphql/schema/builder.go

@railway-app railway-app bot temporarily deployed to hyperindex-v2-setup / staging April 14, 2026 08:42 Inactive
@railway-app railway-app bot temporarily deployed to hyperindex-v2-setup / pr-base April 14, 2026 08:47 Inactive
@railway-app railway-app bot temporarily deployed to hyperindex-v2-setup / pr-base April 14, 2026 09:07 Inactive
@railway-app railway-app bot temporarily deployed to hyperindex-v2-setup / staging April 14, 2026 09:07 Inactive
@Kzoeps
Copy link
Copy Markdown
Collaborator Author

Kzoeps commented Apr 14, 2026

@coderabbitai please review

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 14, 2026

@Kzoeps: Sure! I'll start reviewing the PR right away.

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (4)
internal/database/repositories/records.go (1)

617-628: Typo in function name: normalizedIndextAtExprnormalizedIndexedAtExpr.

The function name has a typo ("Indext" instead of "Indexed"). This affects readability and could cause confusion.

✏️ Suggested rename
-// normalizedIndextAtExpr returns the record-side indexed_at expression used for
+// normalizedIndexedAtExpr returns the record-side indexed_at expression used for
 // ordering and keyset comparisons. SQLite stores indexed_at as TEXT and may
 // contain mixed formats (e.g. "YYYY-MM-DD HH:MM:SS" and RFC3339), so values are
 // normalized to a canonical sortable UTC representation.
-func (r *RecordsRepository) normalizedIndextAtExpr() string {
+func (r *RecordsRepository) normalizedIndexedAtExpr() string {

Then update all call sites (lines 320, 350, 549, 666, 677, 913, 1270, 1284).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/database/repositories/records.go` around lines 617 - 628, Rename the
function normalizedIndextAtExpr to normalizedIndexedAtExpr to fix the typo and
update every call site that invokes normalizedIndextAtExpr to the new name;
specifically change the method declaration on RecordsRepository and replace all
references (all places where normalizedIndextAtExpr() is called) so builds and
callers use normalizedIndexedAtExpr(), preserving the same return logic
(PostgreSQL returns "indexed_at", others use the strftime expression).
internal/tap/consumer.go (1)

117-117: Make the connection outcome self-describing.

This PR fixes a swapped return-order bug, but runOnce still exposes another positional (bool, error) pair. A tiny result type here would make the call site much harder to regress.

♻️ Possible shape
+type runOnceResult struct {
+	connected bool
+}
+
- connected, err := c.runOnce(ctx)
- if connected {
+ result, err := c.runOnce(ctx)
+ if result.connected {
		backoff = minBackoff
	}

-func (c *Consumer) runOnce(ctx context.Context) (bool, error) {
+func (c *Consumer) runOnce(ctx context.Context) (runOnceResult, error) {

Also applies to: 171-174

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/tap/consumer.go` at line 117, The call site uses a positional (bool,
error) return from runOnce which is easy to mis-order; change runOnce's
signature to return a small named result type instead of plain bool: define a
RunOnceResult struct (e.g., type RunOnceResult struct { Connected bool }) and
update runOnce to return (RunOnceResult, error), then update all call sites
(e.g., the c.runOnce(ctx) usages that assign to connected, err) to use res, err
:= c.runOnce(ctx) and check res.Connected; apply the same change to other call
sites around the block mentioned (the calls at the other location referenced) so
the meaning is explicit and not position-dependent.
.githooks/pre-commit (2)

37-38: Note: CI pins golangci-lint to v2.8.0.

The hook runs whichever golangci-lint version is installed locally, while CI uses v2.8.0 (per .github/workflows/ci.yml). Different versions may produce different results.

Consider documenting the expected version in README or updating make tools to install the pinned version.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.githooks/pre-commit around lines 37 - 38, The pre-commit hook prints
"pre-commit: running golangci-lint on staged packages..." and then runs
golangci-lint run "${packages[@]}", which can use a locally different
golangci-lint version than CI's pinned v2.8.0; update the repo to either (A)
document the expected golangci-lint version (v2.8.0) in README and contributing
notes so developers know which version to install, or (B) modify the developer
toolchain installation (e.g., the Make target that installs tools) to install
golangci-lint v2.8.0 and change the hook to invoke that pinned binary (or
check/gate on golangci-lint --version and fail with a message if it does not
match "v2.8.0") so local runs match CI.

16-18: Consider adding gofumpt for consistency with make fmt.

The hook uses only gofmt, but make fmt applies both go fmt and gofumpt. This inconsistency means code passing the pre-commit hook might still differ from make fmt output.

♻️ Add gofumpt if installed
 printf 'pre-commit: formatting staged Go files with gofmt...\n'
 gofmt -w "${staged_go_files[@]}"
+if command -v gofumpt &>/dev/null; then
+	gofumpt -w "${staged_go_files[@]}"
+fi
 git add -- "${staged_go_files[@]}"

Based on learnings: "Run formatting with make fmt (go fmt + gofumpt) before committing".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.githooks/pre-commit around lines 16 - 18, The pre-commit hook currently
runs only gofmt on "${staged_go_files[@]}", causing divergence from make fmt
which also runs gofumpt; update the hook to ensure consistency by either (A)
detecting and running gofumpt -w on the same staged_go_files (e.g., check
command -v gofumpt and run it, then git add those files) or (B) simply invoke
make fmt before adding files so both go fmt and gofumpt are applied; reference
the staged_go_files variable and the existing gofmt invocation when adding the
new step.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.githooks/pre-commit:
- Line 10: The pre-commit hook uses Bash-4+ only features (mapfile in the
staged_go_files assignment and declare -A for associative arrays) which breaks
on macOS's default Bash 3.2; either document the Bash 4+ requirement in project
docs or make the hook POSIX-compatible: replace the mapfile usage that populates
staged_go_files and any declare -A usage with portable alternatives (e.g., while
read -r -d '' loop to collect git diff --name-only -z output into an array and
use plain arrays or newline-separated strings instead of associative arrays),
and detect/bash-check at hook startup (check $BASH_VERSION or use env bash) to
provide a clear error message if an unsupported shell is running. Ensure you
update the references to staged_go_files and any associative array usages in the
hook to the new portable variables.

In `@internal/oauth/dpop.go`:
- Around line 67-74: The function that builds a JWK currently swallows errors
from PublicKey.Bytes() and returns an incomplete &JWK{Kty:"EC", Crv:"P-256"} on
failure; change its signature to return (*JWK, error) (or (JWK, error)) and
update all callers (including the JWKS endpoint handler) to propagate or handle
the error, and when PublicKey.Bytes() returns an error or when len(pubBytes) !=
1+2*p256CoordinateSize() || pubBytes[0] != 0x04 return a clear error (and do not
emit a malformed JWK); alternatively if changing the signature is infeasible,
log a warning including the specific error/invalid-length details before
returning nil or a zero-value JWK so the issue is observable.

In `@internal/tap/consumer.go`:
- Line 246: In the branch that currently does "return true, err" in
internal/tap/consumer.go, wrap the propagated dispatch/write-path error with
fmt.Errorf using the %w verb and a TAP-specific message (e.g., include "TAP
ack/write" or similar) so it matches the other error paths; also ensure fmt is
imported if not already.

---

Nitpick comments:
In @.githooks/pre-commit:
- Around line 37-38: The pre-commit hook prints "pre-commit: running
golangci-lint on staged packages..." and then runs golangci-lint run
"${packages[@]}", which can use a locally different golangci-lint version than
CI's pinned v2.8.0; update the repo to either (A) document the expected
golangci-lint version (v2.8.0) in README and contributing notes so developers
know which version to install, or (B) modify the developer toolchain
installation (e.g., the Make target that installs tools) to install
golangci-lint v2.8.0 and change the hook to invoke that pinned binary (or
check/gate on golangci-lint --version and fail with a message if it does not
match "v2.8.0") so local runs match CI.
- Around line 16-18: The pre-commit hook currently runs only gofmt on
"${staged_go_files[@]}", causing divergence from make fmt which also runs
gofumpt; update the hook to ensure consistency by either (A) detecting and
running gofumpt -w on the same staged_go_files (e.g., check command -v gofumpt
and run it, then git add those files) or (B) simply invoke make fmt before
adding files so both go fmt and gofumpt are applied; reference the
staged_go_files variable and the existing gofmt invocation when adding the new
step.

In `@internal/database/repositories/records.go`:
- Around line 617-628: Rename the function normalizedIndextAtExpr to
normalizedIndexedAtExpr to fix the typo and update every call site that invokes
normalizedIndextAtExpr to the new name; specifically change the method
declaration on RecordsRepository and replace all references (all places where
normalizedIndextAtExpr() is called) so builds and callers use
normalizedIndexedAtExpr(), preserving the same return logic (PostgreSQL returns
"indexed_at", others use the strftime expression).

In `@internal/tap/consumer.go`:
- Line 117: The call site uses a positional (bool, error) return from runOnce
which is easy to mis-order; change runOnce's signature to return a small named
result type instead of plain bool: define a RunOnceResult struct (e.g., type
RunOnceResult struct { Connected bool }) and update runOnce to return
(RunOnceResult, error), then update all call sites (e.g., the c.runOnce(ctx)
usages that assign to connected, err) to use res, err := c.runOnce(ctx) and
check res.Connected; apply the same change to other call sites around the block
mentioned (the calls at the other location referenced) so the meaning is
explicit and not position-dependent.
🪄 Autofix (Beta)

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: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2539c1e9-5095-42f3-92fe-14b520fd8157

📥 Commits

Reviewing files that changed from the base of the PR and between 11c7221 and 35e144e.

📒 Files selected for processing (9)
  • .githooks/pre-commit
  • .github/workflows/ci.yml
  • Makefile
  • README.md
  • internal/database/repositories/records.go
  • internal/database/repositories/records_filter_test.go
  • internal/oauth/dpop.go
  • internal/oauth/dpop_test.go
  • internal/tap/consumer.go
💤 Files with no reviewable changes (1)
  • .github/workflows/ci.yml
✅ Files skipped from review due to trivial changes (2)
  • README.md
  • internal/database/repositories/records_filter_test.go

@railway-app railway-app bot temporarily deployed to hyperindex-v2-setup / pr-base April 14, 2026 09:25 Inactive
@railway-app railway-app bot temporarily deployed to hyperindex-v2-setup / staging April 14, 2026 09:25 Inactive
@railway-app railway-app bot temporarily deployed to hyperindex-v2-setup / pr-base April 14, 2026 09:39 Inactive
@railway-app railway-app bot temporarily deployed to hyperindex-v2-setup / staging April 14, 2026 09:39 Inactive
@Kzoeps Kzoeps merged commit d6d8578 into fix/tap-ack-issue Apr 14, 2026
4 checks passed
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