lukasa1993/tanstack-skills-extracted/tanstack-ai-persistence-build-prisma-adapter

Claude Code and agent skill by lukasa1993/tanstack-skills-extracted

Trust tier 1
  • community
Source check pending

Checking that the SKILL.md file is still in the repository.

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.

Install

npx vskill@latest install lukasa1993/tanstack-skills-extracted/tanstack-ai-persistence-build-prisma-adapter

SKILL.md

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.

Always write all four models and all four stores: thread, run, interrupt, metadata. The docs call interrupts and metadata optional for a hand-rolled adapter; this recipe is not that case. withPersistence needs all four to type-check as ChatPersistence, and the conformance suite fails without them. Do not stop at a thread table and a run table.

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

FindWhere to lookWhat it decides
Schema locationprisma/schema.prisma, or a multi-file prisma/schema/ dirAppend to the existing file, or add one new .prisma file
Providerthe datasource blockWhether Json is available; nothing else changes
Client singletonsrc/lib/prisma.ts, src/db.ts, globalThis dev cacheWhat chat-persistence.ts imports — never new PrismaClient()
Generated clientthe generator client block (output, prisma-client-js vs prisma-client)Where ChatRun/ChatInterrupt row types come from
Existing model namesthe schemaWhether Message/Run are taken — prefix if so
Migration flowprisma/migrations/, or db push in scriptsprisma 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.

<!-- fence: prisma-chat-persistence -->

ts
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 },
})
},
}
}

This is the start of the file. Read the full SKILL.md on GitHub.

Security

  • Security scanPassedPattern checks on the skill's files.
  • Model reviewPassed

Quality evaluation

  • Task evaluationNot runNo evaluation yet. Run one in Skill Studio.

Source

Popularity

7-day trend score
0

Not refreshed yet.

Works with

Any agent vskill installs to: 56 targets in all. These 39 have a page on this site.

  • Claude Code
  • Cursor
  • GitHub Copilot
  • Windsurf
  • Codex
  • Gemini CLI
  • Cline
  • Amp
  • Roo Code
  • Goose
  • Continue
  • Kilo Code
  • Junie
  • Trae
  • OpenHands
  • Qwen Code
  • AI Agent
  • Tabnine Chat
  • Sourcegraph Cody
  • Amazon Q
  • Firebase Genkit
  • Sweep
  • Devin
  • Phind
  • Mentat
  • Claude Desktop
  • Aider
  • GPT Pilot
  • Plandex
  • bolt.new
  • v0
  • Replit Agent
  • Anse
  • TypingMind
  • BetterChatGPT
  • LibreChat
  • Warp AI
  • Pieces
  • Fig AI
Embed badge
vskill VERIFIED badge
[![vskill VERIFIED](https://verified-skill.com/api/v1/skills/lukasa1993/tanstack-skills-extracted/tanstack-ai-persistence-build-prisma-adapter/badge)](https://verified-skill.com/skills/lukasa1993/tanstack-skills-extracted/tanstack-ai-persistence-build-prisma-adapter)

Something wrong with this skill or its page? Tell us.