Skip to content

Add per-entity storage routing via @storage directive - #1194

Merged
DZakh merged 14 commits into
mainfrom
claude/storage-config-api-viQhY
May 14, 2026
Merged

Add per-entity storage routing via @storage directive#1194
DZakh merged 14 commits into
mainfrom
claude/storage-config-api-viQhY

Conversation

@DZakh

@DZakh DZakh commented May 11, 2026

Copy link
Copy Markdown
Member

Summary

Implements per-entity storage routing, allowing entities to be selectively written to Postgres and/or ClickHouse via an optional @storage directive. This enables multi-storage deployments where different entities can target different backends.

Key Changes

Entity Parsing & Validation

  • Added postgres and clickhouse fields to Entity struct to track per-entity storage preferences as Option<bool> (None = not specified, Some(true/false) = explicit)
  • Implemented parse_storage_directive() to parse @storage(postgres: true, clickhouse: true) directives with comprehensive error handling:
    • E3: Malformed directives (unknown args, non-boolean values, duplicate args/directives)
    • E4: Directives that enable no storage (all false or empty)
  • Added has_storage_directive() and resolved_storage() methods to Entity for checking and resolving storage preferences
  • Comprehensive test coverage for directive parsing and resolution logic

Storage Validation

  • Implemented validate_entity_storage() in system_config.rs to validate per-entity storage against global config:
    • E1: Entities cannot use backends not enabled globally
    • E2: In multi-storage mode, every entity must declare @storage
    • Provides helpful error messages with examples and fixes
  • Added is_multi() helper to Storage to detect multi-storage mode
  • Integrated validation into SystemConfig initialization

Config Generation

  • Updated public_config_json.rs to include resolved per-entity storage in generated JSON
  • Modified Config.res to add pgUserEntities array—a filtered subset of entities whose resolved storage includes Postgres
  • Updated Hasura integration to use pgUserEntities instead of all entities, since Hasura sits on top of Postgres

Test Schema & E2E Tests

  • Added schema-with-multi-storage.graphql test schema with multi-storage entities
  • Updated e2e test scenario to demonstrate per-entity storage routing with TransferPgOnly and TransferChOnly entities
  • Updated test snapshots to reflect new storage field in generated config JSON

Implementation Details

  • Single-storage mode: entities without @storage inherit the global storage setting
  • Multi-storage mode: unspecified backends default to false in per-entity resolution
  • Error messages are aggregated and sorted alphabetically for consistency
  • All validation happens at config load time, preventing runtime surprises

https://claude.ai/code/session_01BHFQ1ZdviymDkbefiz2RRZ

Summary by CodeRabbit

  • New Features

    • Per-entity storage routing: entities can opt into Postgres, ClickHouse, or both; configs expose per-entity storage metadata and a Postgres-backed entity view
    • Runtime validation against global storage settings; indexer emits distinct Postgres-only, ClickHouse-only, and dual-storage entity records
  • Tests

    • Expanded unit and integration tests for directive parsing, validation, resolution, and multi-storage scenarios

Review Change Stack

claude added 3 commits May 11, 2026 13:15
…ckends

When multiple storages (postgres + clickhouse) are configured in
config.yaml, every entity in schema.graphql must declare which backends
it writes to via @storage(...). Single-storage projects are unaffected:
unannotated entities inherit the global storage.

Validation aggregates two error classes into one message so users can
fix everything in one pass: (E2) entities missing @storage in
multi-storage mode, and (E1) entities targeting a backend not enabled
globally. Malformed directives (E3) and all-false directives (E4) are
caught at schema parsing.

Hasura tracks only entities resolved to Postgres. The resolved
per-entity storage flows through `EntityJson.storage` in the public
config JSON and surfaces in `Config.t.pgUserEntities`, which PgStorage
hands to Hasura.

https://claude.ai/code/session_01BHFQ1ZdviymDkbefiz2RRZ
Replace `insta::assert_snapshot!(err.to_string())` with `assert_eq!`
(or `assert!(contains)` for the alphabetical-ordering test) so the
expected error message lives next to the test rather than in a
separate snapshot file. Delete the now-unused snapshot files.

https://claude.ai/code/session_01BHFQ1ZdviymDkbefiz2RRZ
The scenario already runs in multi-storage mode (postgres + clickhouse),
so the new validation now requires every entity to declare a backend.

- Annotate the existing `Transfer` entity with both backends.
- Add `TransferPgOnly` (postgres-only) and `TransferChOnly`
  (clickhouse-only) to cover the per-entity override paths.
- Write rows for all three in the ERC20 Transfer handler and capture
  them in the indexer smoke-test inline snapshot.

https://claude.ai/code/session_01BHFQ1ZdviymDkbefiz2RRZ
@coderabbitai

coderabbitai Bot commented May 11, 2026

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds per-entity @storage parsing and validation; emits per-entity storage in public JSON; threads resolved storage into envio runtime (for Postgres tracking); updates CLI tests, e2e schema, handlers, and snapshots.

Changes

Multi-Storage Entity Directives

Layer / File(s) Summary
Entity Storage Model
packages/cli/src/config_parsing/entity_parsing.rs
Entity struct gains public postgres: Option<bool> and clickhouse: Option<bool> fields; Entity::new initializes both to None.
Storage Directive Parsing
packages/cli/src/config_parsing/entity_parsing.rs
Entity::from_object parses optional @storage(...). New parse_storage_directive validates directive count, arg names (postgres/clickhouse), boolean types, duplicates, and that at least one backend is enabled. Unit tests added for parsing and error cases.
Public Config JSON Serialization
packages/cli/src/config_parsing/public_config_json.rs
EntityJson gains optional storage: EntityStorageJson. SystemConfig::to_public_config_json resolves each entity's storage and includes it in emitted JSON when present.
Global Storage Validation
packages/cli/src/config_parsing/system_config.rs
Adds Storage::is_multi() and validate_entity_storage(storage, schema); invoked from SystemConfig::from_human_config after storage resolution to surface missing-directive and unsupported-backend errors; tests added for validation paths and deterministic ordering.
CLI Tests & Schema
packages/cli/test/configs/config-with-all-options.yaml, packages/cli/test/schemas/schema-with-multi-storage.graphql
Test config updated to reference multi-storage schema. New schema defines Status, RelatedEntity, and EmptyEntity annotated with @storage(postgres: true, clickhouse: true) for coverage.
Envio Types & Config Mapping
packages/envio/src/Internal.res, packages/envio/src/Config.res
Introduces entityStorage type and adds required storage to genericEntityConfig; public JSON parsing maps nested storage into Internal config; EnvioAddresses sets explicit per-entity storage; fromPublic normalizes global storage and passes it into parsing; added getPgUserEntities.
Postgres Hasura Integration
packages/envio/src/Config.res, packages/envio/src/PgStorage.res
trackDatabase initialization now uses ~userEntities=config->Config.getPgUserEntities so only entities with storage.postgres == true are tracked.
E2E Schema, Handler, Tests
scenarios/e2e_test/schema.graphql, scenarios/e2e_test/src/handlers/ERC20.ts, scenarios/e2e_test/src/indexer.test.ts
Transfer becomes multi-storage; new TransferPgOnly and TransferChOnly added. ERC20 handler writes base and per-backend override entities; indexer snapshot updated to include new groups.

Sequence Diagram(s)

sequenceDiagram
  participant GraphQLSchema
  participant CLIParser as CLI Parser
  participant SystemConfig
  participant Envio
  participant Hasura as PgStorage/Hasura
  GraphQLSchema->>CLIParser: provide ObjectType with optional `@storage`
  CLIParser->>SystemConfig: construct Entities (postgres/clickhouse flags)
  SystemConfig->>SystemConfig: validate_entity_storage(storage, schema)
  SystemConfig->>Envio: emit public config JSON (entity.storage)
  Envio->>Hasura: fromPublic -> getPgUserEntities(config)
  Hasura->>Hasura: trackDatabase(~userEntities=pgUserEntities)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested reviewers

  • JonoPrest
  • moose-code

Poem

🐰 Hops among schemas, parsing bright,
Directives tucked in flags of light.
Postgres, ClickHouse — each knows their part,
Configs and handlers play their art.
Rabbit nods, storage set just right.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title accurately summarizes the main change: adding per-entity storage routing via the @storage directive, which is the primary feature implemented across all modified files.
Docstring Coverage ✅ Passed Docstring coverage is 80.65% which is sufficient. The required threshold is 80.00%.
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.

✏️ 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 claude/storage-config-api-viQhY

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

Reflow the docstring so it isn't parsed as an unindented continued list,
which clippy rejects under -D warnings.

https://claude.ai/code/session_01BHFQ1ZdviymDkbefiz2RRZ
Per CLAUDE.md, drop comments that restate the code (the Entity storage
field tags, the pgUserEntities header block, test helper docstrings).
Keep the index-alignment warning, which is genuinely non-obvious.

Collapse `validate_entity_storage` into two single-class branches:
with two backends, multi-storage mode can only trigger
missing-directive errors and single-storage mode can only trigger
unsupported-backend errors, so the combined formatting paths were dead.
Leave a note pointing at the premise so a third backend would force a
re-merge.
The Rust public JSON now mirrors the `@storage(...)` directive
verbatim: emit the field only when the directive is present, and
within it emit only the args the user wrote. No defaults, no
resolution on the Rust side.

Per-entity storage lives on `Internal.entityConfig` (new
`entityStorage` type, `storage: option<entityStorage>` field). The
`pgUserEntities` field on `Config.t` is replaced by a
`Config.getPgUserEntities` function that derives the subset on call
from each entity's storage plus the global storage config. Hasura
calls the function at init time.

Drops the now-unused `Entity::resolved_storage` Rust helper and its
two tests; Rust JSON snapshots lose the per-entity `storage` block on
single-storage fixtures since those entities never had a directive.
`Internal.entityStorage` becomes `{ postgres: bool, clickhouse: bool }`
(no option) and is always present on `entityConfig`. Resolution moves
into `parseEntitiesFromJson`, which now takes the global storage and
collapses each entity's user-declared values into concrete booleans:
declared backends with `Option.getOr(false)`, undeclared entities
inherit the global. `EnvioAddresses` is hardcoded Postgres-only
(matches Storage::resolve's invariant that Postgres is always enabled).

Downstream filters reduce to a one-liner: `getPgUserEntities` is now
`config.userEntities |> filter(e => e.storage.postgres)`. A future
ClickHouse-specific filter is symmetric.
claude added 2 commits May 13, 2026 13:00
The runtime now respects `@storage(...)` for writes, not just for
Hasura tracking. In `PgStorage.initialize` we split incoming entities
into `pgEntities`/`chEntities` and feed only the matching set to each
backend's table-creation step. In `writeBatchMethod` we filter
`updatedEntities` the same way before handing them to PG's writeBatch
and the sink's writeBatch.

The e2e test in `packages/e2e-tests/src/e2e/e2e.test.ts` adds three
assertions to lock the behaviour:
- Postgres has exactly `Transfer` and `TransferPgOnly` tables, with
  rows in each.
- ClickHouse has exactly `Transfer` and `TransferChOnly` tables, with
  rows in each.
- Hasura's GraphQL schema exposes only the postgres-backed entity
  queries/object types (inline snapshot).

A small `runPgSql` helper wraps Hasura's `run_sql` endpoint to query
Postgres directly. Inline snapshots are populated by vitest on the
first CI run.
`toMatchInlineSnapshot()` with no argument fails in CI (vitest disables
auto-update without `-u`). Seed the three new tests with the expected
shapes so CI runs against real values. If reality diverges (extra
columns, different Hasura query naming, etc.), the CI diff will surface
exact received values for a one-line follow-up.
…hter e2e

- `UserContext` short-circuits get/getWhere/getOrThrow/getOrCreate on
  entities whose resolved storage has `postgres: false`, throwing a
  friendly message about ClickHouse currently being write-only instead
  of letting the SQL layer fail with "relation does not exist".
- `PgStorage.writeBatchMethod` partitions `updatedEntities` into PG and
  CH groups in a single for-loop instead of two `Array.filter` passes.
- The e2e_test ERC20 handler now exercises the new guard: it tries
  `get` and `getWhere` on `TransferChOnly` in separate try/catch blocks
  and asserts both end up with the friendly error, then sets the row.
- Row-count assertions in the e2e tests upgrade from `>0` to
  `toMatchInlineSnapshot()` so the exact counts get locked on the next
  CI run.
Empty `toMatchInlineSnapshot()` placeholders fail in CI (vitest doesn't
auto-update without `-u`). Replace the four row-count assertions with
exact-equality checks tied to `Transfer`'s Postgres row count: the
handler writes one row to each entity per Transfer event, so every
backend's per-entity count must match. This is stronger than `>0` —
dropped writes will surface as a count mismatch — and avoids the
magic-number bootstrap problem.
`.includes()` lets the message drift unnoticed. Switch to exact `===`
on the verbatim string thrown by `throwClickHouseReadOnly` in
UserContext.res, so any wording change here without updating the
e2e handler trips the test.
@DZakh
DZakh merged commit 9fd3bd8 into main May 14, 2026
8 checks passed
@DZakh
DZakh deleted the claude/storage-config-api-viQhY branch May 14, 2026 09:39
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.

2 participants