From 929ee31ba4b75953425234a8feb170914ad9fd5b Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Tue, 27 Oct 2026 19:38:31 +1100 Subject: [PATCH 1/2] docs(ai-persistence): adapter skills always write all four chat tables The Drizzle skill gave SQLite as a full schema block but Postgres only as a prose note, and the docs it points at call interrupts and metadata optional. An agent on Postgres translated by hand and kept only chat_threads and chat_runs. Add a full pg-core block, an all-four rule to the Drizzle, Prisma, and custom recipes, and a done checklist. --- .changeset/persistence-skills-four-tables.md | 5 + .../skills/ai-persistence/SKILL.md | 5 +- .../build-custom-adapter/SKILL.md | 6 + .../build-drizzle-adapter/SKILL.md | 107 ++++++++++++++++-- .../build-prisma-adapter/SKILL.md | 6 + 5 files changed, 117 insertions(+), 12 deletions(-) create mode 100644 .changeset/persistence-skills-four-tables.md diff --git a/.changeset/persistence-skills-four-tables.md b/.changeset/persistence-skills-four-tables.md new file mode 100644 index 0000000000..bce95dfc84 --- /dev/null +++ b/.changeset/persistence-skills-four-tables.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-persistence': patch +--- + +The adapter skills now say to write all four chat tables and stores on every dialect. The Drizzle skill gets a full Postgres schema block instead of a prose note, so an agent does not translate the SQLite schema by hand and drop `interrupts` and `metadata`. diff --git a/packages/ai-persistence/skills/ai-persistence/SKILL.md b/packages/ai-persistence/skills/ai-persistence/SKILL.md index bd627cb2f3..ae58fc1c47 100644 --- a/packages/ai-persistence/skills/ai-persistence/SKILL.md +++ b/packages/ai-persistence/skills/ai-persistence/SKILL.md @@ -111,7 +111,10 @@ reference counts in one transaction. Adding persistence to an app? Pick the recipe that matches what it already runs — each one writes a single `chat-persistence.ts` against the app's -existing database client and schema: +existing database client and schema. Every recipe writes all four chat +stores (messages, runs, interrupts, metadata) and all four tables, on every +dialect; the "which stores do you need" minimums in the docs are for +hand-rolled adapters, not these recipes: | The app runs... | Read | | ---------------------------------------------------- | ------------------------------------------------------- | diff --git a/packages/ai-persistence/skills/ai-persistence/build-custom-adapter/SKILL.md b/packages/ai-persistence/skills/ai-persistence/build-custom-adapter/SKILL.md index 1b9e7b170c..8683908965 100644 --- a/packages/ai-persistence/skills/ai-persistence/build-custom-adapter/SKILL.md +++ b/packages/ai-persistence/skills/ai-persistence/build-custom-adapter/SKILL.md @@ -12,6 +12,12 @@ migration flow. Do not create a package, a second client, or a migration runner. +**Always write all four tables (or collections) 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. + **Route first.** If the app already runs one of these, stop and use that skill — it has the driver-specific code: diff --git a/packages/ai-persistence/skills/ai-persistence/build-drizzle-adapter/SKILL.md b/packages/ai-persistence/skills/ai-persistence/build-drizzle-adapter/SKILL.md index 4425d150bb..24799e4458 100644 --- a/packages/ai-persistence/skills/ai-persistence/build-drizzle-adapter/SKILL.md +++ b/packages/ai-persistence/skills/ai-persistence/build-drizzle-adapter/SKILL.md @@ -13,6 +13,13 @@ 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 @@ -131,17 +138,87 @@ 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`): `jsonb()` for the JSON payloads, -`bigint({ mode: 'number' })` for epoch-ms timestamps (including -`detachedSince`), `integer()` for `driverEpoch`, `boolean()` for -`cancelRequested`, `text()` elsewhere, composite `primaryKey` on -`(namespace, key)` unchanged. **MySQL** -(`drizzle-orm/mysql-core`): `json()`, `bigint({ mode: 'number' })`, -`boolean()` for `cancelRequested`, and `varchar(..., { length: 255 })` for the -primary-key columns. The store bodies below are identical across all three, -only `onConflictDoUpdate` becomes `onDuplicateKeyUpdate` on MySQL, and the -`(status, detachedSince)`, `(threadId, startedAt)`, and -`(parentRunId, startedAt)` indexes carry over as is. +**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. + +```ts ignore +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>().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().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(), + 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().notNull(), + requestedAt: bigint('requested_at', { mode: 'number' }).notNull(), + resolvedAt: bigint('resolved_at', { mode: 'number' }), + payloadJson: jsonb('payload_json').$type>().notNull(), + responseJson: jsonb('response_json').$type(), +}) + +export const chatMetadata = pgTable( + 'chat_metadata', + { + namespace: text('namespace').notNull(), + key: text('key').notNull(), + valueJson: jsonb('value_json').$type().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` @@ -582,6 +659,14 @@ fails the suite instead of silently passing. Subagent support is optional: when `listByParentRun` is absent, the subagent checks skip on their own and need no entry. +## Before you say it is done + +- The schema file has all four tables: threads, runs, interrupts, metadata. +- `chat-persistence.ts` has all four store factories and passes all four to + `defineAIPersistence`. +- The export is annotated `ChatPersistence`. +- The conformance suite in step 5 runs green on the app's dialect. + ## Only if you are publishing this as a package Everything above assumes the file lives in the app. If instead you are shipping diff --git a/packages/ai-persistence/skills/ai-persistence/build-prisma-adapter/SKILL.md b/packages/ai-persistence/skills/ai-persistence/build-prisma-adapter/SKILL.md index 60bb7e1d1e..e1c376639f 100644 --- a/packages/ai-persistence/skills/ai-persistence/build-prisma-adapter/SKILL.md +++ b/packages/ai-persistence/skills/ai-persistence/build-prisma-adapter/SKILL.md @@ -13,6 +13,12 @@ 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 From a64f0be791a3090a3ddb68f4e610818197f4e926 Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Tue, 27 Oct 2026 19:52:19 +1100 Subject: [PATCH 2/2] docs(ai-persistence): skills skip the activities store in the conformance step Verified the Drizzle recipe end to end in a scratch app: the skill's exact Postgres and SQLite schema and store blocks pass runPersistenceConformance on PGlite and libsql. The only failure was the skip list, which predates the activities store. --- .changeset/persistence-skills-four-tables.md | 2 ++ .../ai-persistence/build-cloudflare-adapter/SKILL.md | 8 ++++---- .../skills/ai-persistence/build-custom-adapter/SKILL.md | 8 ++++---- .../skills/ai-persistence/build-drizzle-adapter/SKILL.md | 9 +++++---- .../skills/ai-persistence/build-prisma-adapter/SKILL.md | 8 ++++---- .../ai-persistence/skills/ai-persistence/stores/SKILL.md | 6 +++--- 6 files changed, 22 insertions(+), 19 deletions(-) diff --git a/.changeset/persistence-skills-four-tables.md b/.changeset/persistence-skills-four-tables.md index bce95dfc84..8364f135c3 100644 --- a/.changeset/persistence-skills-four-tables.md +++ b/.changeset/persistence-skills-four-tables.md @@ -3,3 +3,5 @@ --- The adapter skills now say to write all four chat tables and stores on every dialect. The Drizzle skill gets a full Postgres schema block instead of a prose note, so an agent does not translate the SQLite schema by hand and drop `interrupts` and `metadata`. + +The conformance step in every adapter skill now skips the optional `activities` store, which the testkit requires to be provided or declared. The Drizzle recipe's Postgres and SQLite code was run through the testkit on PGlite and libsql and passes. diff --git a/packages/ai-persistence/skills/ai-persistence/build-cloudflare-adapter/SKILL.md b/packages/ai-persistence/skills/ai-persistence/build-cloudflare-adapter/SKILL.md index d757709a9b..9277cbfbd0 100644 --- a/packages/ai-persistence/skills/ai-persistence/build-cloudflare-adapter/SKILL.md +++ b/packages/ai-persistence/skills/ai-persistence/build-cloudflare-adapter/SKILL.md @@ -303,14 +303,14 @@ import { env } from 'cloudflare:test' import { chatPersistence } from '../src/lib/chat-persistence' runPersistenceConformance('app-d1', () => chatPersistence(env.AI_STATE), { - skip: ['generationRuns', 'artifacts', 'blobs'], + skip: ['activities', 'generationRuns', 'artifacts', 'blobs'], }) ``` Run it against a Miniflare D1 binding with the migration applied, reset between -runs. All four state stores are provided; the suite also covers the three -generation stores, so a chat-only adapter declares them skipped (drop the `skip` -once you add the R2-backed set from +runs. All four state stores are provided; the suite also covers the optional +`activities` store and the three generation stores, so a chat-only adapter +declares them skipped (drop the `skip` once you add the R2-backed set from **ai-persistence/build-cloudflare-artifact-store**). `skip` never accepts `'locks'`, which is not a store. diff --git a/packages/ai-persistence/skills/ai-persistence/build-custom-adapter/SKILL.md b/packages/ai-persistence/skills/ai-persistence/build-custom-adapter/SKILL.md index 8683908965..295ed64d06 100644 --- a/packages/ai-persistence/skills/ai-persistence/build-custom-adapter/SKILL.md +++ b/packages/ai-persistence/skills/ai-persistence/build-custom-adapter/SKILL.md @@ -361,13 +361,13 @@ import { runPersistenceConformance } from '@tanstack/ai-persistence/testkit' import { chatPersistence } from '../src/lib/chat-persistence' runPersistenceConformance('app-custom', () => chatPersistence, { - skip: ['generationRuns', 'artifacts', 'blobs'], + skip: ['activities', 'generationRuns', 'artifacts', 'blobs'], }) ``` -Point it at a throwaway database and reset between runs. The suite covers all -seven stores, so declare every intentional omission — a chat adapter skips the -generation half above, and adds e.g. `'metadata'` if it drops that too. `skip` +Point it at a throwaway database and reset between runs. The suite covers +every store, so declare every intentional omission: a chat adapter skips the +optional `activities` store and the generation half, as above. `skip` never accepts `'locks'`, which is not a store. If your recipe leaves `listByThread` or `listReclaimable` unimplemented, diff --git a/packages/ai-persistence/skills/ai-persistence/build-drizzle-adapter/SKILL.md b/packages/ai-persistence/skills/ai-persistence/build-drizzle-adapter/SKILL.md index 24799e4458..cb0711a4aa 100644 --- a/packages/ai-persistence/skills/ai-persistence/build-drizzle-adapter/SKILL.md +++ b/packages/ai-persistence/skills/ai-persistence/build-drizzle-adapter/SKILL.md @@ -642,14 +642,15 @@ import { runPersistenceConformance } from '@tanstack/ai-persistence/testkit' import { chatPersistence } from '../src/lib/chat-persistence' runPersistenceConformance('app-drizzle', () => chatPersistence, { - skip: ['generationRuns', 'artifacts', 'blobs'], + skip: ['activities', 'generationRuns', 'artifacts', 'blobs'], }) ``` Point it at a throwaway database (`:memory:` SQLite, a scratch schema, PGlite) -that has the migration applied, and reset between runs. The suite covers all -seven stores, so a chat adapter declares the generation half it omits; drop the -`skip` once you add those tables. `skip` never accepts `'locks'`, which is not a +that has the migration applied, and reset between runs. The suite covers +every store, so a chat adapter declares what it omits: the optional +`activities` store and the generation half. Drop an entry from `skip` once +you add that table. `skip` never accepts `'locks'`, which is not a store. If your recipe leaves `listByThread` or `listReclaimable` unimplemented, diff --git a/packages/ai-persistence/skills/ai-persistence/build-prisma-adapter/SKILL.md b/packages/ai-persistence/skills/ai-persistence/build-prisma-adapter/SKILL.md index e1c376639f..f93bf2d9b5 100644 --- a/packages/ai-persistence/skills/ai-persistence/build-prisma-adapter/SKILL.md +++ b/packages/ai-persistence/skills/ai-persistence/build-prisma-adapter/SKILL.md @@ -510,15 +510,15 @@ import { runPersistenceConformance } from '@tanstack/ai-persistence/testkit' import { chatPersistence } from '../src/lib/chat-persistence' runPersistenceConformance('app-prisma', () => chatPersistence, { - skip: ['generationRuns', 'artifacts', 'blobs'], + skip: ['activities', '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. +provided; the suite also covers the optional `activities` store and 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 `listByThread` or `listReclaimable` unimplemented, declare it with `skipMethods`, for example diff --git a/packages/ai-persistence/skills/ai-persistence/stores/SKILL.md b/packages/ai-persistence/skills/ai-persistence/stores/SKILL.md index 42c26945c8..ecac178401 100644 --- a/packages/ai-persistence/skills/ai-persistence/stores/SKILL.md +++ b/packages/ai-persistence/skills/ai-persistence/stores/SKILL.md @@ -462,10 +462,10 @@ import { myPersistence } from '../src/persistence' runPersistenceConformance('my-backend', () => myPersistence()) -// Declare intentional omissions. The suite covers all seven stores, so a -// chat-only backend skips the generation half: +// Declare intentional omissions. The suite covers every store, so a +// chat-only backend skips the optional activities store and the generation half: // runPersistenceConformance('chat-only', () => p, { -// skip: ['generationRuns', 'artifacts', 'blobs'], +// skip: ['activities', 'generationRuns', 'artifacts', 'blobs'], // }) // `skip` never accepts 'locks' — locks are not a store.