Skip to content

feat: nest account-scoped operations & state changes on Account.transactions#620

Open
aditya1702 wants to merge 21 commits into
mainfrom
account-history-nested-details
Open

feat: nest account-scoped operations & state changes on Account.transactions#620
aditya1702 wants to merge 21 commits into
mainfrom
account-history-nested-details

Conversation

@aditya1702

@aditya1702 aditya1702 commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Closes #619

TL;DR

  • What: accountByAddress.transactions now embeds each transaction's account-scoped operations and stateChanges on the edge — a full account history in one GraphQL call instead of N follow-up requests.
  • Account-scoped: each edge shows only the operations/state changes involving the queried account, not everything in the transaction.
  • No N+1: the new fields resolve lazily and batch across the whole page via dataloaders; the queries pin the TimescaleDB partition column for chunk exclusion and filter strictly by account.
  • SDK: new GetAccountTransactionsWithOpsAndStateChanges method + response types, with strict (non-null-enforcing) JSON decoding.
  • Page-size cap: account.transactions/operations/stateChanges now cap first/last at 100 (returning BAD_USER_INPUT above that), mirroring account.balances — the nested per-transaction fan-out makes unbounded pages expensive.
  • Backward-compatible: node, cursor, and pageInfo are unchanged; callers that don't request the new fields are unaffected.
  • Correctness fix: BatchGetByAccountAddress now always selects ledger_created_at (the partition key); without it, a query omitting ledgerCreatedAt on the node would have silently returned empty nested lists. Guarded by a regression test.

Summary (layer by layer)

  • Data layerBatchGetAccountOperationsByToIDs / BatchGetAccountStateChangesByToIDs: per-to_id LATERAL queries that pin ledger_created_at (the TimescaleDB partition column) for chunk exclusion, scope by account_id, and return the complete per-transaction set (no LIMIT), ordered DESC. Operations have no account column, so they're scoped through operations_accounts via the TOID band; state changes scope directly via their account_id column.
  • DataloadersaccountOperationsByToIDLoader / accountStateChangesByToIDLoader wrap those methods, batching all of a page's transactions into one query keyed by transaction to_id.
  • Schema + resolversAccount.transactions now returns AccountTransactionConnection; its AccountTransactionEdge carries operations / stateChanges (both @goField(forceResolver: true) so they resolve lazily through the loaders). The Go backing struct threads the account address from the parent Account resolver down to the edge resolvers — it scopes the lookups but is not part of the public schema. Page size on transactions/operations/stateChanges is capped at 100 via parseAccountPaginationParams.
  • SDKGetAccountTransactionsWithOpsAndStateChanges issues the nested query; new connection/edge types whose UnmarshalJSON enforces the schema's non-null contract (edges, node, pageInfo) and dispatches polymorphic state-change nodes by __typename.
  • Partition-pin fixBatchGetByAccountAddress always selects ledger_created_at so the nested edge resolvers receive a real ledger time to pin (see TL;DR).

Test Plan

  • make check clean — fmt, vet, lint, shadow, exhaustive, deadcode, goimports, govulncheck (+ tidy applied)
  • go test ./internal/data/... ./internal/serve/graphql/... ./pkg/wbclient/... → 499 passed
  • Cross-account exclusion verified at the data layer and the resolver layer, for both operations and state changes
  • Partition-pin regression test: nested operations resolve correctly when ledgerCreatedAt is omitted from the node selection (load-bearing — fails if the fix is reverted)
  • Page-size cap regression test: parseAccountPaginationParams rejects first/last > 100 with BAD_USER_INPUT
  • Integration tests deferred per the design spec (follow-up issue)

Cross-repo ordering (per spec): this must merge and publish a tagged SDK before the freighter-backend dependency bump.

🤖 Generated with Claude Code

Copilot AI review requested due to automatic review settings June 2, 2026 01:25
@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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR updates the GraphQL API so accountByAddress { transactions { edges { node operations stateChanges } } } returns account-scoped operations and state changes per transaction edge in a single request, backed by partition-pinned, account-scoped DB queries and new dataloaders, plus an SDK method to consume the new shape.

Changes:

  • GraphQL: Account.transactions now returns DetailedTransactionConnection, with edge-level lazy resolvers for operations and stateChanges.
  • Data + dataloaders: add account-scoped batch loaders and data-model methods using UNNEST(...) + LATERAL queries pinned by ledger_created_at.
  • SDK: add GetAccountTransactionsWithDetails and client-side types/unmarshal logic; add tests for scoping and partition-pin regression.

Reviewed changes

Copilot reviewed 17 out of 23 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
pkg/wbclient/types/types.go Adds SDK connection/edge types + custom JSON unmarshaling for detailed transaction edges.
pkg/wbclient/queries.go Adds nested GraphQL query builder for transactions with embedded operations/state changes.
pkg/wbclient/client.go Adds GetAccountTransactionsWithDetails client method.
pkg/wbclient/client_test.go Adds SDK tests for detailed transaction query + deserialization behaviors.
internal/serve/graphql/schema/pagination.graphqls Introduces DetailedTransactionConnection/DetailedTransactionEdge schema types.
internal/serve/graphql/schema/account.graphqls Switches Account.transactions to return DetailedTransactionConnection.
internal/serve/graphql/resolvers/account.resolvers.go Returns detailed edges and stamps account context onto edges for nested resolvers.
internal/serve/graphql/resolvers/pagination.resolvers.go Implements edge resolvers that load account-scoped operations/state changes via dataloaders.
internal/serve/graphql/resolvers/pagination_resolvers_test.go Adds resolver-level tests for account scoping and partition pinning.
internal/serve/graphql/dataloaders/loaders.go Registers new account-scoped loaders on the dataloader bundle.
internal/serve/graphql/dataloaders/operation_loaders.go Adds account-scoped operations-by-transaction loader.
internal/serve/graphql/dataloaders/statechange_loaders.go Adds account-scoped statechanges-by-transaction loader.
internal/data/transactions.go Ensures ledger_created_at is always selected for partition pinning downstream.
internal/data/operations.go Adds BatchGetAccountOperationsByToIDs using partition-pinned UNNEST + LATERAL.
internal/data/operations_test.go Adds coverage for account-scoped operations-by-transaction batch query.
internal/data/statechanges.go Adds BatchGetAccountStateChangesByToIDs using partition-pinned UNNEST + LATERAL.
internal/data/statechanges_test.go Adds coverage for account-scoped statechanges-by-transaction batch query.
internal/indexer/types/types.go Adds backing Go struct for DetailedTransactionEdge (includes non-schema account context).
internal/serve/graphql/generated/models_gen.go Adds generated model for DetailedTransactionConnection.
internal/serve/graphql/generated/generated.go Regenerates gqlgen output for new schema types/resolvers.
internal/serve/graphql/resolvers/statechange.resolvers.go Minor generated formatting changes (type declarations).
internal/serve/graphql/resolvers/queries.resolvers.go Import reordering from codegen/tidy.
gqlgen.yml Maps DetailedTransactionEdge schema type to its Go model.
Files not reviewed (4)
  • internal/serve/graphql/resolvers/account.resolvers.go: Language not supported
  • internal/serve/graphql/resolvers/pagination.resolvers.go: Language not supported
  • internal/serve/graphql/resolvers/queries.resolvers.go: Language not supported
  • internal/serve/graphql/resolvers/statechange.resolvers.go: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread pkg/wbclient/types/types.go
Comment thread internal/serve/graphql/dataloaders/operation_loaders.go Outdated
Comment thread internal/serve/graphql/dataloaders/statechange_loaders.go Outdated
Comment thread internal/data/operations.go
Comment thread internal/data/statechanges.go
- dataloaders: group account-scoped operation/state-change loaders by
  account (newAccountScopedLoader) so a single aliased multi-account request
  cannot cross-contaminate edges that share a transaction
- wbclient: enforce non-null operations/stateChanges when unmarshaling
  DetailedTransactionEdge, matching the schema and the node/edges/pageInfo checks
- data: guard BatchGetAccount{Operations,StateChanges}ByToIDs against
  mismatched parallel-array lengths
gqlgen emits import blocks that goimports -local regroups (separating the
wallet-backend prefix into its own block). CI's goimports step checks these
files, so the generated.go and *.resolvers.go output must be run through
goimports after generation.
gqlgen emits import blocks that goimports -local regroups, so regenerating
would silently revert the goimports-compliant form that CI's goimports gate
requires. Running goimports -w as part of gql-generate makes the target
self-normalizing, so regeneration order no longer determines compliance.
aditya1702 and others added 7 commits June 4, 2026 21:14
…rve mode

Latency testing in dev found the API OOM-killed at its memory limit after
~2.7h of sustained nested account-history load, with GC pressure degrading
latency beforehand — and no way to see it coming: the serve process exposed
no go_* runtime metrics and had no pprof endpoint (only ingest did).

- metrics.NewMetrics now registers the Go runtime and process collectors,
  covering both serve and ingest registries (TDD'd).
- serve gains the same optional admin pprof server as ingest (admin-port /
  ADMIN_PORT, default off). Dev already sets ADMIN_PORT=9095, so heap
  profiling lights up on deploy with no config change.
… statement cache thrash

prepareColumnsWithID round-tripped the column list through map-backed sets,
so the SELECT column order rotated randomly on every call. The order is part
of the SQL text, which keys two caches:

- pgx's global RowToStructByName field-mapping cache (sync.Map, no eviction):
  every fresh ordering created a permanent entry. Under sustained load the
  serve process leaked ~1.5MiB/min until OOM (heap-diff confirmed: 74% of
  growth under pgx.lookupNamedStructFields). Restarts reset it; latency
  crept up beforehand from GC pressure (p99 70ms -> 1.8s over ~17h).
- the per-connection prepared statement cache: near-unique SQL per request
  meant Parse/Describe round trips on almost every query, and useless
  pg_stat_statements (thousands of calls=1 variants).

Sorting makes the SQL text deterministic per column set: one pgx cache entry
and one prepared statement per query shape, reused forever.
The UNNEST + CROSS JOIN LATERAL shape is a joinable subquery that the
planner may flatten into a hash join scanning the account's entire
state_changes history across all chunks. On compressed chunks (no
b-tree indexes) that scan replaces ~100 index probes with a
multi-million-row read per request.

A flat WHERE with to_id = ANY(...) plus a ledger_created_at range has
no join to flatten: compressed chunks are read via the segmentby index
plus batch min/max metadata (one batch per page window in the common
case), uncompressed chunks via ordinary index scans, and out-of-range
chunks are excluded entirely.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A subquery containing LIMIT cannot be flattened into a join, so the
planner must execute the operations_accounts band lookup as a per-pair
index probe instead of scanning the account's entire history and
hash-joining the pairs. LIMIT 4096 is an invariant, not tuning: a TOID
reserves 12 bits for the operation index, so the band predicate already
bounds the subquery below 4096 rows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…d-bearing

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

Add new methods to the Go client according to Freighter's history redesign needs

2 participants