Skip to main content

ai-persistence-build-prisma-adapter

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.

Source facts

Repository
TanStack/ai
Last source activity
September 24, 2026 at 09:20
Detected SKILL.md language
English
Stars
3,199
Forks
366

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
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. ```prisma 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") parentRunId String? @map("parent_run_id") subagentRunId String? @map("subagent_run_id") name String? @@index([threadId, status]) @@index([threadId, startedAt]) // Powers listByParentRun: children of one parent, oldest startedAt first. @@index([parentRunId, 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. ```ts ignore 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' // Trusts the shape the stores themselves wrote — nothing else writes these // columns. function parseJson<T>(raw: string): T { return JSON.parse(raw) } const RUN_STATUSES: ReadonlyArray<RunStatus> = [ 'running', 'interrupted', 'completed', 'failed', 'aborted', ] const INTERRUPT_STATUSES: ReadonlyArray<InterruptStatus> = [ 'pending', 'resolved', 'cancelled', ] // The column is a plain String, so narrow instead of trusting it. function toRunStatus(value: string): RunStatus { const status = RUN_STATUSES.find((candidate) => candidate === value) if (!status) throw new Error(`Unknown run status: ${value}`) return status } function toInterruptStatus(value: string): InterruptStatus { const status = INTERRUPT_STATUSES.find((candidate) => candidate === value) if (!status) throw new Error(`Unknown interrupt status: ${value}`) return status } // Records omit absent optionals so they compare cleanly against the reference // in-memory backend. function mapRun(row: ChatRun): RunRecord { return { runId: row.runId, threadId: row.threadId, status: toRunStatus(row.status), startedAt: Number(row.startedAt), ...(row.finishedAt != null ? { finishedAt: Number(row.finishedAt) } : {}), ...(row.error != null ? { error: { message: row.error, ...(row.errorCode != null ? { code: row.errorCode } : {}), }, } : {}), ...(row.usageJson != null ? { usage: parseJson<TokenUsage>(row.usageJson) } : {}), ...(row.sandboxKey != null ? { sandboxKey: row.sandboxKey } : {}), ...(row.detachedSince != null ? { detachedSince: Number(row.detachedSince) } : {}), ...(row.cancelRequested != null ? { cancelRequested: row.cancelRequested } : {}), ...(row.driverEpoch != null ? { driverEpoch: row.driverEpoch } : {}), ...(row.parentRunId != null ? { parentRunId: row.parentRunId } : {}), ...(row.subagentRunId != null ? { subagentRunId: row.subagentRunId } : {}), ...(row.name != null ? { name: row.name } : {}), } } function mapInterrupt(row: ChatInterrupt): InterruptRecord { return { interruptId: row.interruptId, runId: row.runId, threadId: row.threadId, status: toInterruptStatus(row.status), requestedAt: Number(row.requestedAt), payload: parseJson<Record<string, unknown>>(row.payloadJson), ...(row.resolvedAt != null ? { resolvedAt: Number(row.resolvedAt) } : {}), ...(row.responseJson != null ? { response: parseJson<unknown>(row.responseJson) } : {}), } } function createMessageStore(db: PrismaClient): MessageStore { return { async loadThread(threadId) { const row = await db.chatThread.findUnique({ where: { threadId } }) // Unknown thread is [], never null. return row ? parseJson<Array<ModelMessage>>(row.messagesJson) : [] }, // Full overwrite — `messages` is the complete authoritative transcript. async saveThread(threadId, messages) { const messagesJson = JSON.stringify(messages) const updatedAt = BigInt(Date.now()) await db.chatThread.upsert({ where: { threadId }, create: { threadId, messagesJson, updatedAt }, update: { messagesJson, updatedAt }, }) }, } } function createRunStore(db: PrismaClient): RunStore { return { async get(runId) { const row = await db.chatRun.findUnique({ where: { runId } }) return row ? mapRun(row) : null }, // An empty `update` is Prisma's ON CONFLICT DO NOTHING: an existing runId // comes back untouched, so resume and double-submit are safe. async createOrResume(input) { const { runId, threadId, startedAt, status } = input const row = await db.chatRun.upsert({ where: { runId }, create: { runId, threadId, status: status ?? 'running', startedAt: BigInt(startedAt), ...(input.parentRunId !== undefined ? { parentRunId: input.parentRunId } : {}), ...(input.subagentRunId !== undefined ? { subagentRunId: input.subagentRunId } : {}), ...(input.name !== undefined ? { name: input.name } : {}), }, update: {}, }) return mapRun(row) }, // Patching an unknown runId is a no-op: never throws, never inserts. async update(runId, patch) { const data: Prisma.ChatRunUpdateManyMutationInput = {} if (patch.status !== undefined) data.status = patch.status if (patch.finishedAt !== undefined) { data.finishedAt = BigInt(patch.finishedAt) } // Both columns move together, so a later code-less failure cannot // leave a stale errorCode from an earlier one behind. if (patch.error !== undefined) { data.error = patch.error.message data.errorCode = patch.error.code ?? null } if (patch.usage !== undefined) data.usageJson = JSON.stringify(patch.usage) // The four durable-run fields use `'field' in patch`, NOT // `!== undefined`: a reattach clears `detachedSince` by passing it // explicitly as `undefined`, and that must still write NULL, not be // silently dropped from the update. Checking `!== undefined` cannot // tell "clear this" from "didn't mention this", and would leave every // reattached run looking permanently detached to the reaper. Same // reasoning for `cancelRequested` (`false` is a meaningful value). if ('sandboxKey' in patch) data.sandboxKey = patch.sandboxKey ?? null if ('detachedSince' in patch) { data.detachedSince = patch.detachedSince === undefined ? null : BigInt(patch.detachedSince) } if ('cancelRequested' in patch) data.cancelRequested = patch.cancelRequested ?? null if ('driverEpoch' in patch) data.driverEpoch = patch.driverEpoch ?? null if (Object.keys(data).length === 0) return await db.chatRun.updateMany({ where: { runId }, data }) }, // Optional in the contract; enables reconnect without a client-held run id. async findActiveRun(threadId) { const row = await db.chatRun.findFirst({ where: { threadId, status: 'running' }, orderBy: { startedAt: 'desc' }, }) return row ? mapRun(row) : null }, // Optional; every run for the thread, ascending by startedAt. Uses the // (threadId, startedAt) index. async listByThread(threadId) {
View on GitHub
This SKILL.md is very large, so SkillsMP previews the first section here. View on GitHub