Skip to content

Add zkApp command range query - #162

Draft
Hebilicious wants to merge 2 commits into
o1-labs:mainfrom
Hebilicious:branch/ext-21-zkapp-command-range-query
Draft

Add zkApp command range query#162
Hebilicious wants to merge 2 commits into
o1-labs:mainfrom
Hebilicious:branch/ext-21-zkapp-command-range-query

Conversation

@Hebilicious

@Hebilicious Hebilicious commented Jun 8, 2026

Copy link
Copy Markdown

Closes #161.

Adds Query.zkappCommands for fetching successful zkApp commands over an explicit block range, including block info, fee payer data, and ordered account updates with actions, events, app state, and preconditions.

Guardrails:

  • requires explicit from and to; there is no implicit latest-range scan
  • enforces ZKAPP_COMMAND_RANGE_SIZE for this heavier query, defaulting to 1000
  • runs a preflight expanded account-update count and rejects ranges above ZKAPP_COMMAND_ACCOUNT_UPDATE_LIMIT, defaulting to 5000
  • uses array cardinality for the unfiltered count path, and only joins account-update tables for accountPublicKey / tokenId filtered counts

Also adds service tests for the range and account-update guardrails.

@Hebilicious
Hebilicious force-pushed the branch/ext-21-zkapp-command-range-query branch from 23d7ca3 to 5a4b75a Compare June 8, 2026 17:32
@dkijania

dkijania commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

@Hebilicious thanks for contribution! I just briefly looked at it.. will review this PR fully shortly. Looks like our CI has some issueswith running pr on forks

Comment thread src/db/sql/zkapp-commands/queries.ts Outdated
import { BlockStatusFilter } from '../../../blockchain/types.js';
import type { ZkappCommandDatabaseRow } from './types.js';

export function getZkappCommandsQuery(

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.

This 250-line query isn't executed by any test, from what i see. The unit tests only cover the pure rowsToZkappCommands and the guard functions, and the integration fixture has only failed zkApp commands (tests/integration/integration.test.ts:10), so with the status <> 'failed' filter it returns empty here.

Can you add test ?

ON b.id = bzkc.block_id
AND bzkc.status <> 'failed'
JOIN zkapp_commands zkc ON bzkc.zkapp_command_id = zkc.id
JOIN zkapp_fee_payer_body fpb ON zkc.zkapp_fee_payer_body_id = fpb.id

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.

zkapp_fee_payer_body is the one table this query JOINs that isn't in USED_TABLES (src/db/sql/events-actions/queries.ts:427). Please add it to USED_TABLES so a missing table is caught at boot rather than first query.

WHERE
b.height >= ${from}
AND b.height < ${to}
${

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.

Hm.. This filters purely on b.height and b.chain_status, unlike events/actions which run the recursive pending_chain walk to resolve the true chain. For CANONICAL that's fine, but for the default ALL and for PENDING, competing/orphaned blocks at the same height all come through. Either use canonical resolution logic or document this on the schema field.

FROM block_range b
JOIN blocks_zkapp_commands bzkc
ON b.id = bzkc.block_id
AND bzkc.status <> 'failed'

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.

Here you are filtering out failed commands, so the status is always 'applied' and failureReason always null. The two output fields are effectively constant and looks like there is no point returning them . Given the PR scope is "successful" commands, simplest is to drop both fields.

Comment thread src/resolvers.ts
tracingState: new TracingState(graphQLSpan),
});
},
zkappCommands: async (_, { input }, context) => {

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.

events/block-details are gated behind ENABLE_BLOCK_TRANSACTION_DETAILS; this heavier query is exposed ungated. is it intentional?

Comment thread schema.graphql Outdated
"""
Mina block height to filter zkApp commands to, exclusive
"""
to: Int!

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.

NIT: from before to reads more naturally.

@dkijania

Copy link
Copy Markdown
Contributor

@Hebilicious overall looking good! Just couple comments

@Hebilicious

Copy link
Copy Markdown
Author

@dkijania thanks for the review, I will make the changes you suggest and we will test this on our archive internally.

@Hebilicious
Hebilicious marked this pull request as draft June 19, 2026 16:25
@Hebilicious
Hebilicious force-pushed the branch/ext-21-zkapp-command-range-query branch from 5a4b75a to 3d391d9 Compare June 25, 2026 06:54
@SanabriaRusso

Copy link
Copy Markdown
Collaborator

Really nice addition — requiring explicit from/to, the ZKAPP_COMMAND_RANGE_SIZE cap, and the preflight ZKAPP_COMMAND_ACCOUNT_UPDATE_LIMIT count make this a well-bounded, DoS-resistant range query, and the schema change is cleanly additive (new Query.zkappCommands + new Zkapp* types only). So it stays backwards-compatible for existing clients — including the mina-explorer, whose nested blocks → transactions → zkappCommands field is a different schema coordinate and is unaffected.

One coupling worth reconsidering, building on @dkijania's gating question: the query is gated behind ENABLE_BLOCK_TRANSACTION_DETAILS. That's also the flag an operator turns on so the blocks query populates transactions { userCommands / zkappCommands / feeTransfer } — which the mina-explorer's block-detail and "flat" zkApp-activity views depend on (its query is literally labeled "archive with ENABLE_BLOCK_TRANSACTION_DETAILS"). So any archive that enables block-transaction details for the Explorer would also switch on this heavier top-level range query, with no way to keep one without the other.

Would a dedicated, default-off flag be cleaner, so the two are independent?

const ENABLE_ZKAPP_COMMANDS_QUERY =
  process.env.ENABLE_ZKAPP_COMMANDS_QUERY === 'true';
// ...
if (!ENABLE_ZKAPP_COMMANDS_QUERY) {
  enabledQueries = enabledQueries.filter((q) => q !== 'zkappCommands');
}

That keeps operators who enable block-transaction details for the Explorer from unintentionally exposing the range query, while still letting you opt in explicitly. (Also +1 on @dkijania's coverage note — the added integration test only hits the canonical path with an empty-event account update, so the ALL/PENDING recursive resolution and the nested action/event/appState/precondition extraction are still uncovered.)

@SanabriaRusso

Copy link
Copy Markdown
Collaborator

Verdict: DRAFT — not assessed for merge

Second-pass review, deliberately additive: I'm not re-litigating the six existing threads, only recording what the force-push actually landed and then covering the angle none of them touch — downstream compatibility with mina-explorer / mina-explorer-api.

Prior review points — status at head 3d391d97

Worth flagging for whoever reads the threads next: the branch was force-pushed after the review (author date 2026-05-25, commit date 2026-06-25; @dkijania's inline comments are dated 2026-06-11). The threads are still anchored to line numbers that have moved, so several read as open when the content already changed. Against the current tree:

Thread Status at head
zkapp_fee_payer_body missing from USED_TABLES Done — plus 7 more (public_keys, tokens, zkapp_updates, zkapp_account_precondition, zkapp_network_precondition, zkapp_global_slot_bounds, zkapp_states_nullable) at src/db/sql/events-actions/queries.ts:427-450
ALL/PENDING doesn't do the recursive pending_chain walk Done — the non-canonical branch of blockRangeCte (src/db/sql/zkapp-commands/queries.ts:35-144) is the same RECURSIVE pending_chain + full_chain shape as blockCTE in events-actions/queries.ts:15-72; orphans are excluded
status / failureReason are constants, drop them Done — neither is selected in SQL nor present on ZkappCommandOutput (schema.graphql:193-201)
Should the heavier query be gated? Done — gated on ENABLE_BLOCK_TRANSACTION_DETAILS at src/resolvers.ts:74-77. @SanabriaRusso's follow-up (give it its own flag so enabling block-transaction details for the Explorer doesn't also expose the range query) is still open
from before to nit Doneschema.graphql:79,83
Nothing executes getZkappCommandsQuery Partiallytests/integration/setup.ts:91-144 now inserts one applied command and integration.test.ts:405-429 asserts on it; still canonical-only / one account update / empty events, so the ALL+PENDING path and the action/event/appState/precondition extraction remain uncovered

Both red checks are fork-permission infra, not your code (Check Schema: Resource not accessible by integration creating the check run; build-and-deploy: couldn't find remote ref branch/ext-21-…). Consequence worth knowing: graphql-inspector never actually ran a breaking-change check on this PR, so nobody has an automated verdict on the schema delta. Manual one follows.

Schema delta vs main — purely additive, no consumer break

I diffed schema.graphql@3d391d97 against main. +77 / -0, and every addition is a new coordinate:

  • New input ZkappCommandFilterOptionsInput (schema.graphql:74-93)
  • New types ZkappFieldArray, ZkappAccountPrecondition, ZkappGlobalSlotBounds, ZkappNetworkPrecondition, ZkappAccountUpdateOutput, ZkappCommandOutput (schema.graphql:158-201)
  • One new root field zkappCommands(input: ZkappCommandFilterOptionsInput!): [ZkappCommandOutput!]! (schema.graphql:295)

No type/field/argument renamed or removed, no nullability tightened, no existing input field made required, no enum value dropped. Confirmed specifically:

  • ZkAppCommand is untouched. The new type is ZkappCommandOutput (schema.graphql:193), a different name from the existing ZkAppCommand (schema.graphql:261). ZkAppCommand.feePayer is still String! (schema.graphql:263) and status/failureReason are still top-level. This is the thing I most expected a new-feature PR to break, and it doesn't: the FLAT shape both consumers depend on survives — mina-explorer/src/services/api/blocks.ts:142-149 (zkappCommands { hash feePayer fee memo status failureReason }, then blocks.ts:359-369 reads cmd.feePayer as an address string), and mina-explorer-api/app/upstream/archive.py:11-12 whose docstring pins the same contract.
  • BlockTransactions.zkappCommands still resolves to [ZkAppCommand!]! — a different schema coordinate from the new root field, so the explorer's nested query is unaffected.
  • BlockQueryInput / EventFilterOptionsInput / ActionFilterOptionsInput untouched, so inBestChain capability detection (mina-explorer/src/services/api/bestChainFilter.ts, mina-explorer-api/app/upstream/archive.py) and o1js's fetchActions are unaffected.
  • Nice side effect of the gate: with ENABLE_BLOCK_TRANSACTION_DETAILS unset the field is stripped from the printed SDL (src/resolvers.ts:88-101), so a client asking for it gets verbatim Cannot query field "zkappCommands" on type "Query" — which is exactly SCHEMA_ERROR_MARKERS in mina-explorer-api/app/upstream/graphql.py:32. Graceful, no masking. (The six Zkapp* types stay in the SDL as orphans when disabled — harmless, just cosmetic in introspection.)

The one substantive finding: the account-update guard doesn't bound work when a filter is set

ZKAPP_COMMAND_RANGE_SIZE=1000 and the preflight ZKAPP_COMMAND_ACCOUNT_UPDATE_LIMIT=5000 are a good design, and the unfiltered count path is genuinely cheap — SUM(cardinality(zkc.zkapp_account_updates_ids)) reads the array length off zkapp_commands without expanding anything (src/db/sql/zkapp-commands/queries.ts:323-333). That's the right idea.

But the filtered branch (queries.ts:335-359) loses that property twice over:

  1. It does the full unnest … WITH ORDINALITY + zkapp_account_updatezkapp_account_update_bodyaccount_identifierspublic_keystokens expansion across the whole block range before applying account_update_pk.value = $1. So the "cheap preflight" costs the same order of magnitude as the query it is guarding — the request pays the expansion twice (count, then main).
  2. It returns the post-filter count. For a public key with few or no account updates in the range, the count comes back ~0, the guard never fires, and ZKAPP_COMMAND_ACCOUNT_UPDATE_LIMIT bounds results rather than work.

Concretely: zkappCommands(input: { from: H-1000, to: H, accountPublicKey: "B62q…<key that appears nowhere>" }) returns [] instantly to the caller while expanding every account update in 1000 mainnet blocks, twice, server-side. Cheap to issue, expensive to serve, and it passes every existing guardrail. There's no rate limiting merged yet and no statement_timeout anywhere in the repo (grep -rn statement_timeout src docs → nothing), so the client-side 20s abort in both consumers doesn't stop the query — it keeps running on the pool.

This isn't helped by indexes: zkapp_account_update_body.account_identifier_id has an FK but no index (tests/integration/fixtures/archive_db.sql:6194 is the constraint; there's no matching CREATE INDEX), so the planner can't drive the filter from the public_keys.value unique index backwards. The range side is fine — idx_blocks_height, idx_chain_status, idx_blocks_zkapp_commands_block_id all exist, and every other join is a PK lookup.

Suggested fix, small and self-contained: always run the cheap cardinality count to bound the raw expansion, and only then run the filtered count if a filter is present.

// src/services/zkapp-commands-service/zkapp-commands-service.ts
const [{ count: rawCount }] = await getZkappCommandAccountUpdateCountQuery(
  this.client, blockStatus, range.to, range.from   // no filter args -> cardinality path
);
assertZkappCommandAccountUpdateLimit(Number(rawCount));

if (accountPublicKey || tokenId) {
  const [{ count: filteredCount }] = await getZkappCommandAccountUpdateCountQuery(
    this.client, blockStatus, range.to, range.from,
    accountPublicKey?.toString(), tokenId?.toString()
  );
  assertZkappCommandAccountUpdateLimit(Number(filteredCount));
}

That makes the guard O(commands in range) in all cases and bounds work, not just output. A regression test would be: filtered request over a range whose raw expansion exceeds the limit but whose filtered count is 0 → must throw the block-range error, not return [].

Coordination with #183 (query-cost limits)

I checked the new type against the in-flight defaults (GRAPHQL_MAX_DEPTH=10, MAX_ALIASES=15, MAX_TOKENS=1000, MAX_COST=5000) — no conflict:

  • Deepest selection is zkappCommands → accountUpdates → accountPrecondition → state → fields = depth 5, same as the existing deepest query. P0: Add GraphQL query-cost controls via graphql-armor (#164) #183's "~5 levels" claim stays true after this PR.
  • Full-fat single-operation cost under graphql-armor's default heuristic works out to ~135, so even 15 aliases lands near ~2k, under 5000.

Only real interaction is GRAPHQL_MAX_TOKENS=1000: a client selecting the entire ZkappCommandOutput tree is ~60-70 tokens, so plenty of headroom, but whichever of these two lands second should re-run the other's worst-case query once. Worth a note in #183's description either way.

Non-blocking nits

  • src/resolvers.ts:15-16 re-reads process.env.ENABLE_BLOCK_TRANSACTION_DETAILS even though src/server/server.ts already exports the parsed constant. Importing it would keep one source of truth (and would matter if the parsing ever gains a fallback).
  • Number(process.env.ZKAPP_COMMAND_RANGE_SIZE) || 1000 (server.ts:17-18) silently maps 01000 and accepts negatives. P0: Add GraphQL query-cost controls via graphql-armor (#164) #183 explicitly handles malformed env by falling back to the default; matching that here (const n = Number(...); const v = Number.isInteger(n) && n > 0 ? n : 1000;) would make the three range knobs behave consistently.
  • Adding 8 tables to USED_TABLES widens the boot-time hard fail in checkSQLSchema (src/db/archive-node-adapter/archive-node-adapter.ts:89-95) for a feature that is off by default — an archive on an older dump missing e.g. zkapp_states_nullable would now refuse to start where it previously ran fine. All 8 are in the standard Berkeley+ schema so this is low risk, but gating the extra names on ENABLE_BLOCK_TRANSACTION_DETAILS would keep the failure proportional to what's actually enabled.
  • events/actions return [EventOutput]! while the new field returns [ZkappCommandOutput!]!. The new one is strictly better — just noting the inconsistency in case you want a follow-up to tighten the old two.

Automated second-pass review — focus: downstream compatibility with mina-explorer / mina-explorer-api.

@SanabriaRusso

Copy link
Copy Markdown
Collaborator

Reviewed for downstream compatibility only — this is a draft, so nothing here is a merge gate.

Schema is purely additive, re-confirmed at edddf4ff. schema.graphql is +77/−0 with zero deletions: one new input (ZkappCommandFilterOptionsInput), six new types (ZkappFieldArray, ZkappAccountPrecondition, ZkappGlobalSlotBounds, ZkappNetworkPrecondition, ZkappAccountUpdateOutput, ZkappCommandOutput), and one new root field Query.zkappCommands. I pulled the full file at the PR head — the existing type ZkAppCommand (capital A, line 261) is untouched, its flat feePayer: String! (line 263) is intact, Block.zkappCommands: [ZkAppCommand!]! (line 279) is unchanged, and the other feePayer: String! at line 196 is untouched. The new ZkappCommandOutput is a distinct coordinate. No break for either downstream consumer.

Worth calling out: gating zkappCommands behind ENABLE_BLOCK_TRANSACTION_DETAILS (default off) in src/resolvers.ts is the right shape, and it matches the flag-gating rule #198 is introducing. I traced the resolvers.ts rewrite — with both ENABLED_QUERIES and the flag unset, enabledQueries resolves to all resolver keys minus zkappCommands, so the served Query surface for events / actions / networkState / blocks is unchanged. The refactor is behaviour-preserving. One thing to watch: #193 (open) adds a startup validator whose KNOWN_QUERIES is exactly those four names, so if this branch ever adds zkappCommands to ENABLED_QUERIES handling, the two need reconciling.

The one actionable finding still stands, unchanged at this head.

In src/db/sql/zkapp-commands/queries.ts, getZkappCommandAccountUpdateCountQuery is the preflight that ZkappCommandsService.executeZkappCommandsQuery runs before the real query, feeding assertZkappCommandAccountUpdateLimit. Its two branches are not equivalent guards:

  • UnfilteredSELECT COALESCE(SUM(cardinality(zkc.zkapp_account_updates_ids)), 0) over the block range. Cheap (array lengths, no expansion) and it measures the work the server is about to do. Correct.
  • Filtered (accountPublicKey and/or tokenId) — builds the matching_account_identifierstarget_account_updates CTEs, joins tau.account_update_id = ANY(zkc.zkapp_account_updates_ids) across the whole range, then COUNT(*) post-filter.

So in the filtered case the guard measures the result size rather than the work. A request for a public key with no matching account updates over a 1000-block range counts 0, sails past ZKAPP_COMMAND_ACCOUNT_UPDATE_LIMIT, and then runs the identical full expansion a second time in the main query — two complete range scans server-side for a request that returns nothing. That is the cheapest possible request for an attacker to issue and among the most expensive to serve, and there is no index on zkapp_account_update_body.account_identifier_id (FK constraint only) to soften either pass.

Concrete fix — make the guard measure range work, not result size. The expansion cost over the range is the same regardless of the predicate, so the filtered branch shouldn't exist at all. Collapse the function to the unfiltered form and drop the two now-unused parameters:

export function getZkappCommandAccountUpdateCountQuery(
  dbClient: postgres.Sql,
  status: BlockStatusFilter,
  to: number,
  from: number
) {
  return dbClient<{ count: string }[]>`
    WITH ${blockRangeCte(dbClient, status, to, from)}
    SELECT COALESCE(SUM(cardinality(zkc.zkapp_account_updates_ids)), 0)::text AS count
    FROM block_range b
    JOIN blocks_zkapp_commands bzkc
      ON b.id = bzkc.block_id
      AND bzkc.status <> 'failed'
    JOIN zkapp_commands zkc ON bzkc.zkapp_command_id = zkc.id;
  `;
}

and drop the two trailing arguments at the call site in ZkappCommandsService.executeZkappCommandsQuery. The guard then rejects dense ranges before either expansion runs, and a filtered request costs one pass instead of two.

Pair it with the supporting index, which the filtered path needs regardless:

CREATE INDEX CONCURRENTLY IF NOT EXISTS
  zkapp_account_update_body_account_identifier_id_idx
  ON zkapp_account_update_body (account_identifier_id);

CI note — worse than at the last review. No checks have run against the current head; the head SHA has zero check-runs. All six workflow runs from today sit in action_required, waiting on a maintainer to click "Approve and run workflows" for this fork PR. The last real runs are from 2026-07-03, where the CI job (graphql-inspector's "Check Schema") failed in 12 s on fork permissions. So graphql-inspector has still never actually evaluated this branch for breaking changes — the additive assessment above comes from reading the schema diff directly, not from a green check. Someone with write access should approve the workflows to get a real verdict on the record.

For context on why the additive property matters here: mina-explorer-api classifies any response containing Cannot query field, Unknown argument, or Unknown type as a permanent schema error, which drives tier fallback and poisons its capability cache (app/upstream/graphql.py:33-42). A schema change that removed or renamed a field both consumers query would therefore not just fail — it would stick. Keeping this purely additive is exactly right.

@dkijania
dkijania force-pushed the branch/ext-21-zkapp-command-range-query branch 2 times, most recently from e31e139 to 72b8820 Compare August 26, 2026 17:06
@dkijania
dkijania force-pushed the branch/ext-21-zkapp-command-range-query branch from 72b8820 to 3d9f97f Compare August 27, 2026 20:40

@SanabriaRusso SanabriaRusso left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Round-4 review — comment only (this is a draft)

Re-verified from scratch at head 3d9f97fa. Nothing from round 3 is carried forward on trust: the head moved three times since then (edddf4ffe31e139572b8820f3d9f97fa) and main moved by eleven merges, so every claim below was re-executed against the current head.


1. Rebase state: clean. No action needed.

git merge-base origin/main refs/remotes/pr/162  ->  3fd33794   (== origin/main HEAD)
git rev-list --count origin/main..pr/162        ->  2
git rev-list --count pr/162..origin/main        ->  0
git merge-tree origin/main pr/162               ->  7e4c9980, exit 0, no conflict markers

The branch already contains all of main, including graphql-yoga 5, Node 22, rate limiting, metrics, readiness, graceful shutdown, explicit error masking and docs/versioning.md. It builds and lints against that main:

npm run build      -> clean (tsc, engines node >=22.12.0, graphql-yoga 5.21.2)
npm run lint       -> clean
npm run test:unit  -> 175 assertions, 0 failures, incl. 12 new ZkappCommandsService tests

mergeStateStatus: BLOCKED is the draft status plus the missing checks, not a merge conflict.


2. Schema additivity: mechanically confirmed at 3d9f97fa. 0 breaking changes.

schema.graphql is +77 / -0 — zero deletions. Because CI has never evaluated this branch (see §3), I ran the breaking-change analysis by hand, implementing every category docs/versioning.md names as breaking (type/field/arg/enum-value removal, field type change, output T!T, input TT!, required arg added to an existing field, root type rename) over the two built schemas:

OLD: origin/main:schema.graphql          NEW: pr/162:schema.graphql
BREAKING (0):
ADDITIONS (8):
  + field added: Query.zkappCommands: [ZkappCommandOutput!]!
  + type added: ZkappCommandFilterOptionsInput (input)
  + type added: ZkappFieldArray, ZkappAccountPrecondition, ZkappGlobalSlotBounds,
                ZkappNetworkPrecondition, ZkappAccountUpdateOutput, ZkappCommandOutput

ZkAppCommand (capital A) and its flat feePayer: String! are untouched — schema.graphql:261-268 in this branch is byte-identical to main. BlockTransactions.zkappCommands: [ZkAppCommand!]! is untouched. Query.zkappCommands is a genuinely distinct coordinate from BlockTransactions.zkappCommands; they share a name but not a type, which is legal and does not confuse a Cannot query field probe.

2b. The flag gating and the resolvers.ts rewrite are behaviour-preserving. Verified by execution, not by reading.

I built both trees and printed the served schema under six env combinations:

case ENABLED_QUERIES ENABLE_BLOCK_TRANSACTION_DETAILS main Query fields PR Query fields
A unset unset actions, blocks, events, networkState identical
B unset true actions, blocks, events, networkState + zkappCommands
C events,actions unset actions, events identical
D events,actions true actions, events identical
E …,zkappCommands unset actions, blocks, events, networkState identical (flag wins — fails closed)
F "" unset (empty) identical

Case E is the one that matters for the policy: even when an operator explicitly lists zkappCommands in ENABLED_QUERIES, the flag being off still removes it. Good — it fails closed.

Diffing the two served SDLs in case A gives BREAKING (0), ADDITIONS (7) — the 7 new types remain present as unreachable types, but Query is field-identical to main. So a default deployment of this branch serves exactly main's query surface.

2c. Error-message contract (docs/versioning.md → "Error messages and validation behaviour"): unchanged, verified string-for-string.

Ran validate(schema, parse(q)) against both trees, both flag states:

probe main PR (flag off) PR (flag on)
{ zkappCommands(input:{from:1,to:2}){hash} } Cannot query field "zkappCommands" on type "Query". same (valid)
explorer flat fallback blocks{transactions{zkappCommands{hash feePayer fee memo}}} valid valid valid
explorer nested probe …zkappCommands{zkappCommand{memo}} Cannot query field "zkappCommand" on type "ZkAppCommand". same same
events(input:…, bogus: 1) Unknown argument "bogus" on field "Query.events". same same
query Q($x: NoSuchType!) Unknown type "NoSuchType". same same
blocks(query:{inBestChain:true}) valid valid valid

All three of mina-explorer-api's SCHEMA_ERROR_MARKERS (app/upstream/graphql.py:33-42) are produced verbatim and unchanged. The new BLOCK_RANGE_ERROR messages contain none of those three substrings, so they cannot poison that consumer's capability cache.

Downstream impact: none. mina-explorer reads blocks{transactions{zkappCommands}} at src/pages/ZkAppsPage.tsx:35,66, src/services/api/blocks.ts:70,84, src/components/dashboard/RecentBlocks.tsx:19 and src/components/blocks/BlockDetail.tsx:218 — all on BlockTransactions, none on Query, none touched here. The "Cannot query field" probes at transactions.ts:488,556,698,977 and ZkAppsPage.tsx:183 still fire on exactly the same inputs with exactly the same text.


3. ⚠️ Check Schema has never evaluated this branch — and it structurally cannot, for any fork PR. This is a repo bug, not a PR bug.

Round 3 reported zero check-runs. That is still true today:

gh pr checks 162                                 -> "no checks reported on the branch"
gh api .../commits/3d9f97fa/check-runs           -> {"total_count":0,"check_runs":[]}
gh api .../commits/3d9f97fa/status               -> state "pending", 0 statuses

All seven workflow runs at this SHA (2026-08-27T20:40:05Z) sit at conclusion: action_required, awaiting a maintainer's "Approve and run workflows". Same for e31e1395, 72b8820f and edddf4ff.

But approving them is not sufficient, and this is the new finding. The one time Check Schema did run on this branch — run 28152520334, at 3d391d97, 2026-07-03 — it failed, and not on the schema:

GraphQL Inspector started
Creating a check named "GraphQL Inspector"
##[error]Resource not accessible by integration
  - https://docs.github.com/rest/checks/runs#create-a-check-run

kamilkisiela/graphql-inspector@master reports by creating a check-run. .github/workflows/graphql-inspector.yaml declares no permissions: block, and a pull_request event from a fork gets a read-only GITHUB_TOKEN regardless of the repo default (which is write here). So the action cannot post, and the job fails.

Corroborated twice, and it is still live today:

branch fork? Check Schema
feat/structured-logging, docs/security-deployment (same-repo) no success
dependabot/github_actions/… @ f2700c6b, 2026-09-02 read-only token failure — same Resource not accessible by integration
branch/ext-21-zkapp-command-range-query (this PR) yes never ran
feat/verification-key-updates (#225) yes never ran

So docs/versioning.md → "Enforcement" ("Schema changes are checked in CI by graphql-inspector… A change it flags as breaking fails the build") does not hold for external contributions — precisely the population where it matters most. Both open schema-changing PRs are external, and neither has ever been checked.

Suggested repo fix (needs no token privileges at all — drop the check-run API call and let the job's exit status be the signal):

# .github/workflows/graphql-inspector.yaml
permissions:
  contents: read

jobs:
  test:
    name: Check Schema
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - uses: actions/setup-node@v4
        with: { node-version: '22' }
      - name: Diff schema against main
        run: |
          git fetch origin main --depth=1
          git show origin/main:schema.graphql > /tmp/base.graphql
          npx --yes @graphql-inspector/cli diff /tmp/base.graphql schema.graphql

@graphql-inspector/cli diff exits non-zero on a breaking change, which fails the job directly. Keep the expected-breaking-change escape hatch by guarding that step with if: !contains(github.event.pull_request.labels.*.name, 'expected-breaking-change').

Until then, my §2 result is the only breaking-change analysis this branch has ever had.


4. docs/versioning.md compliance

rule verdict
Schema additions only → minor ✅ 0 breaking, 8 additions (§2)
Flag-gating: new exposed query ships default-off behind an env flag ✅ default-off verified (§2b case A) — ⚠️ but see Finding 2, it reuses the wrong flag
Validation/parse error text verbatim ✅ all six probes byte-identical (§2c)
Error masking covers thrown runtime errors only ✅ untouched; throwBlockRangeErrorBLOCK_RANGE_ERROR / 400 with errors[] present
No env var removed/renamed, no default changed ✅ three new vars, all documented in README.md, docs/getting-started.md:189-190, .env.example.compose
No endpoint/runtime change
Deprecations n/a

Net: this is a minor-shaped change and complies, with the one caveat in Finding 2.


Finding 1 (open, carried from round 3 — partially addressed, and the fix introduced a new and larger problem)

For a development AI agent picking this up.

What changed since round 3

Commit 3d9f97fa ("fix(sql): prefilter zkapp command account-update counts") addressed half of it. Credit where due: the second full expansion is gone. Both the count query and the data query now prefilter, so the zero-match case no longer unnests every account-update array in the range twice. That part is genuinely fixed.

What is still open, and what got worse

1a. The guard still measures RESULT SIZE, not RANGE WORK.

src/db/sql/zkapp-commands/queries.ts:394-405 — the filtered branch counts matching account updates. src/services/zkapp-commands-service/zkapp-commands-service.ts:95-103 then compares that to ZKAPP_COMMAND_ACCOUNT_UPDATE_LIMIT. A filter can only ever reduce the output; it cannot reduce the work of walking the range. So the guard does not bound what it exists to bound.

Note that the unfiltered branch at queries.ts:382-391 already gets this exactly right — SUM(cardinality(zkc.zkapp_account_updates_ids)), no joins, no expansion. The correct fix is to use that form in all cases.

1b. NEW — target_account_updates is not bounded by the block range at all.

queries.ts:171-177:

target_account_updates AS (
  SELECT zkau.id AS account_update_id
  FROM matching_account_identifiers mai
  JOIN zkapp_account_update_body zkub ON zkub.account_identifier_id = mai.id
  JOIN zkapp_account_update zkau ON zkau.body_id = zkub.id
)

There is no from/to anywhere in it. This selects every account update in the entire archive that touches the matching account identifiers, and only then intersects with the range via tau.account_update_id = ANY(zkc.zkapp_account_updates_ids) (queries.ts:195-196). Two consequences:

  • For a hot zkApp account, the cost scales with the account's whole chain history, not with the requested range. A one-block query can trigger it. The ZKAPP_COMMAND_RANGE_SIZE guard is bypassed entirely for this path.
  • Worse, tokenId alone: matching_account_identifiers (queries.ts:158-169) filters tokens.value, and the default MINA token wSHV2S4qX9jFsLjQo8r1BsMLH2ZRKsZx6EJd1sbozGPieEC4Jf is carried by essentially every account in the ledger. So zkappCommands(input: {from: 1, to: 2, tokenId: "wSHV2S4qX9jFsLjQo8r1BsMLH2ZRKsZx6EJd1sbozGPieEC4Jf"}) — a two-block window — resolves that CTE to the whole account-identifier table and walks all of zkapp_account_update_body. That is a one-line unauthenticated request that pins a connection until PG_STATEMENT_TIMEOUT.

And = ANY(array) is not a hash-joinable condition, so the planner's options are a materialised scan of the whole set or a parameterised nested loop; there is no MATERIALIZED / NOT MATERIALIZED hint to steer it and no measurement in the PR. I want to be straight about the limits of my evidence here: the pathological plan is reasoned from the SQL, not measured — I have no mainnet archive to EXPLAIN against, and the 135-row test fixture would seq-scan either way. What is not reasoned is the absence of a range bound in that CTE; that is plainly visible at queries.ts:171-177. Acceptance criterion 4 below asks you to settle it with a real EXPLAIN (ANALYZE, BUFFERS).

1c. No index backs any of this. I listed every index in the canonical archive schema (tests/integration/fixtures/archive_db.sql:5727-5874 — 22 of them). None is on zkapp_account_update_body.account_identifier_id; the table carries only PRIMARY KEY (id) (line 5444-5448). There is an FK on that column (line 6190-6194) but Postgres does not index FK columns. Same story for zkapp_account_update.body_id. See §6 — this is a shared ask with #225, not a #162-only one.

Suggested fix

Patch A — one guard, measuring range work, for every case. Delete the filtered branch of getZkappCommandAccountUpdateCountQuery and its two filter parameters:

export function getZkappCommandAccountUpdateCountQuery(
  dbClient: postgres.Sql,
  status: BlockStatusFilter,
  to: number,
  from: number
  // No accountPublicKey / tokenId: a filter reduces the OUTPUT, never the RANGE
  // WORK this guard exists to bound. Guarding on the unfiltered cardinality is
  // both cheaper (no joins at all) and the quantity we actually care about.
) {
  return dbClient<{ count: string }[]>`
    WITH ${blockRangeCte(dbClient, status, to, from)}
    SELECT COALESCE(SUM(cardinality(zkc.zkapp_account_updates_ids)), 0)::text AS count
    FROM block_range b
    JOIN blocks_zkapp_commands bzkc
      ON b.id = bzkc.block_id
      AND bzkc.status <> 'failed'
    JOIN zkapp_commands zkc ON bzkc.zkapp_command_id = zkc.id;
  `;
}

and drop the two extra arguments at the call site, zkapp-commands-service.ts:95-102.

Patch B — keep the block range as the sole driver; apply the filter as a semi-join. Delete accountUpdateRowsJoin (queries.ts:181-205) entirely and reduce accountUpdateFilterCtes to just the identifier set:

function accountUpdateFilterCte(
  dbClient: postgres.Sql,
  accountPublicKey?: string,
  tokenId?: string
) {
  if (!accountPublicKey && !tokenId) return dbClient``;
  return dbClient`
    ,
    matching_account_identifiers AS MATERIALIZED (
      SELECT ai.id
      FROM account_identifiers ai
      JOIN public_keys pk ON ai.public_key_id = pk.id
      JOIN tokens t ON ai.token_id = t.id
      WHERE 1 = 1
        ${accountPublicKey ? dbClient`AND pk.value = ${accountPublicKey}` : dbClient``}
        ${tokenId ? dbClient`AND t.value = ${tokenId}` : dbClient``}
    )
  `;
}

In getZkappCommandsQuery, use the unfiltered join form unconditionally and push the filter onto the body row:

    JOIN LATERAL unnest(zkc.zkapp_account_updates_ids)
      WITH ORDINALITY AS account_update_ids(account_update_id, account_update_order) ON true
    JOIN zkapp_account_update zkau ON zkau.id = account_update_ids.account_update_id
    JOIN zkapp_account_update_body zkub ON zkau.body_id = zkub.id
      ${accountPublicKey || tokenId
          ? dbClient`AND zkub.account_identifier_id IN (SELECT id FROM matching_account_identifiers)`
          : dbClient``}

Then delete the now-redundant AND account_update_pk.value = … and AND t.value = … predicates on the public_keys / tokens joins (queries.ts:262-271) — the semi-join subsumes both. target_account_updates disappears; the range is the only driver; = ANY(array) is gone; and the ordinality-recovery LATERAL is no longer needed because the ordinality never left.

This is the shape #225 already uses (see §6).

Acceptance criteria

  1. getZkappCommandAccountUpdateCountQuery no longer accepts accountPublicKey or tokenId, and its generated SQL matches none of /zkapp_account_update_body/, /account_identifiers/, /public_keys/, /tokens/.
  2. getZkappCommandsQuery contains exactly one unnest(zkc.zkapp_account_updates_ids) WITH ORDINALITY, and the string = ANY(zkc.zkapp_account_updates_ids) appears nowhere in src/db/sql/zkapp-commands/queries.ts.
  3. No CTE in either query selects from zkapp_account_update_body without a predicate tying it to block_range.
  4. EXPLAIN (ANALYZE, BUFFERS) output pasted into the PR for three cases against a real archive (devnet is fine):
    • (a) a full ZKAPP_COMMAND_RANGE_SIZE-block range with accountPublicKey set to a key with no account updates — shared read must not exceed the same range with no filter.
    • (b) the same range with tokenId = wSHV2S4qX9jFsLjQo8r1BsMLH2ZRKsZx6EJd1sbozGPieEC4Jf and no accountPublicKey — must complete inside PG_STATEMENT_TIMEOUT, and the plan must contain no scan of zkapp_account_update_body that is not parameterised by the range.
    • (c) a one-block range with that same token id — shared read must be within roughly one block's worth, i.e. it must scale with the range, not with the account's history. Today (b) and (c) are the failure cases; (c) is the sharp one.
  5. The three tests in tests/services/zkapp-commands-service/zkapp-commands-service.test.ts under SQL account-update limit preflight currently pin target_account_updates and = ANY(…) and will fail after this change — that is expected. Rewrite them to assert the invariant rather than the shape: count-query text must not match /zkapp_account_update_body/; data-query text must contain exactly one /unnest\(zkc\.zkapp_account_updates_ids\)/. (Aside: makeSqlTextClient interpolates values with bare String(v), so what those tests inspect is not what postgres.js actually sends — real queries are parameterised, so there is no injection here, but do not treat that helper's output as evidence about escaping.)
  6. npm run build, npm run lint, npm run test:unit and npm run test:integration all pass.

Finding 2 (new this round) — the new query is gated by the wrong flag, which forces it on for every mina-explorer deployment

For a development AI agent picking this up.

src/resolvers.ts:15-16,74-78 gates Query.zkappCommands behind ENABLE_BLOCK_TRANSACTION_DETAILS. That flag already means something else and is already load-bearing for a downstream consumer:

  • On main it gates block-detail output only — src/services/blocks-service/blocks-service.ts:101 (populating transactions.{userCommands,zkappCommands,feeTransfers}) and :397 (parentHash). Documented at docs/getting-started.md:191 as "Include userCommands / zkappCommands / feeTransfers".
  • mina-explorer needs it on. src/pages/ZkAppsPage.tsx:57-75 (ZKAPP_ACTIVITY_QUERY_FLAT) reads blocks{transactions{zkappCommands{hash feePayer fee memo}}} and extracts at :156-169; src/services/api/blocks.ts:70,84 and src/components/blocks/BlockDetail.tsx:218 do the same. With the flag off the query still validates and simply returns empty arrays, so the zkApps page silently shows nothing.

So any deployment serving mina-explorer must set ENABLE_BLOCK_TRANSACTION_DETAILS=true — and, with this PR, thereby also exposes a brand-new range query with the unbounded-work path of Finding 1, whether or not the operator wants it. My case-B/case-E table in §2b is the proof: flipping that one variable is what adds zkappCommands to the served Query. ENABLED_QUERIES is an escape hatch, but it defaults to "everything", so the default for a mina-explorer deployment is exposed.

docs/versioning.md → "Flag-gating behaviour changes" asks each such change to ship behind an env flag; overloading an existing one means the operator cannot take one capability without the other. And neither README.md, docs/getting-started.md:191 nor .env.example.compose was updated to say the flag now also exposes a query — an operator reading the docs cannot discover this.

Suggested fix

Give it its own flag, defaulting off, in src/server/server.ts next to the others:

const ENABLE_ZKAPP_COMMAND_QUERY =
  process.env.ENABLE_ZKAPP_COMMAND_QUERY === 'true';

and in src/resolvers.ts replace

const ENABLE_BLOCK_TRANSACTION_DETAILS =
  process.env.ENABLE_BLOCK_TRANSACTION_DETAILS === 'true';

if (!ENABLE_BLOCK_TRANSACTION_DETAILS) {

with

const ENABLE_ZKAPP_COMMAND_QUERY =
  process.env.ENABLE_ZKAPP_COMMAND_QUERY === 'true';

if (!ENABLE_ZKAPP_COMMAND_QUERY) {

Add ENABLE_ZKAPP_COMMAND_QUERY?: string; to src/envionment.d.ts and document it in the three env tables. Leave the ENABLE_BLOCK_TRANSACTION_DETAILS row exactly as it is.

Acceptance criteria

  1. Query.zkappCommands is gated by ENABLE_ZKAPP_COMMAND_QUERY (default false) and by nothing else.
  2. With ENABLE_BLOCK_TRANSACTION_DETAILS=true and ENABLE_ZKAPP_COMMAND_QUERY unset: printSchema(schema) does not contain zkappCommands(, and blocks{transactions{zkappCommands{hash feePayer fee memo}}} still validates. Both halves must hold — the second is the mina-explorer path.
  3. With ENABLE_ZKAPP_COMMAND_QUERY=true and ENABLE_BLOCK_TRANSACTION_DETAILS unset: Query.zkappCommands is present.
  4. ENABLED_QUERIES still fails closed: with ENABLED_QUERIES listing zkappCommands but ENABLE_ZKAPP_COMMAND_QUERY unset, the field is absent (this is case E in §2b — preserve that behaviour).
  5. README.md, docs/getting-started.md and .env.example.compose document the new flag; the ENABLE_BLOCK_TRANSACTION_DETAILS rows are unchanged.
  6. A unit test in tests/unit/ asserts 2, 3 and 4.

5. Non-blocking (one line each, per the "ship it" bar — do not gold-plate these)

  • Filtered results return a partial command. With accountPublicKey/tokenId set, ZkappCommandOutput.accountUpdates contains only the matching updates, not the whole command — but each carries callDepth, so a consumer can easily mis-reconstruct the call tree from a subset. One sentence in the ZkappCommandFilterOptionsInput description would close it.
  • Three new env vars where main has one. ZKAPP_COMMAND_RANGE_SIZE (1000) and ZKAPP_COMMAND_ACCOUNT_UPDATE_LIMIT (5000) are a third and fourth copy of the validateRange pattern already in events-service.ts:72-77 and actions-service.ts:146-151. #225 reuses main's BLOCK_RANGE_SIZE and message text instead. Reusing BLOCK_RANGE_SIZE, or lifting one shared assertBlockRange(from, to, max) helper, would leave one knob to reason about rather than two. (Patch A makes ZKAPP_COMMAND_ACCOUNT_UPDATE_LIMIT genuinely meaningful, so that one earns its keep.)
  • USED_TABLES is a boot gate. src/db/sql/events-actions/queries.ts:543-566 adds 8 tables, and src/db/archive-node-adapter/archive-node-adapter.ts:106-112 throws Missing table X and refuses to start if any is absent — unconditionally, even with the feature flag off. I verified all 8 (public_keys, tokens, zkapp_fee_payer_body, zkapp_updates, zkapp_account_precondition, zkapp_network_precondition, zkapp_global_slot_bounds, zkapp_states_nullable) exist in the canonical archive schema, so the practical risk is nil — noted only so nobody is surprised.
  • 7 orphan types are served when the flag is off (§2b). Harmless — introspection is off by default and Cannot query field still fires correctly — but if you want the served schema to match the enabled surface exactly, prune unreachable types in the same AST visit.
  • The CHANGES_REQUESTED from @dkijania (2026-06-11) is stale: @dkijania has since authored the head commit 3d9f97fa on this branch. Worth re-reviewing or dismissing so it stops reading as an outstanding objection.

6. Coordination with #225 ("Add bounded verification-key update query")

I reviewed #162 alongside #225 so the two authors get one story instead of two.

  • No schema collision. #225 adds VerificationKeyUpdateFilterInput, VerificationKeyUpdate, Query.verificationKeyUpdates (+25/-0); #162 adds the 7 coordinates in §2 (+77/-0). Zero name overlap, zero semantic overlap. The only conflicts are textual and append-only — the Query block, the insertion point after ActionFilterOptionsInput, plus src/resolvers.ts, src/db/sql/events-actions/queries.ts, archive-node-adapter{,.interface}.ts and tests/integration/integration.test.ts. Resolve by taking both sides.
  • #225 already solves Finding 1's problem, and #162 should copy it. In src/db/sql/verification-key-updates/queries.ts, #225 resolves the hash to an id-set in target_updates AS MATERIALIZED (…), then drives the join from the bounded block range and applies the set as a semi-join predicate (zaub.update_id IN (SELECT id FROM target_updates)). Work stays bounded by the range; the MATERIALIZED is deliberate and carries a measurement in the comment (405 ms inlined vs 199 ms materialised on a 681k-block devnet archive over a 10 000-block range). Patch B above is that same shape. #225 also reuses main's throwBlockRangeError and main's BLOCK_RANGE_SIZE rather than inventing new knobs.
  • The index ask is one ask, not two. From tests/integration/fixtures/archive_db.sql:5727-5874, none of these is indexed: zkapp_account_update_body.account_identifier_id (#162), zkapp_account_update_body.update_id (#225), zkapp_account_update.body_id (both), zkapp_updates.verification_key_id (#225). Both queries sequential-scan zkapp_account_update_body today. This should go to whoever owns archive-node schema as a single migration naming all four columns, credited to both PRs — not as two competing requests.
  • Both PRs are blocked by the same CI defect (§3): both are fork PRs, both have zero check-runs, and Check Schema would fail on either even after approval.

I have a message out to the #225 reviewer on two open questions — whether the 199 ms MATERIALIZED measurement was taken with a large target_updates set (if not, #225 has a milder version of the same unboundedness and I would soften the recommendation above), and whether #225 is content sharing BLOCK_RANGE_SIZE. No reply yet at the time of writing, so treat those two sub-points as provisional; everything else in this section I verified directly from the two heads.


7. What would make this mergeable — in order

Maintainer (blocking, and nobody else can do these):

  1. Click "Approve and run workflows" on this PR. Seven runs have been sitting at action_required since 2026-08-27. #225 needs the same.
  2. Fix .github/workflows/graphql-inspector.yaml (§3) so Check Schema can pass on a fork PR at all. Without this, step 1 buys you a red Check Schema for a PR whose schema is provably fine — worse than no signal. This also unblocks #225 and stops the recurring Dependabot failure.
  3. Route the four-column index migration (§6) to the archive-node schema owner, as one request covering #162 and #225.

Author (blocking):

  1. Finding 1 — Patches A and B, plus the EXPLAIN (ANALYZE, BUFFERS) evidence for the three cases. This is the one that would otherwise ship a one-line unauthenticated request that walks the whole zkapp_account_update_body table.
  2. Finding 2 — move Query.zkappCommands to its own ENABLE_ZKAPP_COMMAND_QUERY flag, default off, and document it.
  3. Update the three SQL account-update limit preflight tests, which pin the SQL shape Patch B removes.

Author (cheap, do them in the same push):

  1. One sentence in the ZkappCommandFilterOptionsInput description saying that a filter narrows accountUpdates to the matching updates only.
  2. Decide ZKAPP_COMMAND_RANGE_SIZE vs reusing BLOCK_RANGE_SIZE, ideally matching whatever #225 settles on.

Then:

  1. Mark ready for review, and ask @dkijania to refresh or dismiss the June CHANGES_REQUESTED (he has since authored the head commit).

Once 1, 2, 4 and 5 land, I do not see anything else standing between this and merge. The schema half of this PR is in good shape — it is provably additive, it fails closed, and it does not touch a single string, field or argument either downstream consumer depends on. The SQL half is where the remaining risk is, and it is concentrated in one CTE.


How to reproduce everything above
# rebase state
git merge-base origin/main refs/remotes/pr/162     # -> 3fd33794 == origin/main
git merge-tree origin/main refs/remotes/pr/162     # -> single tree oid, exit 0

# build / lint / unit against current main's toolchain
git archive refs/remotes/pr/162 | tar -x -C /tmp/pr162 && cd /tmp/pr162
npm run build && npm run lint && npm run test:unit

# served-schema probe, per env combination
ENABLED_QUERIES= ENABLE_BLOCK_TRANSACTION_DETAILS= \
  node -e "import('./build/src/resolvers.js').then(async m => {
    const {printSchema}=await import('graphql');
    console.log(Object.keys(m.schema.getQueryType().getFields()).sort());
  })"

# validation error strings
node -e "import('./build/src/resolvers.js').then(async m => {
    const {parse,validate}=await import('graphql');
    console.log(validate(m.schema, parse('{ zkappCommands(input:{from:1,to:2}){hash} }')).map(e=>e.message));
  })"

# CI state
gh pr checks 162
gh api repos/o1-labs/Archive-Node-API/commits/3d9f97fa/check-runs
gh run view 28152520334 --repo o1-labs/Archive-Node-API --log-failed | grep -i "not accessible"

# indexes present in the archive schema
grep -n '^CREATE INDEX' tests/integration/fixtures/archive_db.sql

@SanabriaRusso SanabriaRusso left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Follow-up: the two provisional points in §6 are now answered, plus one correction to my own comment

The #225 reviewer came back on both open questions. Replacing the provisional wording in §6 of my comment above.

1. No softening needed — #225 stands as the model, with one caveat #162 should inherit. The MATERIALIZED measurement was not taken on a large target_updates set, but a large one does not exist in the data: on the current hourly devnet dump (691,103 blocks, 176,348 zkapp_updates rows), zkapp_updates is hash-consed — across all 1,593 distinct verification keys the maximum rows for any single vk hash is 564. EXPLAIN (ANALYZE, BUFFERS) on the real SQL at the full 10,000-block range: 196–265 ms for the most common vk, 277–457 ms for a vk with 260 hits, and 9–125 ms for a hash matching nothing.

That last number is the point. In #225 the miss case is the cheapest. In #162 the miss case is the pathological one — a filter that matches nothing still resolves target_account_updates over the whole archive. Exact opposite shape.

The caveat: #225 measured a synthetic scaling curve confirming cost is linear in |target set| and uncapped by the range guard (1k → 22 ms, 100k → 2.2 s, 1M → 24.5 s). It is non-blocking there only because the cardinality cannot be reachedzkapp_updates is hash-consed. zkapp_account_update_body is not. So when copying #225's shape (Patch B), copy the range-driven semi-join, not the "large set is fine" conclusion: on this table the set genuinely can be large, which is precisely why Patch B keeps block_range as the sole driver rather than relying on the filter set being small.

2. BLOCK_RANGE_SIZE: #225 shares it, explicitlyverification-key-updates-service.ts:10 imports it unchanged (used at :66-69) from server.js and adds no knob. That settles non-blocking item 2 in §5: ZKAPP_COMMAND_RANGE_SIZE should be dropped in favour of BLOCK_RANGE_SIZE and the shared throwBlockRangeError message text. (ZKAPP_COMMAND_ACCOUNT_UPDATE_LIMIT still earns its keep once Patch A makes it measure range work.)

3. Indexes: withdrawing my version. #225's reviewer has taken ownership of the joint four-column ask, attributed to both PRs, proposed for docs/runbook.md. Use §6 of the #225 review as the single source; ignore my §6 phrasing so there is one request, not two. They independently confirmed zkapp_updates.verification_key_id is unindexed and measured a 21.5 ms range-independent floor from it.

4. Correction to my own comment. In §4 I wrote that throwBlockRangeError yields "BLOCK_RANGE_ERROR / 400". The 400 is misleading: src/errors/error.ts:10-16 puts it in extensions.status, and graphql-yoga only honours extensions.http.status — so the wire status is HTTP 200 with a populated errors[] and extensions.code, verified against a live server by the #225 reviewer.

This is better news than what I wrote. mina-explorer-api (app/upstream/graphql.py) opens its per-endpoint circuit breaker on a 4xx with no errors[]; a 200 with errors[] cannot trip it. #162 inherits this automatically since it uses the same throwBlockRangeError. So a client hammering zkappCommands with an over-limit range degrades gracefully instead of taking the archive tier dark. Nothing to change — I just described the mechanism wrongly.

For the record: #225 was approved, with a strictly additive schema change (0 removed lines in schema.graphql and src/resolvers-types.ts). Everything else in my comment above stands as written.

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 zkApp command range query with account update details

3 participants