Repository navigation
docs(ai-persistence): adapter skills always write all four chat tables #1918
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| --- | ||
| '@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`. | ||
|
|
||
| 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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<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. | ||
|
Comment on lines
+214
to
+218
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Make all indexed MySQL strings indexable. The note changes only primary-key columns to Drizzle maps these to MySQL
|
||
|
|
||
| The store bodies in step 3 are identical across all three dialects. Only | ||
| `onConflictDoUpdate` becomes `onDuplicateKeyUpdate` on MySQL. | ||
|
Comment on lines
+220
to
+221
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Translate every conflict operation for MySQL. The store code uses Following this guidance leaves the MySQL recipe with unsupported conflict calls and breaks its insert-if-absent paths. Use
|
||
|
|
||
| ## 3. Write `src/lib/chat-persistence.ts` | ||
|
|
||
|
|
@@ -565,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, | ||
|
|
@@ -582,6 +660,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. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| ## Only if you are publishing this as a package | ||
|
|
||
| Everything above assumes the file lives in the app. If instead you are shipping | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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'], | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Correct the documented The example includes
|
||
| // }) | ||
| // `skip` never accepts 'locks' — locks are not a store. | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Keep
activitiesskipped until the adapter implements it.The artifact-store recipe covers
artifactsandblobsand requires a separategenerationRunsimplementation. It does not addactivities. Removing the entireskiplist therefore makes conformance fail unless all omitted stores are implemented. Remove entries individually instead. (raw.githubusercontent.com)SkillSpector (2.12.0)
[warning] 95: [P9] Whitespace Padding: Large whitespace padding was detected (a block of blank lines or a long run of spaces). This can push injected instructions below or to the right of the visible area so a human reviewer never sees them while the agent still reads them. Manual review of the hidden content is recommended.
Remediation: Remove the large whitespace padding (blank-line blocks or long space runs) and review any content hidden below or to the right of it. Keep skill files compact and reviewable so no instructions can be concealed off-screen.
(Prompt Injection (P9))
[warning] 96: [P9] Whitespace Padding: Large whitespace padding was detected (a block of blank lines or a long run of spaces). This can push injected instructions below or to the right of the visible area so a human reviewer never sees them while the agent still reads them. Manual review of the hidden content is recommended.
Remediation: Remove the large whitespace padding (blank-line blocks or long space runs) and review any content hidden below or to the right of it. Keep skill files compact and reviewable so no instructions can be concealed off-screen.
(Prompt Injection (P9))