Add schema-check subcommand and pre-deploy guard (#58)#108
Add schema-check subcommand and pre-deploy guard (#58)#108thedavidmeister merged 16 commits intomainfrom
Conversation
`rain-metadata schema-check --source <subgraph/schema.graphql> --consumer <crates/metaboard/src/schema/metaboard.graphql>` parses both files, walks every entity in the source schema, and reports missing entities, missing fields, and field-type mismatches. Exits non-zero on any drift. Wired in as a pre-deploy step in manual-subgraph-deploy.yml. If the consumer snapshot has drifted, the workflow fails before `subgraph-deploy` runs — no Goldsky deploys, nothing to roll back. Run on this branch surfaces the existing drift between the deployed subgraph and the metaboard crate's snapshot: - field `MetaV1.transaction` is missing from consumer schema - field `MetaV1.subject` type mismatch: source `Bytes!` vs consumer `BigInt!` - entity `Transaction` is missing from consumer schema Closes #58 once the consumer snapshot is regenerated to match. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
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:
WalkthroughAdds a schema-check CLI that validates consumer schemas against a local SDL or a live GraphQL introspection, updates the CLI to async, extends the metaboard schema, and integrates schema checking plus conditional rollback into the manual subgraph deploy GitHub Actions workflow. ChangesSchema Validation with Deployment Rollback
Sequence DiagramsequenceDiagram
participant GH as GitHub Actions
participant Deploy as subgraph-deploy
participant GS as Goldsky API
participant Meta as rain-metadata CLI
GH->>Deploy: run subgraph-deploy (capture logs -> deploy.log)
Deploy->>GS: create deployed subgraph versions
GS-->>Deploy: return deployed endpoints (URLs)
Deploy-->>GH: write deployed_urls.txt
GH->>Meta: run schema-check --live-url <first-captured-URL> --consumer <path>
Meta->>GS: POST introspection query to live URL
GS-->>Meta: return schema JSON
Meta-->>GH: validation result (ok or errors + synthesized SDL)
alt schema validation fails
GH->>GS: goldsky subgraph delete name_and_version (per-network)
GS-->>GH: deletion results
else schema validation passes
GH-->>GH: finish deploy
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes 🚥 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)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@crates/cli/Cargo.toml`:
- Around line 11-17: graphql-parser is currently an unconditional dependency but
is only used by the CLI subcommand in schema_check.rs, so gate it behind the
existing "cli" feature: move/remove the unconditional graphql-parser dependency
entry from Cargo.toml, add it as an optional dependency (set optional = true)
and include it in the cli feature array alongside "dep:tracing", "dep:clap",
etc., so that graphql-parser is only compiled when the "cli" feature is enabled
(and update any Cargo.toml dependency key names to match the existing dep:...
pattern).
In `@crates/cli/src/cli/schema_check.rs`:
- Around line 60-64: The code rebuilds consumer_fields (BTreeMap<&str,
&Field<'_, String>>) from consumer_entity.fields inside the outer loop; instead
precompute a lookup for every entity once (e.g., a map from entity name or
reference to BTreeMap<&str, &Field<'_, String>>) before iterating sources—move
the BTreeMap construction out of the loop that uses consumer_entity so you can
reuse the per-entity field map (build it from all_objects() or a helper like
build_entity_field_map()) and then reference that precomputed map inside the
loop where consumer_fields is currently created.
- Around line 88-92: The current check() returns Ok(0) when source_entities is
empty which silently passes; modify check() to treat an empty source_entities as
an error condition: if source_entities.is_empty() return Err(...) (or push a
descriptive error into errors before the final if/else) so the function returns
Err when no `@entity` types were found. Reference the check() function and the
source_entities and errors variables and ensure the final result reports a clear
failure (with a message like "no `@entity` types found in source; possible
misconfigured path or non-subgraph SDL") instead of Ok(0).
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: 1f539f2c-f143-4084-a072-98df3979aeb2
⛔ Files ignored due to path filters (1)
Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (4)
.github/workflows/manual-subgraph-deploy.ymlcrates/cli/Cargo.tomlcrates/cli/src/cli/mod.rscrates/cli/src/cli/schema_check.rs
Adds 12 cases covering: empty source, non-Object source defs ignored, @entity(immutable: true), consumer-side extras (filter/orderBy/derived fields with arguments) ignored, nullability mismatch, list vs scalar, nested wrapper recursion, multiple simultaneous errors, parse errors on each side, and a consumer with no Object types. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a dedicated `schema-drift-check` job to rainix.yaml so the source/consumer comparison runs on every push, not only at deploy time. This branch's CI will go red until the consumer schema in crates/metaboard/src/schema/metaboard.graphql is regenerated. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This reverts commit 316995f.
Inverts the previous design. The deploy workflow now: 1. Runs subgraph-deploy (deploys all networks). 2. Captures the resulting Goldsky URLs from the deploy log. 3. Runs `rain-metadata schema-check --live-url <URL> --consumer ...` against one of the live URLs. The schema-check binary fetches GraphQL introspection, reduces it to entity-shaped Object types, and diffs against the consumer snapshot. 4. On schema-check failure, rolls back every just-deployed `metaboard-<network>/<address>-<commit>` via `goldsky subgraph delete`. The introspection-derived SDL is printed in the failing step's log so the consumer file can be regenerated by copy/paste. Adds `--live-url` to the `schema-check` subcommand. Mutually exclusive with `--source`. Uses tokio runtime + reqwest (already deps of the cli crate). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 2
♻️ Duplicate comments (1)
crates/cli/src/cli/schema_check.rs (1)
160-204:⚠️ Potential issue | 🟠 Major | ⚡ Quick winFail when the source yields zero
@entitytypes.
check()currently returnsOk(0), and the test below locks that in. That makes a bad--sourceinput or an empty live-derived SDL look like a successful validation run, so the guard can pass without checking anything.Suggested fix
let source_entities = entities(&source_doc); let consumer_objects = all_objects(&consumer_doc); + if source_entities.is_empty() { + return Err(vec![ + "source schema has no `@entity` types; verify --source/--live-url input".into(), + ]); + } + let mut errors = Vec::new();- fn empty_source_passes_with_zero_entities() { - // No `@entity` types in source means nothing to verify; consumer - // can have anything. + fn empty_source_is_reported() { let source = "scalar Bytes"; let consumer = "type Whatever { x: Int }"; - let n = check(source, consumer).unwrap(); - assert_eq!(n, 0); + let errs = check(source, consumer).unwrap_err(); + assert_eq!(errs.len(), 1); + assert!(errs[0].contains("no `@entity` types")); }Also applies to: 396-404
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/cli/src/cli/schema_check.rs` around lines 160 - 204, The function currently treats an empty source_entities (result of entities(&source_doc)) as a successful check by returning Ok(0); change it to treat zero `@entity` types as an error: after computing source_entities, if source_entities.is_empty() push a descriptive error into errors (e.g. "no `@entity` types found in source schema") and return Err(errors) instead of Ok(0); update the same logic in the other occurrence around the block referenced (lines ~396-404) so both places fail when the source yields zero entities.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In @.github/workflows/manual-subgraph-deploy.yml:
- Around line 26-55: Reorder and tighten the workflow so schema validation runs
before pushing changes: move the "Schema check against live deployment" step to
run prior to "Deploy and capture URLs" (or replace it with a pre-deploy
schema-check/dry-run that validates the local schema), ensure it uses the
intended target (or a precomputed/declarative URL) rather than relying on deploy
output, and make URL capture failures fatal by removing the "echo
::warning::...; exit 0" logic that turns missing deployed_urls.txt into success;
keep the existing "Rollback on schema failure" logic but only allow it to run
when the actual deploy ran and schema_check passed beforehand (so
subgraph-deploy is not executed until schema_check succeeds).
In `@crates/cli/src/cli/schema_check.rs`:
- Around line 81-92: The live introspection HTTP call has no timeouts and can
hang; update the client creation used inside runtime.block_on (replace
reqwest::Client::new()) to build a reqwest::Client via
reqwest::Client::builder() with a connect_timeout (e.g. Duration::from_secs(10))
and an overall request timeout (e.g. .timeout(Duration::from_secs(30))) so the
client.post(...).send().await? will fail fast on slow/unresponsive endpoints;
import std::time::Duration and handle the resulting build() error or propagate
it from the surrounding function.
---
Duplicate comments:
In `@crates/cli/src/cli/schema_check.rs`:
- Around line 160-204: The function currently treats an empty source_entities
(result of entities(&source_doc)) as a successful check by returning Ok(0);
change it to treat zero `@entity` types as an error: after computing
source_entities, if source_entities.is_empty() push a descriptive error into
errors (e.g. "no `@entity` types found in source schema") and return Err(errors)
instead of Ok(0); update the same logic in the other occurrence around the block
referenced (lines ~396-404) so both places fail when the source yields zero
entities.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: fe7e6cad-6b07-4030-8a14-2ce436cad703
📒 Files selected for processing (2)
.github/workflows/manual-subgraph-deploy.ymlcrates/cli/src/cli/schema_check.rs
The cli crate's tokio is feature-gated to `rt` only, so `tokio::runtime::Runtime::new()` is unavailable (it requires `rt-multi-thread`). Switch the introspection HTTP call to `reqwest::blocking::Client`, which doesn't need a runtime. Adds the `blocking` feature to reqwest in crates/cli/Cargo.toml. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`crates/cli` already runs under #[tokio::main], so reqwest::blocking
panics at runtime ("Cannot drop a runtime in a context where blocking
is not allowed"). Make `schema_check`, `dispatch`, and `cli::main`
async, await through to the call site, and use `reqwest::Client`
(async) which shares the existing tokio runtime.
Reverts the `blocking` feature added in the previous commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
♻️ Duplicate comments (2)
crates/cli/src/cli/schema_check.rs (2)
80-90:⚠️ Potential issue | 🟠 Major | ⚡ Quick winAdd request/connect timeouts for live introspection fetch.
This network call is part of a deploy guard, but it currently has no deadline and can hang indefinitely on slow/broken endpoints.
Suggested fix
+use std::time::Duration; + async fn fetch_live_entities_as_sdl(url: &str) -> anyhow::Result<String> { - let client = reqwest::Client::new(); + let client = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(30)) + .build()?; let body = serde_json::json!({ "query": INTROSPECTION_QUERY }); let resp: serde_json::Value = client .post(url)🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/cli/src/cli/schema_check.rs` around lines 80 - 90, The fetch_live_entities_as_sdl function currently creates a reqwest::Client with Client::new() that can hang; replace that with a client built via reqwest::Client::builder() and set both a connect_timeout (e.g. Duration::from_secs(10)) and an overall request timeout (e.g. Duration::from_secs(30)) so the POST/json/send calls fail fast on slow endpoints; update the client variable creation in fetch_live_entities_as_sdl, add the required std::time::Duration import, and preserve existing error propagation (the ? chains) so behavior otherwise remains the same.
155-199:⚠️ Potential issue | 🟠 Major | ⚡ Quick winFail fast when source has zero
@entitytypes.Returning success with
0 entities verifiedallows misconfigured/incorrect source SDL to bypass this pre-deploy guard. Treat this as an error condition.Suggested fix
fn check(source_sdl: &str, consumer_sdl: &str) -> Result<usize, Vec<String>> { @@ let source_entities = entities(&source_doc); let consumer_objects = all_objects(&consumer_doc); + if source_entities.is_empty() { + return Err(vec![ + "source schema has no `@entity` types; verify --source/--live-url input".into(), + ]); + } + let mut errors = Vec::new();(Downstream impact: update the test that currently expects empty source to pass.)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/cli/src/cli/schema_check.rs` around lines 155 - 199, The current check returns Ok(0) when the source SDL has zero `@entity` types, letting an empty/misconfigured source pass; update the logic in schema_check.rs after computing source_entities (from entities(&source_doc)) to treat an empty source_entities as an error: if source_entities.is_empty() return Err with a clear error message (e.g., "source schema contains no `@entity` types") instead of Ok(source_entities.len()); keep the rest of the comparison logic (consumer_objects, field/type checks) unchanged and update the tests that expected an empty source to succeed.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Duplicate comments:
In `@crates/cli/src/cli/schema_check.rs`:
- Around line 80-90: The fetch_live_entities_as_sdl function currently creates a
reqwest::Client with Client::new() that can hang; replace that with a client
built via reqwest::Client::builder() and set both a connect_timeout (e.g.
Duration::from_secs(10)) and an overall request timeout (e.g.
Duration::from_secs(30)) so the POST/json/send calls fail fast on slow
endpoints; update the client variable creation in fetch_live_entities_as_sdl,
add the required std::time::Duration import, and preserve existing error
propagation (the ? chains) so behavior otherwise remains the same.
- Around line 155-199: The current check returns Ok(0) when the source SDL has
zero `@entity` types, letting an empty/misconfigured source pass; update the logic
in schema_check.rs after computing source_entities (from entities(&source_doc))
to treat an empty source_entities as an error: if source_entities.is_empty()
return Err with a clear error message (e.g., "source schema contains no `@entity`
types") instead of Ok(source_entities.len()); keep the rest of the comparison
logic (consumer_objects, field/type checks) unchanged and update the tests that
expected an empty source to succeed.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: 7789b3b7-1e8f-4b8c-9695-5abb254a7a69
📒 Files selected for processing (3)
crates/cli/src/cli/mod.rscrates/cli/src/cli/schema_check.rscrates/cli/src/main.rs
Apply the introspection-derived entity SDL surfaced by the deploy schema-check: - MetaV1: add `transaction: Transaction!`, change `subject` BigInt!→Bytes! - New `Transaction` entity type These are exactly the 3 mismatches the live-URL schema-check reported against the just-deployed subgraph. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@crates/metaboard/src/schema/metaboard.graphql`:
- Around line 83-96: Update the Rust MetaV1 struct in
crates/metaboard/src/types/metas.rs to match the GraphQL schema: add a
transaction field of type Transaction to MetaV1 and change the subject field's
type from BigInt to the Bytes equivalent used in the codebase (the same Rust
type used for other Bytes fields), preserving field names and visibility; ensure
the Transaction Rust type referenced matches the existing Transaction definition
used elsewhere.
- Line 86: The GraphQL schema's MetaV1.subject is Bytes! but the MetaV1_filter
fields for subject (e.g., MetaV1_filter.subject_eq, MetaV1_filter.subject_in,
MetaV1_filter.subject_not, MetaV1_filter.subject_not_in) are still typed as
BigInt; update each of these subject* filter field types to use Bytes (and list
variants to use [Bytes] where appropriate, preserving nullability) so the filter
types align with MetaV1.subject.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: 8c5a998f-de9a-4550-9421-c7eee93a56e0
📒 Files selected for processing (1)
crates/metaboard/src/schema/metaboard.graphql
The live deployed subgraph indexes MetaV1_2 events with `bytes32 subject`,
so the introspection-derived schema has `MetaV1.subject: Bytes!`. The
consumer crate's Cynic types and GraphQL filter inputs were still on
BigInt — surfacing during deploy CI's schema-check.
Changes:
- crates/metaboard/src/schema/metaboard.graphql: MetaV1_filter.subject_*
fields BigInt -> Bytes; added subject_contains/_not_contains for parity
with introspection-generated Bytes filters.
- crates/metaboard/src/types/metas.rs: MetasBySubjectVariables.subject
and MetaV1.subject now Bytes.
- crates/metaboard/src/metaboard_client.rs: get_metabytes_by_subject
takes &Bytes; tests updated to construct Bytes("0x...").
- crates/cli/src/meta/types/dotrain/source_v1.rs: hex-encode the [u8; 32]
subject with a 0x prefix and pass as Bytes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
- check(): fail when source has no @entity types instead of returning Ok(0). Silently passing on empty source would mask a misconfigured --source path or non-subgraph SDL in CI. - fetch_live_entities_as_sdl: bound the introspection HTTP call with 10s connect timeout + 30s overall timeout so a hung Goldsky endpoint can't wedge the deploy job indefinitely. - Cargo.toml: feature-gate graphql-parser under `cli` like the other CLI-only deps so non-CLI builds (e.g. WASM) don't pull it in. Skipped: - Pre-deploy schema-check using --source: this is the design we explicitly chose against. Live introspection of the deployed URL is required to catch divergence between what's deployed and what consumers expect; a --source check only validates two local files. - Pre-computing consumer_fields per entity: noted by CR as low-pri, schemas are small. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Addressed in 2688cdf:
Skipping the workflow reorder (pre-deploy |
Direct tests for every previously-untested function: - entities: only @entity-tagged Object types are returned, regardless of directive arguments, ignoring scalars/enums/input/etc. - all_objects: returns every Object type keyed by name, ignoring non-Object definitions. - type_equal: named vs named, wrapper distinctions (Named/NonNull/List), recursive nested wrappers. - type_to_string: SDL-syntax rendering for Named, NonNull, List, and nested combinations. - is_entity_object: skips _Meta_, Query, Subscription, *_filter, *_orderBy, and empty names. - render_type: recursive unwrap of NON_NULL/LIST around SCALAR, plain SCALAR, fallback for missing name. - fetch_live_entities_as_sdl (httpmock): entity filter applied, GraphQL errors propagated, malformed `data` rejected, HTTP failure surfaced. - schema_check end-to-end (tempfile + httpmock): success path with file I/O, mutually-exclusive-args validation, failure path including the live-derived SDL printout. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The BigInt -> Bytes migration of MetaV1.subject required two wire
changes that were not directly tested:
1. DotrainSourceV1::fetch_by_subject must emit `0x`-prefixed hex when
converting the [u8; 32] subject into a Bytes scalar. Adds a focused
httpmock test that body_contains() the exact `0x4242...` literal.
2. MetaboardSubgraphClient::get_metabytes_by_subject must pass the
Bytes value through verbatim. Strengthens the existing success test
to body_contains("0x7b") so a future change that strips/reformats
the prefix is caught.
Both tests mutation-verified: temporarily reverting the producing code
makes them fail; reverting the test mutation restores green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`crates/cli` is built for wasm32-unknown-unknown by CI's "Build for wasm target" step, and reqwest's wasm impl uses the browser fetch API which doesn't expose ClientBuilder timing methods. The CI failure surfaced as `error[E0599]: no method named connect_timeout`. Native builds keep the 10s connect / 30s overall timeouts; wasm builds fall back to plain reqwest::Client::new(). The CLI binary never actually runs under wasm, so the lack of timeout there is a compile-only concession. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
Replaces all_objects() + per-iteration BTreeMap rebuild with build_field_index(), which constructs the `name -> (field-name -> field)` lookup once for every Object type in the consumer document before the source-entity loop runs. The previous shape did `O(s)` BTreeMap allocations (one per source entity that matched a consumer Object); the new shape does `O(c)` allocations once (one per consumer Object) and pure lookups during the loop. Schema sizes are tiny so the runtime difference is invisible, but the new structure makes the cost explicit and removes the rebuild. Test renamed accordingly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
@coderabbitai assess this PR size classification for the totality of the PR with the following criterias and report it in your comment: S/M/L PR Classification Guidelines:This guide helps classify merged pull requests by effort and complexity rather than just line count. The goal is to assess the difficulty and scope of changes after they have been completed. Small (S)Characteristics:
Review Effort: Would have taken 5-10 minutes Examples:
Medium (M)Characteristics:
Review Effort: Would have taken 15-30 minutes Examples:
Large (L)Characteristics:
Review Effort: Would have taken 45+ minutes Examples:
Additional Factors to ConsiderWhen deciding between sizes, also consider:
Notes:
|
|
Tip For best results, initiate chat on the files or code changes. SIZE=L |
Summary
Adds `rain-metadata schema-check` subcommand that compares the source subgraph schema (`subgraph/schema.graphql`) to the consumer crate's introspection snapshot (`crates/metaboard/src/schema/metaboard.graphql`) at the entity level: missing entities, missing fields, field-type mismatches. Exits non-zero on drift.
Wired in as a pre-deploy step in `manual-subgraph-deploy.yml`. If drift is detected, the workflow fails before `subgraph-deploy` runs — no Goldsky deploys, nothing to roll back.
Demonstration
Run on this branch surfaces real drift between the deployed subgraph and the metaboard crate's snapshot:
```
schema check failed with 3 mismatches:
```
Smoke-tested end-to-end: triggered `Subgraph manual deploy` on this branch (run 25311954053). Schema-check step failed; `subgraph-deploy` step was skipped. Zero Goldsky deploys.
Closes
This PR adds the gate. It will block all further deploys until `crates/metaboard/src/schema/metaboard.graphql` is regenerated to match the live subgraph. Closes #58 on merge + snapshot regeneration.
Test plan
🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Schema
Tests
Chores