Skip to content

Seanaye/feat/optimistic links - #5502

Merged
seanaye merged 10 commits into
mainfrom
seanaye/feat/optimistic-links
Aug 10, 2026
Merged

Seanaye/feat/optimistic links#5502
seanaye merged 10 commits into
mainfrom
seanaye/feat/optimistic-links

Conversation

@seanaye

@seanaye seanaye commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

This PR adds a new cache core message which allows upserting links (edges) between entities for optimitic update purposes. This allows callers to correct optimitically add new links, changing the relationship between nodes before the server response returns.

Initially this is used in group soup to re-create bins which might no longer exist, then place objects into those bins

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6abd1c4e-d4a6-4d99-930d-9cba8036ac24

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Added optimistic updates for removing and adding links within embedded lists.
    • Embedded items can now be created automatically when missing, with link counts initialized or updated.
    • Grouped-item updates now create missing destination bins on initial pages.
  • Bug Fixes

    • Improved link deduplication, count handling, rollback, persistence, and commit behavior.
    • Added validation for invalid counts, conflicting fields, and excessive insertion data.

Walkthrough

The GraphQL cache adds typed removeEmbeddedLink and upsertEmbeddedLink operations. The Rust cache engine validates and applies embedded-list mutations, maintains counts, and creates missing items from scalar fields. Grouped optimistic updates now move links between bins and create missing destination bins on initial pages. Tests cover serialized operations, count changes, persistence, rollback, settlement, deduplication, and missing-bin creation.

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Title check ⚠️ Warning The title describes optimistic links but does not follow the required Conventional Commits format. Rename the title with a valid prefix, such as "feat: add optimistic embedded link updates".
✅ Passed checks (3 passed)
Check name Status Explanation
Description check ✅ Passed The description explains the optimistic link upsert feature and its use for recreating missing bins.
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.

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.

❤️ Share

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

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

@seanaye
seanaye force-pushed the seanaye/feat/optimistic-links branch from 56e123f to 719fcd4 Compare August 7, 2026 17:39
@seanaye
seanaye force-pushed the seanaye/feat/combine-mutation-dispatch branch from e9a1f37 to 318d475 Compare August 7, 2026 17:39
@seanaye
seanaye force-pushed the seanaye/feat/optimistic-links branch from 719fcd4 to d913c1d Compare August 7, 2026 18:12
@seanaye
seanaye force-pushed the seanaye/feat/combine-mutation-dispatch branch from 318d475 to e9725b5 Compare August 7, 2026 18:12
@seanaye
seanaye force-pushed the seanaye/feat/optimistic-links branch from d913c1d to 7286798 Compare August 7, 2026 18:33
Base automatically changed from seanaye/feat/combine-mutation-dispatch to main August 7, 2026 19:12
@seanaye
seanaye force-pushed the seanaye/feat/optimistic-links branch from 7286798 to 54e30a8 Compare August 7, 2026 19:13

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (5)
apps/web/src/lib/graphql-cache/exchange/optimistic.ts (2)

42-47: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Consider excluding managed fields from ScalarInsertFields.

ScalarInsertFields<T> permits every scalar key of TItem, including the selector field and the count field. The Rust engine rejects such payloads at runtime with ConflictingInsertField (see crates/client/cache-core/src/link_patch.rs lines 273-277). You can move that check to compile time by excluding the managed keys.

The selector and count keys are generic parameters of upsertEmbeddedLink, so ScalarInsertFields can accept them:

type ScalarInsertFields<T, TExclude extends keyof T = never> = Partial<{
  [K in Exclude<ScalarKey<T>, TExclude>]: Exclude<T[K], undefined>;
}>;

Then use ScalarInsertFields<TItem, TSelectorField | TCountField | TLinkField> in the upsertEmbeddedLink args.

🤖 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 `@apps/web/src/lib/graphql-cache/exchange/optimistic.ts` around lines 42 - 47,
Update ScalarInsertFields to accept an exclusion-key generic and omit those keys
from its mapped scalar fields. In upsertEmbeddedLink, instantiate it with
TSelectorField, TCountField, and TLinkField so managed fields cannot be supplied
in insert payloads while other scalar fields remain supported.

275-287: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Avoid as unknown as OptimisticUpdate in the two new builders.

The sibling update builder at lines 242-251 uses a single as OptimisticUpdate cast. The new builders use a double cast, which removes all structural checking against OptimisticLinkPatchWire. A field name typo or a shape drift in protocol.ts will then compile without error.

Bind the payload to OptimisticLinkPatchWire first, so the compiler validates the wire shape, and cast only the branded phantom property.

♻️ Proposed change for `removeEmbeddedLink` (apply the same pattern to `upsertEmbeddedLink`)
-  return {
+  const wire: OptimisticLinkPatchWire = {
     query: stringifyDocument(selection.document),
     operationName: documentOperationName(selection.document),
     variablesJson: JSON.stringify(selection.variables ?? {}),
     path: [...selection.path],
     operation: {
       kind: 'removeEmbeddedLink',
       listItem: args.listItem,
       linkField: args.linkField,
       countField: args.countField,
       entityKey: normalizedEntityKey(args.entity),
     },
-  } as unknown as OptimisticUpdate;
+  };
+  return wire as OptimisticUpdate;

Note: args.listItem.equals has type Present<TItem[TSelectorField]>, and insertFields has type ScalarInsertFields<TItem>. If either does not satisfy the wire types, the assignment will fail. That failure is the signal that the wire type must widen, not that the cast must stay.

🤖 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 `@apps/web/src/lib/graphql-cache/exchange/optimistic.ts` around lines 275 -
287, Update the new removeEmbeddedLink and upsertEmbeddedLink builders to
construct their operation payload as an OptimisticLinkPatchWire value before
returning it, so the compiler validates all wire fields and types. Replace each
as unknown as OptimisticUpdate double cast with only the narrow cast needed for
the branded phantom property, following the existing update builder pattern and
preserving the current payload fields.
crates/client/cache-core/src/link_patch.rs (2)

1010-1010: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

Tests belong in a separate test.rs file.

The new embedded-link tests were added to the inline mod tests block in this file. The repository convention places tests in a separate test.rs file inside the same module directory, and the implementation file declares #[cfg(test)] mod test;.

The inline block is pre-existing, so this is a migration rather than a fix for the new tests alone. Treat it as optional in this PR.

As per coding guidelines: "Place tests in a separate test.rs file within the same module directory; implementation files should declare the test submodule with #[cfg(test)] mod test;".

🤖 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/client/cache-core/src/link_patch.rs` at line 1010, Optionally migrate
the existing inline `mod tests` in the link-patch implementation into a sibling
`test.rs` module, moving all test code there and declaring it with `#[cfg(test)]
mod test;` in the implementation file. Keep test behavior unchanged and include
the new embedded-link tests in the external module.

Source: Coding guidelines


886-895: 🗄️ Data Integrity & Integration | 🔵 Trivial | 💤 Low value

Detect duplicate storage keys when resolving insert fields.

resolved_insert_fields collects into a HashMap keyed by the resolved storage key. Two distinct response keys can resolve to the same storage key when the query aliases the same field. collect::<Result<HashMap, _>> keeps the last entry and drops the earlier one silently.

The surrounding code validates every other field conflict explicitly. Add the same rigor here.

♻️ Proposed change to reject duplicate resolved keys
-    let resolved_insert_fields = insert_fields
-        .into_iter()
-        .flat_map(|fields| fields.iter())
-        .map(|(field, value)| {
-            Ok((
-                selected_storage_key(selected_field(selections, concrete, field)?, variables)?,
-                value.clone(),
-            ))
-        })
-        .collect::<Result<HashMap<_, _>, LinkPatchError>>()?;
+    let mut resolved_insert_fields: HashMap<FieldKey, Json> = HashMap::new();
+    for (field, value) in insert_fields.into_iter().flat_map(|fields| fields.iter()) {
+        let key = selected_storage_key(selected_field(selections, concrete, field)?, variables)?;
+        if resolved_insert_fields.insert(key.clone(), value.clone()).is_some() {
+            return Err(LinkPatchError::ConflictingInsertField(key));
+        }
+    }
🤖 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/client/cache-core/src/link_patch.rs` around lines 886 - 895, Update
the resolved insert-field collection in the surrounding link-patch function to
detect duplicate storage keys instead of silently overwriting earlier values.
When two response fields resolve to the same key, return the existing
LinkPatchError conflict used by nearby field-validation logic; retain successful
collection for unique keys.
apps/web/src/lib/graphql-cache/exchange/optimistic.test.ts (1)

119-126: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a negative type assertion for linkField.

The block asserts only that a non-numeric field is rejected as countField. NormalizedEntityListKey is a new constraint and has no coverage. Add a case that passes a non-entity-list field, such as 'key', as linkField. That case locks the new constraint against regressions.

🤖 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 `@apps/web/src/lib/graphql-cache/exchange/optimistic.test.ts` around lines 119
- 126, Add a separate negative type assertion in the optimistic test around
upsertEmbeddedLink, passing the non-list field 'key' as linkField while keeping
the existing invalid countField assertion. Ensure the added case uses the
expected TypeScript error annotation to verify NormalizedEntityListKey rejects
non-entity-list fields.
🤖 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/client/cache-core/src/link_patch.rs`:
- Around line 295-300: Add a distinct managed-field conflict error variant, such
as ConflictingManagedField { first: String, second: String }, and update
validate_embedded_link_fields at
crates/client/cache-core/src/link_patch.rs#L295-L300 to return it with the
actual colliding fields: where_field/link_field for the first check and the
matching pair for the second. Also update
crates/client/cache-core/src/link_patch.rs#L902-L907 to return the new variant
using the two resolved storage keys that collide instead of
resolved_count_field.

---

Nitpick comments:
In `@apps/web/src/lib/graphql-cache/exchange/optimistic.test.ts`:
- Around line 119-126: Add a separate negative type assertion in the optimistic
test around upsertEmbeddedLink, passing the non-list field 'key' as linkField
while keeping the existing invalid countField assertion. Ensure the added case
uses the expected TypeScript error annotation to verify NormalizedEntityListKey
rejects non-entity-list fields.

In `@apps/web/src/lib/graphql-cache/exchange/optimistic.ts`:
- Around line 42-47: Update ScalarInsertFields to accept an exclusion-key
generic and omit those keys from its mapped scalar fields. In
upsertEmbeddedLink, instantiate it with TSelectorField, TCountField, and
TLinkField so managed fields cannot be supplied in insert payloads while other
scalar fields remain supported.
- Around line 275-287: Update the new removeEmbeddedLink and upsertEmbeddedLink
builders to construct their operation payload as an OptimisticLinkPatchWire
value before returning it, so the compiler validates all wire fields and types.
Replace each as unknown as OptimisticUpdate double cast with only the narrow
cast needed for the branded phantom property, following the existing update
builder pattern and preserving the current payload fields.

In `@crates/client/cache-core/src/link_patch.rs`:
- Line 1010: Optionally migrate the existing inline `mod tests` in the
link-patch implementation into a sibling `test.rs` module, moving all test code
there and declaring it with `#[cfg(test)] mod test;` in the implementation file.
Keep test behavior unchanged and include the new embedded-link tests in the
external module.
- Around line 886-895: Update the resolved insert-field collection in the
surrounding link-patch function to detect duplicate storage keys instead of
silently overwriting earlier values. When two response fields resolve to the
same key, return the existing LinkPatchError conflict used by nearby
field-validation logic; retain successful collection for unique keys.
🪄 Autofix

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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 617881b5-0816-4515-ab3c-ce8169ef04ea

📥 Commits

Reviewing files that changed from the base of the PR and between 8480d35 and 54e30a8.

📒 Files selected for processing (8)
  • apps/web/src/lib/graphql-cache/exchange/optimistic.test.ts
  • apps/web/src/lib/graphql-cache/exchange/optimistic.ts
  • apps/web/src/lib/graphql-cache/index.ts
  • apps/web/src/lib/graphql-cache/protocol.ts
  • apps/web/src/lib/queries/soup/grouped/graphql-optimistic.test.ts
  • apps/web/src/lib/queries/soup/grouped/graphql-optimistic.ts
  • crates/client/cache-core/src/link_patch.rs
  • crates/client/cache-core/tests/grouped_optimistic.rs

Comment on lines +295 to +300
if list_item.where_field == *link_field {
return Err(LinkPatchError::ConflictingInsertField(link_field.clone()));
}
if list_item.where_field == *count_field || link_field == count_field {
return Err(LinkPatchError::ConflictingInsertField(count_field.clone()));
}

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

ConflictingInsertField reports the wrong conflict class and the wrong field. Both sites reuse one error variant for conflicts that do not involve an insert field. A caller that sets link_field equal to the selector receives "embedded insert field items conflicts with a managed field", which points at the wrong input. The second site also always names the count field, so a selector-versus-link conflict is reported as a count-field conflict. Add a distinct variant, such as ConflictingManagedField { first: String, second: String }, and name the two fields that actually collide.

  • crates/client/cache-core/src/link_patch.rs#L295-L300: return the new variant from validate_embedded_link_fields, naming where_field and link_field for the first check and the colliding pair for the second check.
  • crates/client/cache-core/src/link_patch.rs#L902-L907: return the new variant and name the two resolved storage keys that match, instead of always returning resolved_count_field.
📍 Affects 1 file
  • crates/client/cache-core/src/link_patch.rs#L295-L300 (this comment)
  • crates/client/cache-core/src/link_patch.rs#L902-L907
🤖 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/client/cache-core/src/link_patch.rs` around lines 295 - 300, Add a
distinct managed-field conflict error variant, such as ConflictingManagedField {
first: String, second: String }, and update validate_embedded_link_fields at
crates/client/cache-core/src/link_patch.rs#L295-L300 to return it with the
actual colliding fields: where_field/link_field for the first check and the
matching pair for the second. Also update
crates/client/cache-core/src/link_patch.rs#L902-L907 to return the new variant
using the two resolved storage keys that collide instead of
resolved_count_field.

@seanaye
seanaye force-pushed the seanaye/feat/optimistic-links branch 2 times, most recently from b500f20 to ee57f1d Compare August 7, 2026 20:38
@seanaye
seanaye force-pushed the seanaye/feat/optimistic-links branch from ee57f1d to 56a1740 Compare August 10, 2026 13:45
@seanaye
seanaye merged commit 6b0779e into main Aug 10, 2026
28 checks passed
@seanaye
seanaye deleted the seanaye/feat/optimistic-links branch August 10, 2026 13:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant