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

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

Trust tier 3
  • community
Source check pending

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

Use when an app already runs Drizzle ORM and needs TanStack AI chat persistence — writes a chat-persistence.ts into the app against its existing db handle, schema file, and drizzle-kit journal. Covers the four tables (SQLite/Postgres/MySQL), the onConflict idempotency rules, JSON columns, and per-request bindings like D1.

Install

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

SKILL.md

Drizzle Chat Persistence

The deliverable is one file in the app — src/lib/chat-persistence.ts — exporting a ChatPersistence built from the app's existing Drizzle db. Plus four tables added to the app's existing schema file and a migration generated through the app's existing drizzle-kit setup.

Do not create a package, a second db instance, a migration runner, or a drizzle.config.ts. The app has those.

Always write all four tables and all four stores: threads, runs, interrupts, metadata. This is true on every dialect. 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 in step 5 fails without them. Do not stop at chat_threads + chat_runs.

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
Dialectdrizzle.config.ts dialect:, or the drizzle-orm/*-core importsqlite-core vs pg-core vs mysql-core column builders
Schema file(s)drizzle.config.ts schema: globWhere the four tables go — append, never start a new file
The db handlesrc/db/index.ts, src/db.ts, src/server/db.tsModule singleton (export const db) vs factory (getDb())
Migration flowdrizzle.config.ts out:, the migrations/ or drizzle/ journalWhich generate/apply commands to tell the user to run
Naming conventionsExisting tables in the schema fileTable prefix, var casing, snake_case column names
Import aliastsconfig.json paths@/db, ~/db, #/db/index, or a relative path

Match what is already there. If their tables are chat_*-prefixed and their vars are camelCase, so are yours. If they already have a messages table for something else, prefix — the store code reads database names off the table objects, so any name works.

Never invent a migration path. Add the tables to their schema file, then have them run their own commands (npx drizzle-kit generate then migrate/push, or wrangler d1 migrations apply for D1). A parallel migration table behind their back is how schemas drift.

2. Add the tables to their schema file

SQLite. JSON payloads use text({ mode: 'json' }) so Drizzle round-trips objects for you; timestamps are integer epoch ms.

<!-- fence: drizzle-sqlite-schema -->

ts
import {
index,
integer,
primaryKey,
sqliteTable,
text,
} from 'drizzle-orm/sqlite-core'
import type { ModelMessage, TokenUsage } from '@tanstack/ai'
import type { InterruptRecord, RunStatus } from '@tanstack/ai-persistence'
export const chatThreads = sqliteTable('chat_threads', {
threadId: text('thread_id').primaryKey(),
messagesJson: text('messages_json', { mode: 'json' })
.$type<Array<ModelMessage>>()
.notNull(),
updatedAt: integer('updated_at').notNull(),
})
export const chatRuns = sqliteTable(
'chat_runs',
{
runId: text('run_id').primaryKey(),
threadId: text('thread_id').notNull(),
status: text('status').$type<RunStatus>().notNull(),
startedAt: integer('started_at').notNull(),
finishedAt: integer('finished_at'),
error: text('error'),
errorCode: text('error_code'),
usageJson: text('usage_json', { mode: 'json' }).$type<TokenUsage>(),
sandboxKey: text('sandbox_key'),
detachedSince: integer('detached_since'),
cancelRequested: integer('cancel_requested', { mode: 'boolean' }),
driverEpoch: integer('driver_epoch'),
parentRunId: text('parent_run_id'),
subagentRunId: text('subagent_run_id'),
name: text('name'),
},
(table) => [
// Powers listReclaimable: status = 'running' AND detachedSince <= cutoff.
index('chat_runs_status_detached').on(table.status, table.detachedSince),
// Powers listByThread and findActiveRun.
index('chat_runs_thread_started').on(table.threadId, table.startedAt),
// Powers listByParentRun: children of one parent, oldest startedAt first.
index('chat_runs_parent_started').on(table.parentRunId, table.startedAt),
],
)
export const chatInterrupts = sqliteTable('chat_interrupts', {
interruptId: text('interrupt_id').primaryKey(),
runId: text('run_id').notNull(),
threadId: text('thread_id').notNull(),
status: text('status').$type<InterruptRecord['status']>().notNull(),
requestedAt: integer('requested_at').notNull(),
resolvedAt: integer('resolved_at'),
payloadJson: text('payload_json', { mode: 'json' })
.$type<Record<string, unknown>>()
.notNull(),
responseJson: text('response_json', { mode: 'json' }).$type<unknown>(),
})
export const chatMetadata = sqliteTable(
'chat_metadata',
{
namespace: text('namespace').notNull(),
key: text('key').notNull(),
valueJson: text('value_json', { mode: 'json' }).$type<unknown>().notNull(),
},
(table) => [primaryKey({ columns: [table.namespace, table.key] })],
)

updatedAt on threads is an app-owned extra, not part of any contract — the stores never read columns they do not know about, so add userId, tenant ids, or audit columns the same way (nullable or defaulted so inserts still succeed). The namespace column 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.

Postgres (drizzle-orm/pg-core). Same four tables. jsonb() for the JSON payloads, bigint({ mode: 'number' }) for epoch-ms timestamps, boolean() for cancelRequested, integer() for driverEpoch. Copy this block; do not translate the SQLite one by hand.

<!-- fence: drizzle-pg-schema -->

ts
import {
bigint,
boolean,
index,
integer,
jsonb,
pgTable,
primaryKey,
text,
} from 'drizzle-orm/pg-core'
import type { ModelMessage, TokenUsage } from '@tanstack/ai'
import type { InterruptRecord, RunStatus } from '@tanstack/ai-persistence'
export const chatThreads = pgTable('chat_threads', {
threadId: text('thread_id').primaryKey(),
messagesJson: jsonb('messages_json').$type<Array<ModelMessage>>().notNull(),
updatedAt: bigint('updated_at', { mode: 'number' }).notNull(),
})
export const chatRuns = pgTable(
'chat_runs',
{
runId: text('run_id').primaryKey(),
threadId: text('thread_id').notNull(),
status: text('status').$type<RunStatus>().notNull(),
startedAt: bigint('started_at', { mode: 'number' }).notNull(),
finishedAt: bigint('finished_at', { mode: 'number' }),
error: text('error'),
errorCode: text('error_code'),
usageJson: jsonb('usage_json').$type<TokenUsage>(),
sandboxKey: text('sandbox_key'),
detachedSince: bigint('detached_since', { mode: 'number' }),
cancelRequested: boolean('cancel_requested'),
driverEpoch: integer('driver_epoch'),
parentRunId: text('parent_run_id'),
subagentRunId: text('subagent_run_id'),
name: text('name'),
},
(table) => [
index('chat_runs_status_detached').on(table.status, table.detachedSince),
index('chat_runs_thread_started').on(table.threadId, table.startedAt),
index('chat_runs_parent_started').on(table.parentRunId, table.startedAt),
],
)
export const chatInterrupts = pgTable('chat_interrupts', {
interruptId: text('interrupt_id').primaryKey(),
runId: text('run_id').notNull(),
threadId: text('thread_id').notNull(),
status: text('status').$type<InterruptRecord['status']>().notNull(),
requestedAt: bigint('requested_at', { mode: 'number' }).notNull(),
resolvedAt: bigint('resolved_at', { mode: 'number' }),
payloadJson: jsonb('payload_json').$type<Record<string, unknown>>().notNull(),
responseJson: jsonb('response_json').$type<unknown>(),
})
export const chatMetadata = pgTable(
'chat_metadata',
{
namespace: text('namespace').notNull(),
key: text('key').notNull(),
valueJson: jsonb('value_json').$type<unknown>().notNull(),
},
(table) => [primaryKey({ columns: [table.namespace, table.key] })],
)

MySQL (drizzle-orm/mysql-core): take the Postgres block and swap pgTable for mysqlTable, jsonb() for json(), and text() for varchar(..., { length: 255 }) on the primary-key columns. bigint({ mode: 'number' }), boolean(), integer(), the indexes, and the composite primary key stay as they are.

The store bodies in step 3 are identical across all three dialects. Only onConflictDoUpdate becomes onDuplicateKeyUpdate on MySQL.

3. Write src/lib/chat-persistence.ts

The whole file. Idempotency is the entire game — the comments below mark the rules the conformance suite checks.

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

ts
import { and, asc, desc, eq, isNotNull, lte } from 'drizzle-orm'
import { defineAIPersistence } from '@tanstack/ai-persistence'
import type { SQL } from 'drizzle-orm'
import type {
ChatPersistence,
InterruptRecord,
InterruptStore,
MessageStore,
MetadataStore,
RunRecord,
RunStore,
} from '@tanstack/ai-persistence'
import { db } from '@/db'
import {
chatInterrupts,
chatMetadata,
chatRuns,
chatThreads,
} from '@/db/schema'
type Db = typeof db
// Records omit absent optionals so they compare cleanly against the reference
// in-memory backend.
function mapRun(row: typeof chatRuns.$inferSelect): RunRecord {
return {
runId: row.runId,
threadId: row.threadId,
status: row.status,
startedAt: row.startedAt,
...(row.finishedAt != null ? { finishedAt: row.finishedAt } : {}),
...(row.error != null
? {
error: {
message: row.error,
...(row.errorCode != null ? { code: row.errorCode } : {}),
},
}
: {}),
...(row.usageJson != null ? { usage: row.usageJson } : {}),
...(row.sandboxKey != null ? { sandboxKey: row.sandboxKey } : {}),
...(row.detachedSince != null ? { detachedSince: 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: typeof chatInterrupts.$inferSelect,
): InterruptRecord {
return {
interruptId: row.interruptId,
runId: row.runId,
threadId: row.threadId,
status: row.status,
requestedAt: row.requestedAt,
payload: row.payloadJson,
...(row.resolvedAt != null ? { resolvedAt: row.resolvedAt } : {}),
...(row.responseJson != null ? { response: row.responseJson } : {}),
}
}

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

Security

  • Security scanPassed, 95 of 100Pattern 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-drizzle-adapter/badge)](https://verified-skill.com/skills/lukasa1993/tanstack-skills-extracted/tanstack-ai-persistence-build-drizzle-adapter)

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