| name | ai-persistence/build-prisma-adapter |
| description | Use when an app already runs Prisma and needs TanStack AI chat persistence — writes a chat-persistence.ts into the app against its existing PrismaClient and schema.prisma. Covers the four models, BigInt timestamps, JSON-as-string columns, upsert-with-empty-update idempotency, and model renaming. |
Prisma Chat Persistence
The deliverable is one file in the app — src/lib/chat-persistence.ts —
exporting a ChatPersistence built from the app's existing PrismaClient. Plus
four models added to the app's existing schema.prisma and a migration created
with the app's own prisma migrate.
Do not create a package, a second client, a datasource block, a generator, or a
hand-written SQL migration. The app has those.
Read the Store Reference
(docs/persistence/store-reference.md) for the store contracts and
invariants, and ai-persistence/stores for the shape rules. Every
store below mirrors the reference in-memory backend in
@tanstack/ai-persistence (memory.ts); the shared conformance testkit is the
proof.
1. Read the app before writing anything
| Find | Where to look | What it decides |
|---|
| Schema location | prisma/schema.prisma, or a multi-file prisma/schema/ dir | Append to the existing file, or add one new .prisma file |
| Provider | the datasource block | Whether Json is available; nothing else changes |
| Client singleton | src/lib/prisma.ts, src/db.ts, globalThis dev cache | What chat-persistence.ts imports — never new PrismaClient() |
| Generated client | the generator client block (output, prisma-client-js vs prisma-client) | Where ChatRun/ChatInterrupt row types come from |
| Existing model names | the schema | Whether Message/Run are taken — prefix if so |
| Migration flow | prisma/migrations/, or db push in scripts | prisma migrate dev vs prisma db push |
Prisma 6 and 7 both work: the delegate query API (findUnique, upsert,
update, findMany, delete) is unchanged, so it does not matter which
client the app generated.
Never invent a migration path. Add the models, then have the user run their
own npx prisma migrate dev --name chat-persistence (or db push) and
prisma generate.
2. Add the models to their schema
IDs are String, timestamps are BigInt (portable epoch ms — Int overflows
in 2038, DateTime forces a conversion at every boundary), JSON payloads are
String. Use @map/@@map to match the app's database naming.
model ChatThread {
threadId String @id @map("thread_id")
messagesJson String @map("messages_json")
updatedAt BigInt @map("updated_at")
@@map("chat_threads")
}
model ChatRun {
runId String @id @map("run_id")
threadId String @map("thread_id")
status String
startedAt BigInt @map("started_at")
finishedAt BigInt? @map("finished_at")
error String?
errorCode String? @map("error_code")
usageJson String? @map("usage_json")
sandboxKey String? @map("sandbox_key")
detachedSince BigInt? @map("detached_since")
cancelRequested Boolean? @map("cancel_requested")
driverEpoch Int? @map("driver_epoch")
@@index([threadId, status])
@@index([threadId, startedAt])
// Powers listReclaimable: status = 'running' AND detachedSince <= cutoff.
@@index([status, detachedSince])
@@map("chat_runs")
}
model ChatInterrupt {
interruptId String @id @map("interrupt_id")
runId String @map("run_id")
threadId String @map("thread_id")
status String
requestedAt BigInt @map("requested_at")
resolvedAt BigInt? @map("resolved_at")
payloadJson String @map("payload_json")
responseJson String? @map("response_json")
@@index([threadId, requestedAt])
@@map("chat_interrupts")
}
model ChatMetadata {
namespace String
key String
valueJson String @map("value_json")
@@id([namespace, key])
@@map("chat_metadata")
}
Rename models freely to fit the app — the store code below is the only thing
that references them. Extra app-owned fields (a userId, audit columns) are
fine as long as they are optional or defaulted, so the stores' creates still
succeed. namespace is the MetadataStore first argument; the stock SQL in
the guide calls the same column scope.
RunRecord.error is a structured RunError ({ message: string, code?: string }),
so it gets two columns rather than one JSON blob: error for the provider's
prose and errorCode for the stable classification an operator filters and
groups by. error and errorCode always move together in update, so a
later code-less failure can never leave a stale code from an earlier one
behind.
On Postgres or MySQL you can switch the *Json fields to Prisma's Json
type and drop the JSON.stringify/parse in the mappers below. Keep String
if the app targets SQLite or if it is multi-provider.
3. Write src/lib/chat-persistence.ts
Two conversions the SQL backends do not need: BigInt timestamps in and out,
and JSON as strings. Everything else is the shared invariant set.
import { defineAIPersistence } from '@tanstack/ai-persistence'
import type {
ChatInterrupt,
ChatRun,
Prisma,
PrismaClient,
} from '@prisma/client'
import type { ModelMessage, TokenUsage } from '@tanstack/ai'
import type {
ChatPersistence,
InterruptRecord,
InterruptStatus,
InterruptStore,
MessageStore,
MetadataStore,
RunRecord,
RunStatus,
RunStore,
} from '@tanstack/ai-persistence'
import { prisma } from '@/lib/prisma'
function parseJson<T>(raw: string): T {
return JSON.parse(raw)
}
const RUN_STATUSES: ReadonlyArray<RunStatus> = [
'running',
'interrupted',
'completed',
'failed',
'aborted',
]
: <> = [
,
,
,
]
(): {
status = .( candidate === value)
(!status) ()
status
}
(): {
status = .( candidate === value)
(!status) ()
status
}
(): {
{
: row.,
: row.,
: (row.),
: (row.),
...(row. != ? { : (row.) } : {}),
...(row. !=
? {
: {
: row.,
...(row. != ? { : row. } : {}),
},
}
: {}),
...(row. !=
? { : parseJson<>(row.) }
: {}),
...(row. != ? { : row. } : {}),
...(row. !=
? { : (row.) }
: {}),
...(row. !=
? { : row. }
: {}),
...(row. != ? { : row. } : {}),
}
}
(): {
{
: row.,
: row.,
: row.,
: (row.),
: (row.),
: parseJson<<, >>(row.),
...(row. != ? { : (row.) } : {}),
...(row. !=
? { : parseJson<>(row.) }
: {}),
}
}
(): {
{
() {
row = db..({ : { threadId } })
row ? parseJson<<>>(row.) : []
},
() {
messagesJson = .(messages)
updatedAt = (.())
db..({
: { threadId },
: { threadId, messagesJson, updatedAt },
: { messagesJson, updatedAt },
})
},
}
}
(): {
{
() {
row = db..({ : { runId } })
row ? (row) :
},
() {
row = db..({
: { runId },
: {
runId,
threadId,
: status ?? ,
: (startedAt),
},
: {},
})
(row)
},
() {
: . = {}
(patch. !== ) data. = patch.
(patch. !== ) {
data. = (patch.)
}
(patch. !== ) {
data. = patch..
data. = patch.. ??
}
(patch. !== )
data. = .(patch.)
( patch) data. = patch. ??
( patch) {
data. =
patch. === ? : (patch.)
}
( patch)
data. = patch. ??
( patch) data. = patch. ??
(.(data). === )
db..({ : { runId }, data })
},
() {
row = db..({
: { threadId, : },
: { : },
})
row ? (row) :
},
() {
rows = db..({
: { threadId },
: { : },
})
rows.(mapRun)
},
() {
cutoff = (now - ttlMs)
rows = db..({
: {
: ,
: { : , : cutoff },
},
})
rows.(mapRun)
},
}
}
(): {
= () => {
rows = db..({
where,
: { : },
})
rows.(mapInterrupt)
}
{
() {
db..({
: { : record. },
: {
: record.,
: record.,
: record.,
: ,
: (record.),
: .(record.),
...(record. !==
? { : .(record.) }
: {}),
},
: {},
})
},
() {
db..({
: { interruptId },
: {
: ,
: (.()),
...(response !==
? { : .(response) }
: {}),
},
})
},
() {
db..({
: { interruptId },
: { : , : (.()) },
})
},
() {
row = db..({ : { interruptId } })
row ? (row) :
},
: ({ threadId }),
: ({ threadId, : }),
: ({ runId }),
: ({ runId, : }),
}
}
(): {
{
() {
row = db..({
: { : { namespace, key } },
})
row ? parseJson<>(row.) :
},
() {
(value == ) {
(
,
)
}
valueJson = .(value)
db..({
: { : { namespace, key } },
: { namespace, key, valueJson },
: { valueJson },
})
},
() {
db..({ : { namespace, key } })
},
}
}
: = ({
: {
: (prisma),
: (prisma),
: (prisma),
: (prisma),
},
})
Notes that bite:
updateMany, not update, for patches. update throws
P2025 on a missing row; the contract says a patch to an unknown id is a
silent no-op.
namespace_key is Prisma's generated alias for the @@id([namespace, key])
composite. If you rename the fields, the alias name changes with them.
- Annotate
ChatPersistence — bare AIPersistence is the all-optional bag and
withPersistence rejects it. There is no locks store: stores accepts only
those four keys, and coordination is wired separately with withLocks (see
ai-core/locks).
- If the app renamed the models, the delegate accessors are camelCase
(
prisma.chatThread for model ChatThread), and the row types imported from
the client are PascalCase.
4. Wire it into the chat route
import {
chat,
chatParamsFromRequest,
toServerSentEventsResponse,
} from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { withPersistence } from '@tanstack/ai-persistence'
import { chatPersistence } from '@/lib/chat-persistence'
export async function POST(request: Request) {
const params = await chatParamsFromRequest(request)
const stream = chat({
adapter: openaiText('gpt-5.5'),
messages: params.messages,
threadId: params.threadId,
runId: params.runId,
...(params.resume ? { resume: params.resume } : {}),
middleware: [withPersistence(chatPersistence)],
})
return toServerSentEventsResponse(stream)
}
threadId is a bare string to the stores. Authorize thread access at the
route — derive the user from the session, never trust a client-supplied id.
5. Verify
import { runPersistenceConformance } from '@tanstack/ai-persistence/testkit'
import { chatPersistence } from '../src/lib/chat-persistence'
runPersistenceConformance('app-prisma', () => chatPersistence, {
skip: ['generationRuns', 'artifacts', 'blobs'],
})
Point the client at a throwaway database with the migration applied (a scratch
SQLite file is enough) and reset it between runs. All four state stores are
provided; the suite also covers the three generation stores, so declare those as
skipped until you add them. skip never accepts 'locks', which is not a
store.
If your recipe leaves an optional runs method
(listByThread/listReclaimable) unimplemented, declare it
with skipMethods, e.g. { skipMethods: ['runs.listByThread'] }. An
omitted method that is not declared fails the suite instead of silently
passing.
Only if you are publishing this as a package
Everything above assumes the file lives in the app. For a reusable npm adapter,
the same store bodies apply, plus:
- Peer dep
@prisma/client >=6.7.0. Ship no datasource, generator,
connection URL, or prebuilt SQL migration — those stay in the consumer's
schema.
- Type the client structurally (a
PrismaClientLike shape) and read model
delegates off it at runtime, so Prisma 6 and 7 clients both satisfy it
regardless of where they were generated.
- Ship the models as a raw string asset plus a CLI
(
tanstack-ai-prisma-models) that copies a provider-neutral fragment into the
consumer's multi-file schema directory. They then run prisma migrate.
- Let consumers rename:
prismaPersistence(prisma, { models: { messages: 'chatMessage' } }),
where map values are the camelCase client accessors. Throw a
PrismaModelError naming every store whose delegate cannot be found. Keep the
field surface and the composite-id alias fixed; database names and extra
app-owned fields are theirs.
- Run
runPersistenceConformance over a temporary SQLite database generated
from the fragment.