Skip to content

feat: Make snapshots optional with cursor-based filtering (HYP-148)#70

Merged
adiman9 merged 21 commits intomainfrom
skip-snapshot
Mar 21, 2026
Merged

feat: Make snapshots optional with cursor-based filtering (HYP-148)#70
adiman9 merged 21 commits intomainfrom
skip-snapshot

Conversation

@adiman9
Copy link
Contributor

@adiman9 adiman9 commented Mar 21, 2026

Summary

Implements HYP-148: Make snapshots optional with cursor-based filtering for WebSocket connections.

Changes

Server (rust/hyperstack-server)

  • Extended Subscription struct with:
    • with_snapshot: Option<bool> - Skip initial snapshot when false (defaults to true for backward compatibility)
    • after: Option<String> - Cursor to resume from specific _seq point
    • snapshot_limit: Option<usize> - Limit number of entities in snapshot
  • Added seq: Option<String> field to Frame struct for live update cursor tracking
  • Implemented EntityCache::get_after() method for efficient cursor-based filtering
  • Updated WebSocket server to respect with_snapshot flag across all modes (State, List, Append)

Rust SDK (rust/hyperstack-sdk)

  • Added builder methods to Subscription:
    • .with_snapshot(bool) - Control snapshot inclusion
    • .after(str) - Set resume cursor
    • .with_snapshot_limit(usize) - Set snapshot limit
  • Added seq field to Frame struct
  • Added builder methods to view builders (UseBuilder, WatchBuilder, RichWatchBuilder)
  • Updated connection and streaming APIs

TypeScript/React SDKs

  • Added to Subscription and WatchOptions:
    • withSnapshot?: boolean
    • after?: string
    • snapshotLimit?: number
  • Added seq?: string to EntityFrame
  • Updated React hook types (ViewHookOptions, ListParamsBase)
  • Updated useListView and useStateView hooks to pass new fields through

Usage Examples

Rust SDK

Using Subscription directly (for advanced use cases):

// Skip snapshot - live updates only
let sub = Subscription::new("Game/list").with_snapshot(false);

// Resume from specific point
let sub = Subscription::new("Game/list")
    .after("123456789:000000000042")
    .with_snapshot_limit(100);

Using view builders (standard API):

// Skip snapshot
views.list().listen().with_snapshot(false);

// Resume from cursor with limit
views.list()
    .listen()
    .after("123456789:000000000042")
    .with_snapshot_limit(100);

TypeScript/React

Standard view API:

// Skip snapshot
view.watch({ withSnapshot: false });

// Resume from cursor
view.watch({ 
  after: "123456789:000000000042", 
  snapshotLimit: 100 
});

React hooks:

// Skip snapshot
.use({ withSnapshot: false });

// Resume from cursor with limit
.use({ 
  after: "123456789:000000000042",
  snapshotLimit: 100 
});

Backward Compatibility

Fully backward compatible

  • with_snapshot defaults to true, existing clients receive snapshots as before
  • New fields are optional in all SDKs
  • No breaking changes to existing APIs

adiman9 added 3 commits March 21, 2026 03:19
…Socket protocol

Implement HYP-148 to make snapshots optional and support cursor-based filtering:

- Add with_snapshot, after, and snapshot_limit fields to Subscription struct
- Add seq field to Frame for live update cursor tracking
- Implement EntityCache::get_after() for filtering entities by cursor
- Update WebSocket server to respect with_snapshot flag
- Update projector to include seq in Frame from SlotContext

Clients can now:
- Skip initial snapshot with with_snapshot: false
- Resume from specific point with after cursor (_seq value)
- Limit snapshot size with snapshot_limit
- Track position via seq field in live updates
Update Rust SDK for HYP-148 protocol changes:

- Add with_snapshot, after, and snapshot_limit fields to Subscription
- Add builder methods: with_snapshot(), after(), with_snapshot_limit()
- Add seq field to Frame struct for cursor tracking
- Update ConnectionManager to send new subscription fields
- Maintain backward compatibility with defaults

Usage:
  let sub = Subscription::new("Game/list")
      .with_snapshot(false)
      .after("123:000000000042")
      .with_snapshot_limit(100);
Update TypeScript and React SDKs for HYP-148 protocol changes:

- Add withSnapshot, after, and snapshotLimit to Subscription and WatchOptions
- Add seq field to EntityFrame for cursor tracking
- Add new fields to ViewHookOptions and ListParamsBase for React hooks
- Connection automatically includes new fields via spread operator

Usage:
  // Skip snapshot
  view.watch({ withSnapshot: false })

  // Resume from cursor
  view.watch({ after: "123:000000000042", snapshotLimit: 100 })

  // React hooks
  useListView(Game.list, { after: lastSeq, withSnapshot: false })
@vercel
Copy link

vercel bot commented Mar 21, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
hyperstack-docs Ready Ready Preview, Comment Mar 21, 2026 0:33am

Request Review

…ptions

Update SDKs to fully expose HYP-148 subscription options:

Rust SDK:
- Add with_snapshot, after, and snapshot_limit fields to UseBuilder
- Add builder methods: with_snapshot(), after(), with_snapshot_limit()
- Similar updates to WatchBuilder and RichWatchBuilder

React SDK:
- Update useListView to pass withSnapshot, after, snapshotLimit through
- Update useStateView to pass withSnapshot, after, snapshotLimit through
- Ensure all subscription options are included in useEffect and refresh dependencies
Ensure with_snapshot, after, and snapshot_limit are properly passed from builders through to actual WebSocket subscriptions:

- Update UseStream, EntityStream, and RichEntityStream to accept and store new fields
- Update all builder poll_next implementations to pass new fields when creating streams
- Update connection.ensure_subscription_with_opts calls to use actual values instead of None
- Ensure UseBuilder, WatchBuilder, and RichWatchBuilder all properly wire up the fields
@greptile-apps
Copy link

greptile-apps bot commented Mar 21, 2026

Greptile Summary

This PR adds cursor-based filtering and optional snapshot delivery to the WebSocket subscription system across the Rust server, Rust SDK, and TypeScript/React SDKs. The implementation is generally well-structured: the server-side Subscription struct and the SDK's Subscription struct both gain #[serde(rename_all = "camelCase")], resolving the previously flagged TypeScript ↔ Rust serialization mismatch. The new get_after() cache method is clean and well-tested, and backward compatibility is preserved throughout.

Key issues found:

  • Snapshot ordering inconsistency (P1): When snapshot_limit is set without a cursor, entities are sorted descending (newest-first) to select the most-recent N, but are never re-sorted ascending before delivery. The cursor path (get_after()) returns ascending order. This means Mode::Append (ordered event-log) clients receive snapshot entities in the opposite order depending on whether after is provided. Both otel and non-otel Mode::List | Mode::Append paths are affected (server.rs lines ~926–945 and ~1095–1114).

  • after + with_snapshot: false silently no-ops (P2): If a client sets both after: "cursor" and with_snapshot: false, the cursor is silently discarded — no snapshot is sent and live updates are not filtered by cursor. This is a footgun for users expecting a "resume from cursor without snapshot" semantic. A server-side warning log would help surface the misuse.

  • Previously flagged issues resolved: The snapshot_limit-before-matches_key filter issue, non-otel snapshot_limit gap, get_all non-determinism, and TypeScript camelCase mismatch have all been addressed in this PR.

Confidence Score: 3/5

  • Safe to merge for basic withSnapshot: false use cases, but the snapshot ordering inconsistency should be fixed before snapshot_limit is used in production with Mode::Append views.
  • Core functionality (skipping snapshots, cursor-based filtering, seq field propagation) works correctly and is well-tested. The snapshot_limit-without-cursor path delivers entities in descending order while the cursor path delivers in ascending order — this inconsistency in Mode::Append will produce misordered snapshots for clients using snapshot_limit as a "last N events" window. The fix is a two-line re-sort after truncation. The after+with_snapshot:false footgun is a usability concern but not a correctness bug.
  • Both Mode::List | Mode::Append paths in rust/hyperstack-server/src/websocket/server.rs (otel path ~line 926 and non-otel path ~line 1095) need a re-sort-ascending after the descending truncation.

Important Files Changed

Filename Overview
rust/hyperstack-server/src/websocket/server.rs Implements with_snapshot and cursor filtering for both otel and non-otel code paths. Contains a snapshot ordering inconsistency: when snapshot_limit is used without after, entities are delivered to clients in descending _seq order instead of the ascending order produced by get_after() — this affects both List and Append modes in both code paths.
rust/hyperstack-server/src/cache.rs Adds get_after() method with cmp_seq() helper for numerically-correct _seq comparison. get_after() correctly filters, sorts ascending, and truncates. Well-tested with four new unit tests covering the main cases (cursor filtering, limit, empty result, missing _seq).
rust/hyperstack-server/src/websocket/subscription.rs Adds with_snapshot, after, and snapshot_limit fields to the server's Subscription struct with #[serde(rename_all = "camelCase")]. New deserialization tests confirm TypeScript camelCase field names are now handled correctly. Backward compatible since with_snapshot defaults to true when absent.
rust/hyperstack-sdk/src/subscription.rs Adds #[serde(rename_all = "camelCase")] (consistent with the server) and new builder methods (with_snapshot, after, with_snapshot_limit). All existing single-word fields (view, key, take, skip, etc.) are unaffected by camelCase renaming.
typescript/react/src/view-hooks.ts Updates useStateView and useListView to pass new options to the subscription registry and correctly skips setting isLoading = true when withSnapshot: false. New options are added to useEffect and useCallback dependency arrays. Initial useState also correctly avoids starting in loading state when withSnapshot: false.
rust/hyperstack-sdk/src/connection.rs Introduces SubscriptionOptions struct to consolidate per-subscription parameters. ensure_subscription_with_opts signature cleaned up from individual positional args to a single options struct. Backward-compatible via SubscriptionOptions::default() in the no-opts path.

Sequence Diagram

sequenceDiagram
    participant C as Client (TS/Rust SDK)
    participant WS as WebSocket Server
    participant Cache as EntityCache
    participant Bus as Bus Manager

    C->>WS: subscribe { view, withSnapshot, after, snapshotLimit }

    alt withSnapshot === false
        WS->>Bus: get_or_create_bus()
        WS->>Bus: rx.borrow_and_update() (mark seen)
        Note over WS: No snapshot sent
    else withSnapshot === true (default)
        WS->>Bus: get_or_create_bus()

        alt after cursor provided
            WS->>Cache: get_after(view, cursor, snapshotLimit)
            Cache-->>WS: Vec<(key, entity)> sorted ascending by _seq
        else no cursor
            WS->>Cache: get_all(view)
            Cache-->>WS: Vec<(key, entity)> (unordered)
            alt snapshotLimit set
                Note over WS: sort descending, truncate to limit ⚠️ stays descending
            end
        end

        WS->>WS: filter by matches_key
        WS->>C: SnapshotFrame (batched)
        WS->>Bus: rx.borrow_and_update()
    end

    loop Live Updates
        Bus-->>WS: entity update (with seq from slot_context)
        WS->>C: Frame { op, key, data, seq }
    end
Loading

Comments Outside Diff (3)

  1. rust/hyperstack-server/src/websocket/server.rs, line 926-945 (link)

    Snapshot delivered in descending order when snapshot_limit set without cursor

    After sorting descending and truncating to get the most-recent N entities, the code sends them to the client in that reversed order — no re-sort to ascending is performed before building snapshot_entities. By contrast, get_after() (the cursor path) always returns entities in ascending _seq order.

    This means clients receive:

    • No cursor + limit: newest-first (descending)
    • Cursor + optional limit: oldest-first (ascending)

    For Mode::Append (ordered event logs) this is especially problematic — a client gets the last N events in reverse chronological order as the snapshot, but then live updates continue to arrive in ascending order. The client has to detect and compensate for this inconsistency.

    The same issue exists in the non-otel code path at ~line 1095–1113.

    Fix: re-sort ascending after truncating:

    snapshots.sort_by(|a, b| {
        let sa = a.1.get("_seq").and_then(|s| s.as_str()).unwrap_or("");
        let sb = b.1.get("_seq").and_then(|s| s.as_str()).unwrap_or("");
        cmp_seq(sb, sa) // descending: select most-recent N
    });
    snapshots.truncate(limit);
    // Re-sort ascending so clients receive oldest-first, consistent with get_after()
    snapshots.sort_by(|a, b| {
        let sa = a.1.get("_seq").and_then(|s| s.as_str()).unwrap_or("");
        let sb = b.1.get("_seq").and_then(|s| s.as_str()).unwrap_or("");
        cmp_seq(sa, sb)
    });

    The duplicate code path at line 1095 (non-otel Mode::List | Mode::Append) needs the same fix.

  2. rust/hyperstack-server/src/websocket/server.rs, line 1095-1114 (link)

    Same descending-order inconsistency in non-otel Mode::List | Mode::Append path

    This is the exact same ordering bug as in the otel path at line 926–945: entities are sorted descending and truncated to get the most-recent N, but are never re-sorted ascending before being sent as snapshot batches. The client receives newest-first here but oldest-first when a cursor (after) is used.

    snapshots.sort_by(|a, b| {
        let sa = a.1.get("_seq").and_then(|s| s.as_str()).unwrap_or("");
        let sb = b.1.get("_seq").and_then(|s| s.as_str()).unwrap_or("");
        cmp_seq(sb, sa) // descending: select most-recent N
    });
    snapshots.truncate(limit);
    // Re-sort ascending for consistent oldest-first delivery
    snapshots.sort_by(|a, b| {
        let sa = a.1.get("_seq").and_then(|s| s.as_str()).unwrap_or("");
        let sb = b.1.get("_seq").and_then(|s| s.as_str()).unwrap_or("");
        cmp_seq(sa, sb)
    });
  3. rust/hyperstack-server/src/websocket/subscription.rs, line 14-28 (link)

    after + with_snapshot: false silently produces a misleading combination

    If a client sets after: "some_cursor" along with with_snapshot: false, the after cursor is completely ignored — no snapshot is sent and there is no filtering of live updates by cursor. A developer reasonably expecting "resume from cursor, skip the snapshot" would receive all live updates from the moment of subscription, silently losing any events between their last-seen cursor and the current time.

    Consider logging a warning (or documenting clearly) that after has no effect when with_snapshot is false, to help users avoid this footgun:

    if subscription.after.is_some() && !should_send_snapshot {
        warn!(
            "Client {} subscribed to {} with `after` cursor but `with_snapshot: false`; \
             cursor is ignored — missed events will not be recovered",
            ctx.client_id, view_id
        );
    }

Fix All in Claude Code

Prompt To Fix All With AI
This is a comment left during a code review.
Path: rust/hyperstack-server/src/websocket/server.rs
Line: 926-945

Comment:
**Snapshot delivered in descending order when `snapshot_limit` set without cursor**

After sorting descending and truncating to get the most-recent N entities, the code sends them to the client in that reversed order — no re-sort to ascending is performed before building `snapshot_entities`. By contrast, `get_after()` (the cursor path) always returns entities in **ascending** `_seq` order.

This means clients receive:
- No cursor + limit: newest-first (descending)
- Cursor + optional limit: oldest-first (ascending)

For `Mode::Append` (ordered event logs) this is especially problematic — a client gets the last N events in reverse chronological order as the snapshot, but then live updates continue to arrive in ascending order. The client has to detect and compensate for this inconsistency.

The same issue exists in the non-otel code path at ~line 1095–1113.

Fix: re-sort ascending after truncating:

```rust
snapshots.sort_by(|a, b| {
    let sa = a.1.get("_seq").and_then(|s| s.as_str()).unwrap_or("");
    let sb = b.1.get("_seq").and_then(|s| s.as_str()).unwrap_or("");
    cmp_seq(sb, sa) // descending: select most-recent N
});
snapshots.truncate(limit);
// Re-sort ascending so clients receive oldest-first, consistent with get_after()
snapshots.sort_by(|a, b| {
    let sa = a.1.get("_seq").and_then(|s| s.as_str()).unwrap_or("");
    let sb = b.1.get("_seq").and_then(|s| s.as_str()).unwrap_or("");
    cmp_seq(sa, sb)
});
```

The duplicate code path at line 1095 (non-otel `Mode::List | Mode::Append`) needs the same fix.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: rust/hyperstack-server/src/websocket/server.rs
Line: 1095-1114

Comment:
**Same descending-order inconsistency in non-otel `Mode::List | Mode::Append` path**

This is the exact same ordering bug as in the otel path at line 926–945: entities are sorted descending and truncated to get the most-recent N, but are never re-sorted ascending before being sent as snapshot batches. The client receives newest-first here but oldest-first when a cursor (`after`) is used.

```rust
snapshots.sort_by(|a, b| {
    let sa = a.1.get("_seq").and_then(|s| s.as_str()).unwrap_or("");
    let sb = b.1.get("_seq").and_then(|s| s.as_str()).unwrap_or("");
    cmp_seq(sb, sa) // descending: select most-recent N
});
snapshots.truncate(limit);
// Re-sort ascending for consistent oldest-first delivery
snapshots.sort_by(|a, b| {
    let sa = a.1.get("_seq").and_then(|s| s.as_str()).unwrap_or("");
    let sb = b.1.get("_seq").and_then(|s| s.as_str()).unwrap_or("");
    cmp_seq(sa, sb)
});
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: rust/hyperstack-server/src/websocket/subscription.rs
Line: 14-28

Comment:
**`after` + `with_snapshot: false` silently produces a misleading combination**

If a client sets `after: "some_cursor"` along with `with_snapshot: false`, the `after` cursor is completely ignored — no snapshot is sent and there is no filtering of live updates by cursor. A developer reasonably expecting "resume from cursor, skip the snapshot" would receive all live updates from the moment of subscription, silently losing any events between their last-seen cursor and the current time.

Consider logging a warning (or documenting clearly) that `after` has no effect when `with_snapshot` is `false`, to help users avoid this footgun:

```rust
if subscription.after.is_some() && !should_send_snapshot {
    warn!(
        "Client {} subscribed to {} with `after` cursor but `with_snapshot: false`; \
         cursor is ignored — missed events will not be recovered",
        ctx.client_id, view_id
    );
}
```

How can I resolve this? If you propose a fix, please make it concise.

Last reviewed commit: "docs: document that ..."

adiman9 added 2 commits March 21, 2026 03:42
Group 5 optional subscription parameters into SubscriptionOptions
struct to reduce ensure_subscription_with_opts from 8 args to 3.
The snapshot_limit subscription parameter was only being honored when
an after cursor was set. When after was None, get_all() was called
without any limit, returning the full unlimited snapshot. This fix
adds the limit parameter to get_all() and passes snapshot_limit in
both otel and non-otel code paths for List/Append mode.
adiman9 added 3 commits March 21, 2026 04:05
Fixes field name mismatch between TypeScript client and Rust server.
The TypeScript Subscription interface uses camelCase (withSnapshot,
snapshotLimit), but the Rust struct used snake_case. Added
#[serde(rename_all = "camelCase")] attribute so the server properly
recognizes these fields. Updated tests to use camelCase JSON keys.
The snapshot_limit was being applied to the unfiltered cache before
the matches_key filter, causing clients to receive fewer entities
than the limit when filtering was in use. Now the limit is applied
after filtering so clients get up to snapshot_limit matching entities.
adiman9 added 3 commits March 21, 2026 04:31
When only snapshot_limit is set (no cursor), entities were being taken
from DashMap in non-deterministic order. Now we sort by _seq descending
to ensure deterministic results, matching the behavior when an after
cursor is provided.

This applies to both the otel and non-otel code paths for List/Append
mode subscriptions.
When both `after` (cursor) and `snapshot_limit` are set, the descending
sort was overwriting the ascending order from `get_after`, causing the
wrong subset to be returned (most recent N instead of next N).

- Only apply descending sort when there's no cursor
- Pass snapshot_limit to get_after to avoid loading unnecessary data
- Remove redundant .take() call since limiting now happens in get_after
@adiman9 adiman9 merged commit 46be9aa into main Mar 21, 2026
10 checks passed
@adiman9 adiman9 deleted the skip-snapshot branch March 21, 2026 12:54
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.

1 participant