채팅 어댑터 만들기
트랜스크립트, 실행 수명 주기, 영속적인 승인을 자체 데이터베이스에 저장하고 싶으며,
서비스를 추가하기보다 작은 스토어 네 개를 직접 작성하는 편을 선호할 수 있습니다. 이
페이지에서는 앱이 일반적으로 확장되는 순서에 따라 SQLite(Node에 내장된 node:sqlite)를
사용해 이를 처음부터 끝까지 모두 구축합니다.
먼저 어댑터 직접 만들기에서 어댑터의 형태와 앱에 필요한
스토어를 확인합니다. 모든 메서드 시그니처와 불변식은 스토어 참조에
있습니다. 이 안내서의 실행 가능한 버전은 examples/ts-react-chat 앱
(src/lib/sqlite-persistence.ts)에 있습니다.
1. 스키마
테이블은 네 개입니다. JSON 페이로드는 텍스트로 저장하고(SQLite에는 JSON 열 타입이 없음), 타임스탬프는 정수(에포크 밀리초)로 저장하며, 모든 키는 스토어 메서드가 레코드를 조회하는 방식에 맞춥니다.
CREATE TABLE IF NOT EXISTS messages (
thread_id text PRIMARY KEY NOT NULL,
messages_json text NOT NULL
);
CREATE TABLE IF NOT EXISTS runs (
run_id text PRIMARY KEY NOT NULL,
thread_id text NOT NULL,
status text NOT NULL,
started_at integer NOT NULL,
finished_at integer,
error text,
error_code text,
usage_json text,
sandbox_key text,
detached_since integer,
cancel_requested integer,
driver_epoch integer
);
CREATE TABLE IF NOT EXISTS interrupts (
interrupt_id text PRIMARY KEY NOT NULL,
run_id text NOT NULL,
thread_id text NOT NULL,
status text NOT NULL,
requested_at integer NOT NULL,
resolved_at integer,
payload_json text NOT NULL,
response_json text
);
CREATE TABLE IF NOT EXISTS metadata (
scope text NOT NULL,
key text NOT NULL,
value_json text NOT NULL,
PRIMARY KEY (scope, key)
);
2. 메시지: 전체 트랜스크립트 덮어쓰기
다음 두 계약을 지켜야 합니다.
saveThread는 항상 완전하고 권위 있는 기록을 받습니다. 추가가 아니라 교체입니다.loadThread는 한 번도 저장되지 않은 스레드에 대해[]를 반환하며,null을 반환하지 않습니다.
import { DatabaseSync } from 'node:sqlite'
import { defineMessageStore } from '@tanstack/ai-persistence'
import type { ModelMessage } from '@tanstack/ai'
// `defineMessageStore` types the object inline against the contract, so you get
// autocomplete and checking with no separate `: MessageStore` annotation.
function createMessageStore(db: DatabaseSync) {
const select = db.prepare(
'SELECT messages_json FROM messages WHERE thread_id = ?',
)
const upsert = db.prepare(
`INSERT INTO messages (thread_id, messages_json) VALUES (?, ?)
ON CONFLICT(thread_id) DO UPDATE SET messages_json = excluded.messages_json`,
)
return defineMessageStore({
async loadThread(threadId) {
const json = select.get(threadId)?.messages_json
// Unknown thread → [] (never null). `node:sqlite` types columns as a
// SQL-value union, so narrow to string before parsing (no cast).
if (typeof json !== 'string') return []
const parsed: Array<ModelMessage> = JSON.parse(json)
return parsed
},
async saveThread(threadId, messages) {
upsert.run(threadId, JSON.stringify(messages))
},
})
}
메서드는 async이므로 node:sqlite(동기 드라이버)에 Promise.resolve 래퍼가
필요하지 않습니다. async는 반환된 값을 프로미스로 승격하며, 아무것도 반환하지 않는
메서드는 void로 확인됩니다. 비동기 드라이버에서는 대신 쿼리를 await합니다.
3. 실행: 멱등적 생성, 패치, 조회
다음 두 계약을 지켜야 합니다.
createOrResume는 멱등적이어야 합니다. 실행 ID가 이미 있으면 저장된 레코드를 변경하지 않고 반환하므로, 실행을 재개해도startedAt이나 상태가 초기화되지 않습니다.INSERT ... ON CONFLICT DO NOTHING한 문으로 이를 구현할 수 있습니다.- 알 수 없는 실행 ID에 대한
update는 아무 작업도 하지 않습니다.
import { DatabaseSync } from 'node:sqlite'
import { defineRunStore } from '@tanstack/ai-persistence'
import type { RunRecord, RunStatus } from '@tanstack/ai-persistence'
// The `status` column is text; validate it back into the union (no cast).
function toRunStatus(value: unknown): RunStatus {
switch (value) {
case 'running':
case 'interrupted':
case 'completed':
case 'failed':
case 'aborted':
return value
default:
throw new TypeError(`Unexpected run status: ${String(value)}`)
}
}
// `node:sqlite` types columns as a SQL-value union, so coerce/narrow each field
// (String / Number / typeof) rather than casting the whole row.
function mapRun(row: Record<string, unknown>): RunRecord {
return {
runId: String(row.run_id),
threadId: String(row.thread_id),
status: toRunStatus(row.status),
startedAt: Number(row.started_at),
...(row.finished_at != null ? { finishedAt: Number(row.finished_at) } : {}),
// `error` is a `RunError`: the provider's prose in `error`, its stable
// classification in `error_code`. Two columns rather than one JSON blob,
// because `code` is the field an operator filters and groups by
// (`WHERE error_code = 'rate_limited'`), and this schema keeps the `_json`
// suffix for columns that really hold serialized JSON. Omit `code` when the
// column is NULL so the record matches an error that carried no code.
...(typeof row.error === 'string'
? {
error: {
message: row.error,
...(typeof row.error_code === 'string'
? { code: row.error_code }
: {}),
},
}
: {}),
...(typeof row.usage_json === 'string'
? { usage: JSON.parse(row.usage_json) }
: {}),
...(typeof row.sandbox_key === 'string'
? { sandboxKey: row.sandbox_key }
: {}),
...(row.detached_since != null
? { detachedSince: Number(row.detached_since) }
: {}),
// SQLite has no boolean column type; store it as 0/1 in an integer column
// and convert back here, the same way `detached_since` round-trips epoch ms.
...(row.cancel_requested != null
? { cancelRequested: Boolean(row.cancel_requested) }
: {}),
// The fencing token a takeover bumps. Round-trip it or single-writer
// fencing silently does nothing: a superseded host re-reads its own epoch,
// never sees a higher one, and keeps appending to a log it no longer owns.
...(row.driver_epoch != null
? { driverEpoch: Number(row.driver_epoch) }
: {}),
}
}
function createRunStore(db: DatabaseSync) {
const select = db.prepare('SELECT * FROM runs WHERE run_id = ?')
const insert = db.prepare(
`INSERT INTO runs (run_id, thread_id, status, started_at) VALUES (?, ?, ?, ?)
ON CONFLICT(run_id) DO NOTHING`,
)
const active = db.prepare(
`SELECT * FROM runs WHERE thread_id = ? AND status = 'running'
ORDER BY started_at DESC LIMIT 1`,
)
const byThread = db.prepare(
'SELECT * FROM runs WHERE thread_id = ? ORDER BY started_at ASC',
)
const reclaimable = db.prepare(
`SELECT * FROM runs WHERE status = 'running' AND detached_since IS NOT NULL
AND detached_since <= ? ORDER BY started_at ASC`,
)
return defineRunStore({
async createOrResume(input) {
const existing = select.get(input.runId)
if (existing) return mapRun(existing)
const status: RunStatus = input.status ?? 'running'
insert.run(input.runId, input.threadId, status, input.startedAt)
return {
runId: input.runId,
threadId: input.threadId,
status,
startedAt: input.startedAt,
}
},
async update(runId, patch) {
const sets: Array<string> = []
const params: Array<string | number | null> = []
if (patch.status !== undefined) {
sets.push('status = ?')
params.push(patch.status)
}
if (patch.finishedAt !== undefined) {
sets.push('finished_at = ?')
params.push(patch.finishedAt)
}
if (patch.error !== undefined) {
// Write both halves together, so a later failure that carries no `code`
// cannot leave the previous failure's code behind.
sets.push('error = ?', 'error_code = ?')
params.push(patch.error.message, patch.error.code ?? null)
}
if (patch.usage !== undefined) {
sets.push('usage_json = ?')
params.push(JSON.stringify(patch.usage))
}
// SANDBOX ONLY, skip these four unless you run durable sandboxed runs:
// https://tanstack.com/ai/latest/docs/persistence/build-a-sandbox-adapter
// A chat app never writes them and nothing here reads them.
//
// They are the fields a caller CLEARS by writing `undefined` explicitly, so
// these branches key off key presence (`'field' in patch`), not
// `!== undefined`.
if ('sandboxKey' in patch) {
sets.push('sandbox_key = ?')
params.push(patch.sandboxKey ?? null)
}
if ('detachedSince' in patch) {
sets.push('detached_since = ?')
params.push(patch.detachedSince ?? null)
}
if ('cancelRequested' in patch) {
sets.push('cancel_requested = ?')
params.push(
patch.cancelRequested === undefined
? null
: patch.cancelRequested
? 1
: 0,
)
}
if ('driverEpoch' in patch) {
sets.push('driver_epoch = ?')
params.push(patch.driverEpoch ?? null)
}
if (sets.length === 0) return
params.push(runId)
db.prepare(`UPDATE runs SET ${sets.join(', ')} WHERE run_id = ?`).run(
...params,
)
},
async get(runId) {
const row = select.get(runId)
return row ? mapRun(row) : null
},
// The most recent still-running run for a thread. `reconstructChat` calls
// this so a hydrating client (a reload, another device, or switching back to
// a generating thread) learns there is a live run and tails it. Stub it to
// null and the thread always looks idle on hydrate: the transcript restores,
// but a reply that was mid-stream never resumes.
async findActiveRun(threadId) {
const row = active.get(threadId)
return row ? mapRun(row) : null
},
// Every run for a thread, oldest first. Optional: only needed to render a
// thread's past agent activity.
async listByThread(threadId) {
return byThread.all(threadId).map(mapRun)
},
// Runs the reaper sweeps: still `running`, and detached since before
// `now - ttlMs`. `withSandbox`'s detach path sets `detachedSince` for you,
// and `reapDetachedRuns` from `@tanstack/ai-sandbox` consumes this list;
// this method only answers the query, scheduling that sweep is the app's
// job. Optional, like the others above.
async listReclaimable({ now, ttlMs }) {
return reclaimable.all(now - ttlMs).map(mapRun)
},
})
}
update는 패치에 있는 필드만으로 SET 목록을 구성하므로 빈 패치는 아무것도 변경하지
않고, 부분 패치는 다른 열을 그대로 둡니다. 누락된 선택적 필드를 제외하고 JSON 열을
파싱하는 작은 헬퍼로 각 행을 다시 매핑합니다.
누락된 것과 명시적인 undefined는 같지 않습니다. 키를 생략한 패치는 열을 그대로
둬야 하며, undefined 값을 가진 키를 포함한 패치는 해당 값을 삭제해야 합니다. 현재
이 방식으로 삭제되는 것은 detachedSince뿐입니다(뷰어가 다시 연결될 때 인계 경로가
{ detachedSince: undefined }를 기록함). 그러나 ORM의 "set" 빌더가 undefined를 처리하는
방식(예를 들어 Drizzle의 .set()은 이러한 열을 제거함)에 따라 이 차이를 놓치기 쉽습니다.
잘못 처리해도 예외가 발생하지 않고 실행이 계속 분리된 것처럼 보입니다. listReclaimable로
정리할 계획이라면 runs(status, detached_since)를 인덱싱합니다.
4. 인터럽트: 없을 때 삽입, 순서가 지정된 목록
create는 없을 때 삽입합니다. 중복 인터럽트 ID가 이미 해결된 인터럽트를 덮어쓰면 안
됩니다. 모든 list* 메서드는 requested_at 오름차순으로 정렬된 레코드를 반환하며,
미들웨어는 이 순서에 의존합니다.
import { DatabaseSync } from 'node:sqlite'
import { defineInterruptStore } from '@tanstack/ai-persistence'
import type {
InterruptRecord,
InterruptStatus,
} from '@tanstack/ai-persistence'
function toInterruptStatus(value: unknown): InterruptStatus {
switch (value) {
case 'pending':
case 'resolved':
case 'cancelled':
return value
default:
throw new TypeError(`Unexpected interrupt status: ${String(value)}`)
}
}
function mapInterrupt(row: Record<string, unknown>): InterruptRecord {
return {
interruptId: String(row.interrupt_id),
runId: String(row.run_id),
threadId: String(row.thread_id),
status: toInterruptStatus(row.status),
requestedAt: Number(row.requested_at),
...(row.resolved_at != null ? { resolvedAt: Number(row.resolved_at) } : {}),
payload:
typeof row.payload_json === 'string' ? JSON.parse(row.payload_json) : {},
...(typeof row.response_json === 'string'
? { response: JSON.parse(row.response_json) }
: {}),
}
}
function createInterruptStore(db: DatabaseSync) {
const insert = db.prepare(
`INSERT INTO interrupts
(interrupt_id, run_id, thread_id, status, requested_at, payload_json, response_json)
VALUES (?, ?, ?, 'pending', ?, ?, ?)
ON CONFLICT(interrupt_id) DO NOTHING`,
)
const resolveRow = db.prepare(
`UPDATE interrupts SET status = 'resolved', resolved_at = ?, response_json = ?
WHERE interrupt_id = ?`,
)
const cancelRow = db.prepare(
`UPDATE interrupts SET status = 'cancelled', resolved_at = ? WHERE interrupt_id = ?`,
)
const selectOne = db.prepare('SELECT * FROM interrupts WHERE interrupt_id = ?')
// Every listing is ORDER BY requested_at ASC, which the middleware relies on.
const byThread = db.prepare(
'SELECT * FROM interrupts WHERE thread_id = ? ORDER BY requested_at ASC',
)
const pendingByThread = db.prepare(
`SELECT * FROM interrupts WHERE thread_id = ? AND status = 'pending'
ORDER BY requested_at ASC`,
)
const byRun = db.prepare(
'SELECT * FROM interrupts WHERE run_id = ? ORDER BY requested_at ASC',
)
const pendingByRun = db.prepare(
`SELECT * FROM interrupts WHERE run_id = ? AND status = 'pending'
ORDER BY requested_at ASC`,
)
return defineInterruptStore({
async create(record) {
// Insert-if-absent: a duplicate id must never clobber an already-resolved
// interrupt back to pending.
insert.run(
record.interruptId,
record.runId,
record.threadId,
record.requestedAt,
JSON.stringify(record.payload),
record.response === undefined ? null : JSON.stringify(record.response),
)
},
async resolve(interruptId, response) {
resolveRow.run(
Date.now(),
response === undefined ? null : JSON.stringify(response),
interruptId,
)
},
async cancel(interruptId) {
cancelRow.run(Date.now(), interruptId)
},
async get(interruptId) {
const row = selectOne.get(interruptId)
return row ? mapInterrupt(row) : null
},
async list(threadId) {
return byThread.all(threadId).map(mapInterrupt)
},
async listPending(threadId) {
return pendingByThread.all(threadId).map(mapInterrupt)
},
async listByRun(runId) {
return byRun.all(runId).map(mapInterrupt)
},
async listPendingByRun(runId) {
return pendingByRun.all(runId).map(mapInterrupt)
},
})
}
5. 메타데이터: nullish 거부
(scope, key)는 복합 식별자입니다. SQL 백엔드는 NOT NULL 텍스트 열에 nullish 값을
저장할 수 없으므로, 모호한 드라이버 오류 대신 명확한 오류와 함께 null 및 undefined를
거부합니다. 호출자는 delete로 값을 삭제합니다.
import { DatabaseSync } from 'node:sqlite'
import { defineMetadataStore } from '@tanstack/ai-persistence'
function createMetadataStore(db: DatabaseSync) {
const select = db.prepare(
'SELECT value_json FROM metadata WHERE scope = ? AND key = ?',
)
const upsert = db.prepare(
`INSERT INTO metadata (scope, key, value_json) VALUES (?, ?, ?)
ON CONFLICT(scope, key) DO UPDATE SET value_json = excluded.value_json`,
)
return defineMetadataStore({
async get(scope, key) {
const json = select.get(scope, key)?.value_json
return typeof json === 'string' ? JSON.parse(json) : null
},
async set(scope, key, value) {
if (value == null) {
throw new TypeError(
'Metadata values must be defined, non-null JSON. Use delete() to clear.',
)
}
upsert.run(scope, key, JSON.stringify(value))
},
async delete(scope, key) {
db.prepare('DELETE FROM metadata WHERE scope = ? AND key = ?').run(
scope,
key,
)
},
})
}
6. 어댑터 조립
데이터베이스를 열고 테이블을 생성한 다음 스토어를 AIPersistence로 반환합니다.
defineAIPersistence는 타입에 정확한 스토어 키를 유지하며 런타임에 알 수 없는 키를
거부합니다.
import { DatabaseSync } from 'node:sqlite'
import { defineAIPersistence } from '@tanstack/ai-persistence'
import type { ChatPersistence } from '@tanstack/ai-persistence'
// The four store factories and the schema string, each from your own module.
import { createInterruptStore } from './interrupt-store'
import { createMessageStore } from './message-store'
import { createMetadataStore } from './metadata-store'
import { createRunStore } from './run-store'
import { SCHEMA_SQL } from './schema'
export function sqlitePersistence(options: {
url: string
migrate?: boolean
}): ChatPersistence {
const db = new DatabaseSync(options.url)
if (options.migrate) db.exec(SCHEMA_SQL)
return defineAIPersistence({
stores: {
messages: createMessageStore(db),
runs: createRunStore(db),
interrupts: createInterruptStore(db),
metadata: createMetadataStore(db),
},
})
}
이것으로 완전한 백엔드가 구성됩니다. 워커 간 뮤텍스도 필요하다면 withLocks를 함께
추가합니다. 잠금을 참조합니다.
다른 영속성과 동일한 방식으로 chat()에 연결합니다.
import {
chat,
chatParamsFromRequest,
toServerSentEventsResponse,
} from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { withPersistence } from '@tanstack/ai-persistence'
import { persistence } from './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(persistence)],
})
return toServerSentEventsResponse(stream)
}
다음 단계
- 생성 어댑터 만들기: 미디어 생성을 위한 생성 실행, 아티팩트, 블롭입니다.
- 어댑터 직접 만들기: 방금 만든 구현에 대해 적합성 테스트 모음을 실행합니다.
- 마이그레이션: 스키마를 누가 소유하고 언제 변경 사항을 적용할지 설명합니다.