Add per-entity storage routing via @storage directive - #1194
Conversation
…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
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds per-entity ChangesMulti-Storage Entity Directives
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)
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
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.
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.
Summary
Implements per-entity storage routing, allowing entities to be selectively written to Postgres and/or ClickHouse via an optional
@storagedirective. This enables multi-storage deployments where different entities can target different backends.Key Changes
Entity Parsing & Validation
postgresandclickhousefields toEntitystruct to track per-entity storage preferences asOption<bool>(None = not specified, Some(true/false) = explicit)parse_storage_directive()to parse@storage(postgres: true, clickhouse: true)directives with comprehensive error handling:has_storage_directive()andresolved_storage()methods to Entity for checking and resolving storage preferencesStorage Validation
validate_entity_storage()insystem_config.rsto validate per-entity storage against global config:@storageis_multi()helper to Storage to detect multi-storage modeConfig Generation
public_config_json.rsto include resolved per-entity storage in generated JSONConfig.resto addpgUserEntitiesarray—a filtered subset of entities whose resolved storage includes PostgrespgUserEntitiesinstead of all entities, since Hasura sits on top of PostgresTest Schema & E2E Tests
schema-with-multi-storage.graphqltest schema with multi-storage entitiesTransferPgOnlyandTransferChOnlyentitiesImplementation Details
@storageinherit the global storage settingfalsein per-entity resolutionhttps://claude.ai/code/session_01BHFQ1ZdviymDkbefiz2RRZ
Summary by CodeRabbit
New Features
Tests