Skip to content

feat(sdk): optional saveDelta hook on SessionStore#95

Open
ketankhairnar wants to merge 2 commits intowithastro:mainfrom
ketankhairnar:feat/sdk-savedelta-hook
Open

feat(sdk): optional saveDelta hook on SessionStore#95
ketankhairnar wants to merge 2 commits intowithastro:mainfrom
ketankhairnar:feat/sdk-savedelta-hook

Conversation

@ketankhairnar
Copy link
Copy Markdown
Contributor

Summary

Adds an optional saveDelta? method on SessionStore, with a paired SessionDelta interface, so persistence adapters can persist O(delta) per turn instead of O(history).

This is the SDK-side follow-up to #89. The recipe-side restructure (category: "persist" + Postgres/D1 connectors) is in #87.

What's in the PR

  • packages/sdk/src/types.ts — adds SessionDelta interface and optional saveDelta? on SessionStore.
  • packages/sdk/src/session.ts — tracks lastSavedEntryCount; in save(), branches on typeof store.saveDelta === 'function' and calls the delta path when available, falls through to save(full) otherwise.
  • packages/sdk/src/session-history.ts — adds two small helpers, getEntryCount() and getEntriesSince(index), used by the SDK to compute the slice.

Non-breaking by design

  • saveDelta? is optional. InMemorySessionStore, the Cloudflare DO store, and the Postgres/D1 recipes from docs: add Postgres + D1 session persistence guides #87 all keep working with zero changes.
  • Adapters that implement only save() see the existing call path.
  • Adapters that implement saveDelta? get only newly appended entries per turn. load(id) still returns full SessionData — the adapter is responsible for reconstructing it from its delta records (this is natural for append-log shapes; documented on the JSDoc).

What's NOT in the PR (deliberately)

  • No tests. There's no vitest setup on main yet; the test-infra work is on a different branch (feat/test-infra). Adding vitest as part of this PR would double its scope and conflict with the in-flight test infra. Test plan to land in a follow-up PR after both this and the test infra merge:
    • Fake store implementing only save() — backward-compat round-trip.
    • Fake store implementing saveDelta? — assert payloads contain only new entries; reload + further appends work.
    • Fake store implementing both — assert SDK calls saveDelta? (not save).
    • Compaction case — assert the next delta carries the new CompactionEntry and prior entries are not re-emitted.
  • No recipe addendum. connectors/persist--postgres.md (in docs: add Postgres + D1 session persistence guides #87) will get an "Optional: append-log shape with saveDelta" section in a follow-up PR after both this and docs: add Postgres + D1 session persistence guides #87 land.

Design notes

SessionDelta carries { newEntries, leafId, metadata } only. No supersedes field in v1: appendCompaction is append-only (it pushes a new CompactionEntry without mutating prior entries), so adapters recover prior history from their own delta records on load(). A supersedes field can be added later non-breakingly if a future entry kind needs it.

Refs

Test plan

  • pnpm check:types passes on the packages/sdk workspace.
  • Manual smoke: existing in-memory store round-trips unchanged.
  • Test infra + fake-store unit suite to follow once vitest lands on main.

@ketankhairnar
Copy link
Copy Markdown
Contributor Author

Quick housekeeping update:

  • Rebased onto current main (was 9 commits behind — picked up the flue.config.ts, outputDir → output, and workspace-root rename work). No conflicts; single commit, still atomic.
  • JSDoc tightened on saveDelta? while I was in there: explicit dispatch precedence (typeof === 'function'), append-log reconstruction note for load(), and called out that newEntries.length === 0 is valid (a no-op save() should not be misread as "delete prior entries"). All derived from local fakes I ran against the dispatch path; no behavior change.
  • pnpm check:types clean on packages/sdk.

Scope unchanged — still no tests in this PR, still no recipe addendum (the runnable example will land alongside #87 where the Postgres recipe lives — that's the natural home for it, and avoids cross-PR coupling).

Ready when you are.

@ketankhairnar
Copy link
Copy Markdown
Contributor Author

Dry-run update for this branch:

Verified against refreshed upstream/main in a temporary worktree at /private/tmp/flue-sdk-savedelta-pr95 so the active working tree stayed untouched.

What passed:

  • pnpm install in the temporary worktree
  • pnpm --filter @flue/sdk build
  • pnpm --filter @flue/sdk check:types
  • pnpm --filter @flue/cli build after rerunning outside the local sandbox

Notes:

  • pnpm install --offline first failed because just-bash-2.14.4.tgz was missing from the local pnpm store; normal pnpm install succeeded.
  • The first CLI build attempt failed because tsx could not open its local IPC pipe under the sandbox (listen EPERM ... tsx-501/...pipe). Rerunning the same command outside the sandbox passed.
  • Connector index generation on this branch reported 10 connectors, 1 category roots, as expected for this PR.
  • Temporary worktree was removed after verification.

Remaining review concern from the dry run: this proves build/type health, but it does not resolve the persistence-contract questions around saveDelta shape, createdAt/updatedAt, and delete/tombstone semantics during overflow recovery.

@ketankhairnar
Copy link
Copy Markdown
Contributor Author

ketankhairnar commented May 10, 2026

Follow-up fix pushed to ketankhairnar/flue@5963a93 (fix(sdk): harden saveDelta contract).

What changed after the review pass:

  • SessionDelta now carries the full session header needed to reconstruct SessionData: version, createdAt, and updatedAt.
  • Added removedEntryIds so delta stores can tombstone entries removed from the current session. This covers overflow recovery removing a failed assistant leaf before retry.
  • Delta computation now tracks saved entry IDs instead of only an entry count. That avoids missing a new entry if a future path both removes and appends before one save.
  • JSDoc now states the actual contract: save() is still required and is used for initial empty session creation; saveDelta() is for live session saves after construction; resumed sessions only emit changes made after load.
  • Exported SessionDelta from both @flue/sdk and @flue/sdk/client.

Verification run:

  • pnpm --filter @flue/sdk build
  • pnpm --filter @flue/sdk check:types
  • pnpm --filter @flue/cli build
  • Focused built-SDK smoke: constructed a session with a saveDelta store, verified normal shell deltas include newEntries, then manually appended and removed a leaf entry and verified the final delta emits removedEntryIds with newEntries: []

Also noting the paired persistence recipe validation from #87 was not dry-run-only:

  • Actual Postgres 16 Docker container on 127.0.0.1:55432, with schema created and rows inspected.
  • Actual with-postgres-persist agent run twice through flue run using Anthropic claude-haiku-4-5: first turn stored nonce FLUE-CERULEAN-55432, second turn resumed the same persisted session and returned that nonce. The Postgres row had 4 entries and a non-null leafId.
  • Actual Wrangler/D1 path: authenticated Wrangler, created a temporary remote D1 database (flue-d1-smoke-20260510112922, APAC, deleted after), ran flue dev --target cloudflare against Flue's generated dist/wrangler.jsonc, initialized the local D1 schema against that generated config, then verified two real requests persisted/resumed FLUE-D1-APAC-55831 and inspected the D1 row.

The live Postgres/D1 runs validate the store/recipe surface that #95 is meant to support conceptually; the saveDelta-specific behavior above is covered by the focused SDK smoke because the current recipes still use the full-blob save() path.

@ketankhairnar ketankhairnar force-pushed the feat/sdk-savedelta-hook branch from 5963a93 to 01d3447 Compare May 11, 2026 07:31
Adds `SessionDelta` interface and optional `saveDelta?` method on
`SessionStore`. Adapters that implement it persist O(delta) per turn
instead of O(history). Existing implementations (in-memory, Cloudflare
DO) work unchanged because the method is optional.

The hook is non-breaking: if `saveDelta` is absent, the SDK falls
through to the existing `save(full)` path. If present, the SDK passes
only entries appended since the last save.

`SessionDelta` carries `{ newEntries, leafId, metadata }`. No
`supersedes` field in v1 — `appendCompaction` is append-only (it pushes
a new `CompactionEntry` without mutating prior history) so the
recipe-side adapter recovers prior entries from its own delta records on
load(). A `supersedes` field can be added later non-breakingly if a
future entry kind needs it.

Closes the SDK-side ask in withastro#89 (the recipe-side restructure landed as
part of withastro#87).
@ketankhairnar ketankhairnar force-pushed the feat/sdk-savedelta-hook branch from 01d3447 to 622861d Compare May 11, 2026 08:34
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