Conversation
RedisLock: SET NX PX lease + never-expiring fencing counter, ownership-checked release/extend in Lua, background renewal that aborts the section's signal on loss. EventLog: append-only resumable log on Redis Streams (atomic MULTI appends, exclusive-range resume, close + final drain, snapshot, TTL for orphaned logs).
New @upstash/agentkit-tanstack-ai implementing TanStack AI's own backend contracts, which upstream only ships in-memory: - upstashPersistence(): messages/runs/interrupts/metadata for withPersistence(); passes TanStack's runPersistenceConformance suite (26/26) on live Redis - upstashStream(): resumable StreamDurability on Redis Streams (core EventLog) - upstashLocks(): distributed LockStore for withLocks() (core RedisLock) - upstashMemory(): MemoryAdapter for memoryMiddleware(), ranked in Redis Search - toolCache() / rateLimit() chat middlewares, createSearchTools() for RAG Tests drive a real chat() agent loop via a scripted TextAdapter. Docs, CLAUDE.md section (replaces the stale "don't reintroduce tanstack-ai" note), changesets, version-sync target.
…dexing on save upstashMemory now provisions its index before the first write and waits for indexing after each save (waitForIndexing, default true), so a saved turn is recallable on the next turn. Adds runMemoryAdapterContract from @tanstack/ai-memory/testkit alongside the persistence conformance suite.
… blobs stores
- generationRuns: hash per job + per-thread zset (findLatestForThread)
- artifacts: hash per record, run/thread zsets ordered createdAt then id bytes;
re-save moves indexes atomically in one Lua script
- blobs (upstashPersistence({ bucket })): bytes in Upstash Blob, records and a
lexical key index in Redis; ranges via signedReadUrl + HTTP Range
- @upstash/blob is an optional peer; the store takes a structural bucket
TanStack's runPersistenceConformance now runs every store with nothing skipped.
A live-Blob copy of the suite runs when UPSTASH_BLOB_TOKEN is set.
Same flag as @upstash/ratelimit #154: Upstash locks only the declared keys instead of the whole database. Applies to RedisLock (sdk), redisDocuments CAS (eve) and all tanstack-ai persistence scripts. Under the flag an undeclared key is an error (verified live), so: - CREATE_RUN declares the parent index only when there is a parent (no placeholder key) - SAVE_ARTIFACT no longer reads its old index keys inside Lua; the caller reads and declares them and the script compare-and-swaps, retrying on a race Adds live tests for both variable-key paths and concurrent artifact saves.
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Lock-key collisions, terminal-log races, concurrent persistence corruption, and adapter scoping issues must be resolved.
Review effort: Balanced
Findings: 5
Open (8)
Atomically reject appends after close and preserve closed state · New Encode user keys to prevent lease and fence key collisions · New Prevent optional peer types from leaking into the root declaration · New Use immutable versioned paths for concurrent uploads · New Make deletion ownership-checked to prevent concurrent save races · New Include requestId in pending tool call keys · New Propagate the default tagged Redis client to core telemetry · New Preserve explicit runId when constructing from an offset · New
What changed in this PR
Adds production-grade TanStack AI backends backed by Upstash Redis, plus reusable coordination primitives in the core SDK.
Changes:
- Adds
RedisLockandEventLogto the core SDK. - Introduces persistence, streams, locks, memory, middleware, search, and Blob support for TanStack AI.
- Adds integration/conformance tests, documentation, telemetry, and release metadata.
| File | Description |
|---|---|
.changeset/lua-key-locking.md |
Records Eve Lua locking change. |
.changeset/sdk-lock-event-log.md |
Records new SDK primitives. |
.changeset/tanstack-ai-initial.md |
Records initial package release. |
CLAUDE.md |
Documents architecture and conventions. |
README.md |
Lists the new package and primitives. |
pnpm-lock.yaml |
Locks TanStack AI dependencies. |
scripts/sync-version.mjs |
Adds package version synchronization. |
packages/eve/src/memory/documents.ts |
Enables scoped Lua key locking. |
packages/sdk/src/event-log.ts |
Implements Redis Streams event logs. |
packages/sdk/src/event-log.test.ts |
Tests event-log behavior. |
packages/sdk/src/index.ts |
Exports coordination primitives. |
packages/sdk/src/lock.ts |
Implements Redis lease locks. |
packages/sdk/src/lock.test.ts |
Tests locking and renewal. |
packages/tanstack-ai/LICENSE |
Adds package license. |
packages/tanstack-ai/README.md |
Documents package usage. |
packages/tanstack-ai/package.json |
Defines package metadata and peers. |
packages/tanstack-ai/tsconfig.json |
Configures TypeScript compilation. |
packages/tanstack-ai/tsup.config.ts |
Configures ESM packaging. |
packages/tanstack-ai/src/blob-store.ts |
Implements Redis/Blob storage. |
packages/tanstack-ai/src/codec.ts |
Encodes Redis values safely. |
packages/tanstack-ai/src/generation-stores.ts |
Implements generation and artifact stores. |
packages/tanstack-ai/src/index.ts |
Exports the public API. |
packages/tanstack-ai/src/integration.test.ts |
Tests persistence and stream integration. |
packages/tanstack-ai/src/locks.ts |
Adapts RedisLock to TanStack AI. |
packages/tanstack-ai/src/locks.test.ts |
Tests distributed lock integration. |
packages/tanstack-ai/src/memory.ts |
Implements Redis Search memory. |
packages/tanstack-ai/src/memory.test.ts |
Tests memory contracts and isolation. |
packages/tanstack-ai/src/middleware.ts |
Adds cache and rate-limit middleware. |
packages/tanstack-ai/src/middleware.test.ts |
Tests middleware through chat loops. |
packages/tanstack-ai/src/persistence.ts |
Implements chat persistence stores. |
packages/tanstack-ai/src/persistence.test.ts |
Runs persistence conformance tests. |
packages/tanstack-ai/src/search-tools.ts |
Adapts schema-driven search tools. |
packages/tanstack-ai/src/search-tools.test.ts |
Tests search tools in chat loops. |
packages/tanstack-ai/src/stream.ts |
Implements resumable Redis streams. |
packages/tanstack-ai/src/stream.test.ts |
Tests replay, joining, and offsets. |
packages/tanstack-ai/src/telemetry.ts |
Adds package telemetry tagging. |
packages/tanstack-ai/src/test-adapter.ts |
Provides a scripted test adapter. |
packages/tanstack-ai/src/test-bucket.ts |
Provides an HTTP-backed test bucket. |
packages/tanstack-ai/src/test-support.ts |
Adds shared live-test utilities. |
packages/tanstack-ai/src/version.ts |
Defines generated package version. |
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…tore plumbing - Records (runs, interrupts, generation runs, artifacts, blob records, metadata, transcripts) are RedisJSON documents: values keep their JSON types, so codec.ts and per-field encoding are gone. Creates are JSON.SET NX, patches JSON.MERGE behind an EXISTS guard (a bare merge creates the key). - records.ts holds the shared CREATE/PATCH scripts, merge-patch conversion (replace semantics for nested objects), loadDocs, assertId, checkRun; persistence.ts and generation-stores.ts no longer duplicate them. - Artifact index bookkeeping moved to a side hash so the document is exactly the record. - Adapter configs reuse the core types (RedisLockConfig, EventLogConfig, ToolCacheConfig, AgentMemoryConfig, SearchToolDefsConfig) and spread them through instead of copying fields one by one. - Telemetry: rateLimit tags its default client, upstashBlobStore tags its own. Behaviour unchanged: TanStack persistence conformance 26/26 (stand-in and live Upstash Blob), memory contract 8/8, full repo 254 passed.
`inputSchema as never` typed the tool args as never, which made the tool unassignable to Tool and forced `as unknown as Tool`. The zod schema is already a valid SchemaInput, so both casts go; the memory save tool's cast was never needed.
…es by feature RedisLock and EventLog move from @upstash/agentkit-sdk to @upstash/agentkit-tanstack-ai (their only consumer) and are exported from there; the core SDK no longer exports them and its changeset is dropped. tanstack-ai/src is grouped into persistence/, stream/ (upstashStream + EventLog), locks/ (upstashLocks + RedisLock), memory/, middleware/, search/ and testing/. No behaviour change: full repo 254 passed / 3 skipped.
- Subpath entry points: ./persistence and ./memory. The root entry no longer references @tanstack/ai-persistence or @tanstack/ai-memory types, verified with a consumer that has only @tanstack/ai and skipLibCheck off. - RedisLock: leases live under lease:<key>, so "fence:x" can't collide with the fencing counter for "x" (regression test added). - EventLog: append is one script that refuses a closed log (EventLogClosedError) and keeps the stream's TTL aligned with the closed flag. - upstashStream: object form stays bound to its explicit runId; a mismatched offset is rejected by read(), matching memoryStream. - Blob store: every put uploads to an immutable versioned path; the record and version pointer swap atomically and the replaced bytes are deleted after commit, so concurrent puts/deletes can't mismatch record and bytes. - Artifacts: delete is a compare-and-swap script like save, so a concurrent move can't leave a dangling index entry. - toolCache: pending calls keyed by requestId + toolCallId. - README: subpath imports; correct description of withLocks. Full repo 259 passed / 3 skipped; live-Blob conformance 26/26.
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Missing package subpath exports and concurrency races can break documented imports and leave inconsistent persistence state.
Review effort: Balanced
Findings: 3
Open (8)
Export persistence and memory subpath entries · New Prevent get from losing data during concurrent overwrite · New Make no-index deletion atomic with concurrent saves · New Avoid truncating listFacts results without pagination · New Align EventLog.append documentation with EVAL implementation · New Fix memory API example to import from ./memory · New Fix persistence API example to import from ./persistence · New Add wire-level telemetry tests for the new adapter · New
Resolved since last review (8)
Make deletion ownership-checked to prevent concurrent save races Use immutable versioned paths for concurrent uploads Prevent optional peer types from leaking into the root declaration Encode user keys to prevent lease and fence key collisions Atomically reject appends after close and preserve closed state Preserve explicit runId when constructing from an offset Propagate the default tagged Redis client to core telemetry Include requestId in pending tool call keys
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
| const [record, path] = (await tx.exec()) as [BlobRecord | null, string | null]; | ||
| if (!record || !path) return null; | ||
| let read; | ||
| try { | ||
| read = await readBytes(path, record, options?.range); |
| if (!run || !thread) { | ||
| // Not indexed: at most a bare document is left (e.g. from a crashed save) — drop it. | ||
| await redis.del(k.artifact(id), k.where(id)); | ||
| return; |
| }, | ||
|
|
||
| async listFacts(scope): Promise<MemoryFact[]> { | ||
| const records = await memory.list({ userId: memoryScopeKey(scope, scopeBy), limit: 100 }); |
| - **Redis primitives** (`src/locks/redis-lock.ts`, `src/stream/event-log.ts`; moved here from core 2026-09-28, exported from this package): `RedisLock` (+ `LockLease`, `LockAcquireTimeoutError`, | ||
| `LockLostError`) — `SET NX PX` lease + a never-expiring `INCR` fencing counter, release/extend are | ||
| ownership-checked Lua; `withLock` renews every `leaseMs/3` and aborts the section's signal on loss. | ||
| `EventLog` (+ `LogEntry`) — Redis Streams: `append` is one `MULTI` of `XADD`s (atomic, ordered ids), |
| export function addTelemetry(redis: unknown, enableTelemetry?: boolean): void { | ||
| tagClient(redis, { sdk: TANSTACK_AI_TELEMETRY, enabled: enableTelemetry }); |
…ce check Bumps @tanstack/ai to 0.63.0, @tanstack/ai-persistence to 0.7.1 and @tanstack/ai-memory to 0.2.8 (dev deps and peer floors), matching the rest of the TanStack AI 0.63 line (ai-react, ai-openai) so an app gets one copy. The 0.7.1 conformance kit adds two opt-in checks (messages.metadata, runs.listByThread.state); both are enabled and pass, live Blob included. Full repo: 263 passed / 3 skipped.
…issing subpath exports examples/tanstack-ai-demo is TanStack AI's own persistent-chat pattern (from their ts-react-chat example) on Upstash: detached runs, upstashPersistence, upstashStream, reconstructChat, upstashMemory, and RedisLock in place of the process-local "one producer per run" set. AGENTKIT_MOCK_MODEL=1 runs a scripted model, so it needs no provider key. `pnpm e2e` starts two production servers against one Upstash Redis and checks: resume after a mid-answer drop on the other instance (nothing missed or repeated), mid-run reconstruct on the other instance then the final transcript, memory across threads and instances (isolated per user), exactly one production of a run POSTed to both instances, and a 400 on a bad body. The CI step (after the example builds, skipped without Redis secrets) is added separately: the box token cannot push workflow changes. Building the demo caught a real bug: package.json#exports had lost ./persistence and ./memory (dist was built, nothing exported). Restored, and src/package-exports.test.ts now pins exports to the tsup entries.
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Concurrency races in artifact and Blob persistence plus unrenewed detached-run leases can violate durability and single-producer guarantees.
Review effort: Balanced
Findings: 4
Open (11)
Producer lease expires during long-running runs · New Detached task is not durable in serverless deployments · New Make no-index deletion atomic with concurrent saves Prevent get from losing data during concurrent overwrite Avoid truncating listFacts results without pagination Use the documented gpt-5.4-mini model override · New Use gpt-5.4-mini as the demo model · New Add wire-level telemetry tests for the new adapter Fix persistence API example to import from ./persistence Fix memory API example to import from ./memory Align EventLog.append documentation with EVAL implementation
Resolved since last review (1)
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
Package: - Artifact delete: the not-indexed case goes through the compare-and-swap script too, so a first save racing it can't leave dangling index entries. - Blob get: if a concurrent overwrite deletes the version being read, re-read the record/pointer snapshot instead of reporting a live key missing (deterministic regression test). - upstashMemory.listFacts returns every fact (sized from count) instead of silently truncating at 100 (test with 120 facts). - Wire-level telemetry tests for every factory: tag present, one tag per shared client, enableTelemetry: false. - JSDoc examples import from ./persistence and ./memory; CLAUDE.md EventLog note. Demo: - The producer holds a renewed lease for the whole run (withLock with an immediate timeout = "already produced elsewhere"); on lease loss it aborts and leaves the log to the new owner. - The detached run is handed to Next.js after() (+ maxDuration) so it survives the response on serverless hosts. - New E2E: a run that outlives a 1s lease is still produced once when re-POSTed to the other instance. Verified to fail without renewal (two producers interleave into one log; asserted via TEXT_MESSAGE_START count). - Default model gpt-5.4-mini, like the other demos. Full CI sequence green: 287 passed / 3 skipped, example builds, E2E 6/6.



Part of DX-3081.
Why
TanStack AI (0.61) defines the extension points for an agent's production state — persistence stores, resumable streams, locks, memory — and ships only in-memory implementations (
memoryPersistence,memoryStream,InMemoryLockStore,inMemory()memory, …). They work in one process and lose everything between serverless instances. No Redis or Postgres backends existed on npm. This PR implements them on Upstash Redis (plus Upstash Blob for file bytes).The Box sandbox provider already exists upstream as
@tanstack/ai-sandbox-upstash-box, so it is not duplicated here.Exports of
@upstash/agentkit-tanstack-aiThree entry points, so the root never needs TanStack's optional packages:
@upstash/agentkit-tanstack-ai(stream, locks, middlewares, search tools,RedisLock/EventLog),@upstash/agentkit-tanstack-ai/persistence(upstashPersistence,upstashBlobStore; needs@tanstack/ai-persistence) and@upstash/agentkit-tanstack-ai/memory(upstashMemory,memoryScopeKey; needs@tanstack/ai-memory).upstashPersistence()findActiveRun, subagent cards, reclaim reaping), human-in-the-loop interrupts, metadata, one-shot generation jobs and their artifacts, plus file bytes when given a Blob bucket@upstash/redis(JSON.SET NX/JSON.MERGE, single-EVALLua writes withallow-key-locking); no core primitive. Blobs viaupstashBlobStorewithPersistence()/withGenerationPersistence()from@tanstack/ai-persistence— all 7 stores:messages,runs,interrupts,metadata,generationRuns,artifacts,blobsUPSTASH_REDIS_REST_URL,UPSTASH_REDIS_REST_TOKEN(+UPSTASH_BLOB_TOKENif the bucket isBucket.fromEnv())upstashBlobStore()upstashPersistence({ bucket }), also usable alone@upstash/blobBucket, optional peer) + RedisJSON records and a lexical key indexBlobStorefrom@tanstack/ai-persistenceredisandbucketare passed in)upstashStream()EventLog(this package,stream/) → Redis StreamsStreamDurabilityfrom@tanstack/ai(durability.adapteroftoServerSentEventsResponse,replayRunStream)UPSTASH_REDIS_REST_URL,UPSTASH_REDIS_REST_TOKENupstashLocks()withSandboxuses them so concurrent requests never create duplicate sandboxes;withLocksdoes not lock whole turns). Lease renewed while the section runs,signalaborts on lossRedisLock(this package,locks/) →SET NX PX+ LuaLockStoreforwithLocks()from@tanstack/ai/locks(read bywithSandbox, run claims)UPSTASH_REDIS_REST_URL,UPSTASH_REDIS_REST_TOKENupstashMemory()save_memorytoolAgentMemory→ Upstash Redis Search (BM25$smart)MemoryAdapterformemoryMiddleware()from@tanstack/ai-memoryUPSTASH_REDIS_REST_URL,UPSTASH_REDIS_REST_TOKENmemoryScopeKey()userIdMemoryScope(=Scopefrom@tanstack/ai)toolCache()ToolCacheChatMiddleware(onBeforeToolCall→skip,onAfterToolCall)UPSTASH_REDIS_REST_URL,UPSTASH_REDIS_REST_TOKENrateLimit()createRateLimit→@upstash/ratelimitChatMiddleware(onStart)UPSTASH_REDIS_REST_URL,UPSTASH_REDIS_REST_TOKENRateLimitExceededErrorrateLimit()fails a run with (identifier, limit, remaining, reset)RUN_ERRORcreateSearchTools()search/aggregate/counttools over your own documents (RAG), descriptions generated from the schema, index created on first usecreateSearchToolDefs→ Redis SearchtoolDefinition().server()forchat({ tools })UPSTASH_REDIS_REST_URL,UPSTASH_REDIS_REST_TOKENcreateRateLimit,Ratelimitchat()createRateLimit,@upstash/ratelimitUPSTASH_REDIS_REST_URL,UPSTASH_REDIS_REST_TOKEN(createRateLimit)Redis variables are read only when no
redisclient is passed. Every export honoursUPSTASH_DISABLE_TELEMETRY.Redis primitives (also exported from this package)
RedisLock(+LockLease,LockAcquireTimeoutError,LockLostError)withLockrenews in the background and aborts the section's signal on lossupstashLocks()EventLog(+LogEntry)upstashStream()Source layout (
packages/tanstack-ai/src):persistence/,stream/,locks/,memory/,middleware/,search/, andtesting/(test-only helpers).Verification (live Upstash Redis)
runPersistenceConformance(@tanstack/ai-persistence/testkit) againstupstashPersistencewith all seven stores, nothing skipped: 26/26. Blob bytes go to a stand-in bucket whose signed URLs are served over real HTTP withRangesupport. A second copy runs against a real Upstash Blob bucket through the@upstash/blobSDK whenUPSTASH_BLOB_TOKENis set: 26/26 on 2026-09-28, including the byte-range case oversignedReadUrl.runMemoryAdapterContract(@tanstack/ai-memory/testkit) againstupstashMemory: 8/8.chat()agent loop through a scriptedTextAdapter(tool call → result → final answer), no provider key needed.toServerSentEventsResponsereplayed byreplayRunStream.sync-version --check✅ — 287 passed / 3 skipped / 0 failed (3 skipped = the existing Box-key tests; the live-Blob suite runs).Demo + end-to-end suite
Recorded run of
examples/tanstack-ai-demo: two production instances (:4310, :4311) on one Upstash Redis, scripted model at 150 ms/word. A reload mid-answer resumes; the same thread opened on the other instance mid-answer finishes there;remember:on B is recalled in a new thread on A.tanstack-ai-demo.mp4
examples/tanstack-ai-demois TanStack AI's own persistent-chat pattern (theirts-react-chatexample) on these backends, with a scripted model behindAGENTKIT_MOCK_MODEL=1.pnpm e2estarts two production servers against one Upstash Redis and checks, 6/6:RedisLock)The demo also caught that
package.json#exportshad dropped./persistenceand./memory; fixed, withsrc/package-exports.test.tsguarding it. CI runs the suite after the example builds.Not in this PR (follow-ups)
Code Mode isolate driver on Upstash Box, Redis sandbox instance/checkpoint stores, QStash background runs writing to
upstashStream, and a QStash schedule forreapDetachedRuns.CLAUDE.md's old "don't reintroduce tanstack-ai" note is replaced with a section explaining why this package differs from the adapter removed in June.Related: upstash/docs#874, upstash/skills#53, upstash/upstash-web#661.