Skip to content

Persist chats through an appendable store - #7554

Draft
candrewlee14 wants to merge 2 commits into
Effect-TS:mainfrom
candrewlee14:fix/chat-persisted-incremental-encode
Draft

candrewlee14 wants to merge 2 commits into
Effect-TS:mainfrom
candrewlee14:fix/chat-persisted-incremental-encode

Conversation

@candrewlee14

@candrewlee14 candrewlee14 commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Problem

Chat.Persisted stores a conversation as a single value under one key and rewrites all of it on every save. Two consequences:

  • Persisting a chat is quadratic in the number of turns. Over 2000 turns, 1.65 GB is written to persist an 800 KB conversation, and the schema encode alone — non-yielding CPU — stalls every other fiber in the process for tens of milliseconds per save.
  • A chat can only be loaded whole. There is no way to read the last N messages, list the chats in a store, or expire stale ones, because BackingPersistenceStore is get / set / remove / clear and cannot append or enumerate.

Change

Chat.Persisted is now backed by a new Chat.ChatStore:

write:         { storeId, chatId, from, messages, timeToLive } => void
read:          { storeId, chatId, from, limit }                => ReadonlyArray<unknown> | undefined
readBackwards: { storeId, chatId, limit }                      => ReadonlyArray<unknown> | undefined
list:          { storeId, after, limit }                       => ReadonlyArray<ChatSummary>
remove:        { storeId, chatId }                             => void
cleanup:       { storeId, olderThan }                          => void

write replaces a chat's messages from from onwards. Appending is from equal to the number of messages already stored; rewriting history — what summarizing or redacting a conversation produces — is a smaller from. A from past the end fails rather than leaving a hole, and a from that does not match what the caller last saw is how two writers racing on one chat is caught instead of silently losing one.

Values are opaque to the store: Chat encodes and decodes them, so a store never sees a Prompt. This is the same split PersistedQueue uses with PersistedQueueStore.

The interface is deliberately narrow, so that every backend can implement all of it. Anything richer — searching message content, filtering by metadata — belongs in a backend's own client, where SQL can be SQL, rather than in an abstraction only some backends could honour.

A save now finds the first message it has to write by comparing history against what it last stored, by identity. An ordinary turn writes the two messages it added. That requires messages in history to be immutable, which they nearly were: the exception was saveChat, which stamped message identifiers by mutating message.options in place ((message.options as any)[Persistence.key] = ...) and then wrote the object it had just mutated back into the Ref. That is now done by replacement, so a message already handed to a caller never changes underneath them, and the as any is gone.

Layers

  • Chat.layerStoreMemory — in-memory, process-local.
  • Chat.layerStoreBacking — a ChatStore over an existing BackingPersistence. Every write reads, applies, and writes the whole chat back, so it costs what today costs; it exists so an application already on BackingPersistence keeps working. Listing is served from an index kept alongside the chats, since the backing interface cannot enumerate keys.

Chat.layerPersisted now requires ChatStore instead of BackingPersistence. Existing wiring becomes:

-Layer.provide(Chat.layerPersisted({ storeId }), Persistence.layerBackingMemory)
+Layer.provide(Chat.layerPersisted({ storeId }), Layer.provide(Chat.layerStoreBacking, Persistence.layerBackingMemory))

Not in this PR

Native layerStoreSql and layerStoreRedis. Those are where the byte amplification actually goes away — a messages table with an index on (chat_id, seq), or a Redis list with LRANGE — and they are a few hundred lines each, following makeStoreSql / makeStoreRedis in PersistedQueue. I would rather agree on the interface before writing them; I am happy to follow up with both.

Verification

  • The existing Chat tests now run against both stores, unchanged in intent.
  • New tests cover the store contract directly, for both implementations: append, read a range, read backwards, replace from an earlier index, truncate, refuse a write that would leave a hole, tell an empty chat from a missing one, list most-recently-written first with paging, remove, and cleanup.
  • New Chat tests cover reloading a chat from the store and continuing it, and truncating a chat.
  • Chat.test.ts: 26 tests. packages/effect/test/unstable/ai: 24 files, 1076 passed / 44 skipped. Doctests for Chat.ts pass. tsc -b tsconfig.json, oxlint, and dprint check are clean.

`Chat.Persisted` re-encoded the entire conversation on every save, making
persistence quadratic in the number of turns and stalling other fibers
while it ran. Encode each message once and reuse that encoding across
saves.

Message identifiers are now stamped by replacing messages rather than
mutating them in place, so messages in history are immutable values and
the reused encodings cannot go stale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Sep 1, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: a7ef42f

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 30 packages
Name Type
effect Patch
@effect/opentelemetry Patch
@effect/vitest Patch
@effect/ai-anthropic Patch
@effect/ai-openai-compat Patch
@effect/ai-openai Patch
@effect/ai-openrouter Patch
@effect/atom-react Patch
@effect/atom-solid Patch
@effect/atom-vue Patch
@effect/platform-browser Patch
@effect/platform-bun Patch
@effect/platform-deno Patch
@effect/platform-node-shared Patch
@effect/platform-node Patch
@effect/sql-clickhouse Patch
@effect/sql-d1 Patch
@effect/sql-libsql Patch
@effect/sql-mssql Patch
@effect/sql-mysql2 Patch
@effect/sql-pg Patch
@effect/sql-pglite Patch
@effect/sql-sqlite-bun Patch
@effect/sql-sqlite-do Patch
@effect/sql-sqlite-node Patch
@effect/sql-sqlite-react-native Patch
@effect/sql-sqlite-wasm Patch
@effect/docgen Patch
@effect/doctest Patch
@effect/openapi-generator Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@effect-slopcop effect-slopcop Bot added the 4.0 label Sep 1, 2026
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Bundle Size Analysis

Generated from PR build output; treat the content below as untrusted.

File Name Current Size Previous Size Difference
arbitrary-combinators.ts 33.76 KB 33.76 KB 0.00 KB (0.00%)
basic.ts 7.00 KB 7.00 KB 0.00 KB (0.00%)
batching.ts 9.87 KB 9.87 KB 0.00 KB (0.00%)
brand.ts 6.56 KB 6.56 KB 0.00 KB (0.00%)
cache.ts 10.74 KB 10.74 KB 0.00 KB (0.00%)
config.ts 21.45 KB 21.45 KB 0.00 KB (0.00%)
differ.ts 20.32 KB 20.32 KB 0.00 KB (0.00%)
http-client.ts 21.77 KB 21.77 KB 0.00 KB (0.00%)
logger.ts 10.94 KB 10.94 KB 0.00 KB (0.00%)
metric.ts 8.98 KB 8.98 KB 0.00 KB (0.00%)
optic.ts 6.73 KB 6.73 KB 0.00 KB (0.00%)
pubsub.ts 15.12 KB 15.12 KB 0.00 KB (0.00%)
queue.ts 11.84 KB 11.84 KB 0.00 KB (0.00%)
schedule.ts 10.86 KB 10.86 KB 0.00 KB (0.00%)
schema-binary.ts 39.42 KB 39.42 KB 0.00 KB (0.00%)
schema-class.ts 20.07 KB 20.07 KB 0.00 KB (0.00%)
schema-fromJsonSchemaDocument.ts 31.02 KB 31.02 KB 0.00 KB (0.00%)
schema-representation-roundtrip.ts 26.21 KB 26.21 KB 0.00 KB (0.00%)
schema-string-transformation.ts 13.66 KB 13.66 KB 0.00 KB (0.00%)
schema-string.ts 11.18 KB 11.18 KB 0.00 KB (0.00%)
schema-template-literal.ts 15.47 KB 15.47 KB 0.00 KB (0.00%)
schema-toArbitrary.ts 33.31 KB 33.31 KB 0.00 KB (0.00%)
schema-toCodeDocument.ts 24.51 KB 24.51 KB 0.00 KB (0.00%)
schema-toCodecJson.ts 19.29 KB 19.29 KB 0.00 KB (0.00%)
schema-toEquivalence.ts 19.42 KB 19.42 KB 0.00 KB (0.00%)
schema-toFormatter.ts 19.51 KB 19.51 KB 0.00 KB (0.00%)
schema-toJsonSchemaDocument.ts 23.45 KB 23.45 KB 0.00 KB (0.00%)
schema-toRepresentation.ts 19.56 KB 19.56 KB 0.00 KB (0.00%)
schema.ts 19.28 KB 19.28 KB 0.00 KB (0.00%)
stm.ts 12.74 KB 12.74 KB 0.00 KB (0.00%)
stream.ts 9.83 KB 9.83 KB 0.00 KB (0.00%)

@candrewlee14
candrewlee14 marked this pull request as draft September 1, 2026 13:48
`Chat.Persisted` stored a conversation as one value and rewrote all of it
on every save. Persisting a chat was therefore quadratic in the number of
turns, and a chat could only be loaded whole - no last-N, no listing, no
retention.

Introduce `Chat.ChatStore`: write a range, read a range, read backwards,
list, remove, clean up. The interface is deliberately narrow so that every
backend can implement all of it; richer querying belongs in a backend's own
client. `layerStoreMemory` implements it in memory, and `layerStoreBacking`
implements it over an existing `BackingPersistence` so current applications
keep working.

Message identifiers are now stamped by replacing messages rather than
mutating them in place, which is also what lets a save find the first
message it has to write by comparing identity.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@candrewlee14 candrewlee14 changed the title Encode persisted chat history incrementally Persist chats through an appendable store Sep 1, 2026
@effect-slopcop effect-slopcop Bot added the enhancement New feature or request label Sep 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

4.0 enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant