Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/persistence-skill-checks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/ai-persistence': patch
---

Persistence adapter skills now type-check, and the Drizzle and custom Postgres recipes run the persistence conformance suite. The custom recipe includes the interrupt and metadata stores. The Prisma recipe is type-checked only, because it needs a generated client.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ docs/superpowers
solo.yml

# Agent scratch output (gap-analysis reports, triage notes — generated locally)
packages/ai-persistence/skills-test/.generated
.agent/gap-analysis/
.agent/triage/
.agent/pr-sweep/
Expand Down
3 changes: 2 additions & 1 deletion knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@
"packages/ai-openai/src/**/*.test.ts",
"packages/ai-openai/src/audio/audio-provider-options.ts",
"packages/ai-devtools/src/production.ts",
"codemods/**/__testfixtures__/**"
"codemods/**/__testfixtures__/**",
"packages/ai-persistence/skills-test/stubs/**"
],
"ignoreExportsUsedInFile": true,
"workspaces": {
Expand Down
4 changes: 3 additions & 1 deletion nx.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,9 @@
"{workspaceRoot}/packages/*/src/**/*",
"{workspaceRoot}/packages/*/skills/**/*",
"{workspaceRoot}/skills/**/*",
"{workspaceRoot}/kiira.config.ts"
"{workspaceRoot}/kiira.config.ts",
"{workspaceRoot}/tsconfig.docs.json",
"{workspaceRoot}/packages/ai-persistence/skills-test/stubs/**/*"
]
},
"test:knip": {
Expand Down
4 changes: 4 additions & 0 deletions packages/ai-persistence/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,12 @@
}
},
"devDependencies": {
"@electric-sql/pglite": "^0.5.11",
"@tanstack/ai": "workspace:*",
"@types/pg": "^8.23.1",
"@vitest/coverage-v8": "4.1.10",
"drizzle-orm": "^0.45.4",
"pg": "^8.23.1",
"vitest": "^4.1.10"
}
}
50 changes: 50 additions & 0 deletions packages/ai-persistence/skills-test/harness/custom-pg.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { PGlite } from '@electric-sql/pglite'

const client = new PGlite()

await client.exec(`
CREATE TABLE chat_threads (
thread_id text PRIMARY KEY,
messages_json jsonb NOT NULL,
updated_at double precision NOT NULL
);
CREATE TABLE chat_runs (
run_id text PRIMARY KEY,
thread_id text NOT NULL,
status text NOT NULL,
started_at double precision NOT NULL,
finished_at double precision,
error text,
error_code text,
usage_json jsonb,
sandbox_key text,
detached_since double precision,
cancel_requested boolean,
driver_epoch integer,
parent_run_id text,
subagent_run_id text,
name text
);
CREATE TABLE chat_interrupts (
interrupt_id text PRIMARY KEY,
run_id text NOT NULL,
thread_id text NOT NULL,
status text NOT NULL,
requested_at double precision NOT NULL,
resolved_at double precision,
payload_json jsonb NOT NULL,
response_json jsonb
);
CREATE TABLE chat_metadata (
namespace text NOT NULL,
key text NOT NULL,
value_json jsonb NOT NULL,
PRIMARY KEY (namespace, key)
);
`)

export const pool = {
query(text: string, values?: ReadonlyArray<unknown>) {
return client.query(text, values === undefined ? undefined : [...values])
},
}
47 changes: 47 additions & 0 deletions packages/ai-persistence/skills-test/harness/pg.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { PGlite } from '@electric-sql/pglite'
import { drizzle } from 'drizzle-orm/pglite'

const client = new PGlite()

await client.exec(`
CREATE TABLE chat_threads (
thread_id text PRIMARY KEY,
messages_json jsonb NOT NULL,
updated_at bigint NOT NULL
);
CREATE TABLE chat_runs (
run_id text PRIMARY KEY,
thread_id text NOT NULL,
status text NOT NULL,
started_at bigint NOT NULL,
finished_at bigint,
error text,
error_code text,
usage_json jsonb,
sandbox_key text,
detached_since bigint,
cancel_requested boolean,
driver_epoch integer,
parent_run_id text,
subagent_run_id text,
name text
);
CREATE TABLE chat_interrupts (
interrupt_id text PRIMARY KEY,
run_id text NOT NULL,
thread_id text NOT NULL,
status text NOT NULL,
requested_at bigint NOT NULL,
resolved_at bigint,
payload_json jsonb NOT NULL,
response_json jsonb
);
CREATE TABLE chat_metadata (
namespace text NOT NULL,
key text NOT NULL,
value_json jsonb NOT NULL,
PRIMARY KEY (namespace, key)
);
`)

export const db = drizzle({ client })
85 changes: 85 additions & 0 deletions packages/ai-persistence/skills-test/harness/sqlite.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { DatabaseSync, type SQLInputValue } from 'node:sqlite'
import { drizzle } from 'drizzle-orm/sqlite-proxy'

const sqlite = new DatabaseSync(':memory:')

sqlite.exec(`
CREATE TABLE chat_threads (
thread_id text PRIMARY KEY,
messages_json text NOT NULL,
updated_at integer NOT NULL
);
CREATE TABLE chat_runs (
run_id text PRIMARY KEY,
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,
parent_run_id text,
subagent_run_id text,
name text
);
CREATE TABLE chat_interrupts (
interrupt_id text PRIMARY KEY,
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 chat_metadata (
namespace text NOT NULL,
key text NOT NULL,
value_json text NOT NULL,
PRIMARY KEY (namespace, key)
);
`)

function bind(value: unknown): SQLInputValue {
if (value == null) return null
if (typeof value === 'boolean') return value ? 1 : 0
if (
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'bigint'
) {
return value
}
if (value instanceof Uint8Array) return value
throw new Error(`Unsupported sqlite value: ${typeof value}`)
}

function cells(
row: Record<string, unknown>,
columns: ReadonlyArray<{ name: string }>,
): Array<unknown> {
return columns.map((column) => {
const value = row[column.name]
return value === undefined ? null : value
})
}

export const db = drizzle(async (sql, params, method) => {
const stmt = sqlite.prepare(sql)
const bound = params.map(bind)
if (method === 'run') {
stmt.run(...bound)
return { rows: [] }
}
const columns = stmt.columns()
if (method === 'get') {
const row = stmt.get(...bound)
return { rows: row ? cells(row, columns) : [] }
}
const rows = stmt.all(...bound)
return { rows: rows.map((row) => cells(row, columns)) }
})
92 changes: 92 additions & 0 deletions packages/ai-persistence/skills-test/load-recipe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { ChatPersistence } from '../src/types'

const here = dirname(fileURLToPath(import.meta.url))

export function extractFence(markdown: string, id: string): string {
const marker = `<!-- fence: ${id} -->`
const markerAt = markdown.indexOf(marker)
if (markerAt < 0) throw new Error(`Missing fence marker ${id}`)
const open = markdown.indexOf('```', markerAt + marker.length)
if (open < 0) throw new Error(`Missing opening fence for ${id}`)
const bodyStart = markdown.indexOf('\n', open)
if (bodyStart < 0) throw new Error(`Missing fence body for ${id}`)
const close = markdown.indexOf('\n```', bodyStart + 1)
if (close < 0) throw new Error(`Missing closing fence for ${id}`)
return markdown.slice(bodyStart + 1, close)
}

export function readSkill(name: string): string {
return readFileSync(
join(here, '..', 'skills', 'ai-persistence', name, 'SKILL.md'),
'utf8',
)
}

function rewriteAppImports(source: string): string {
return source
.replaceAll("from '@/db/schema'", "from './schema'")
.replaceAll("from '@/db'", "from './db'")
}

function writeGenerated(dir: string, files: Record<string, string>): void {
const out = join(here, '.generated', dir)
mkdirSync(out, { recursive: true })
for (const [name, contents] of Object.entries(files)) {
writeFileSync(join(out, name), contents)
}
}

function isRecipe(
value: unknown,
): value is { chatPersistence: ChatPersistence } {
return (
typeof value === 'object' &&
value !== null &&
'chatPersistence' in value &&
typeof value.chatPersistence === 'object' &&
value.chatPersistence !== null
)
}

async function importRecipe(dir: string): Promise<ChatPersistence> {
const specifier = new URL(
`./.generated/${dir}/chat-persistence.ts`,
import.meta.url,
).href
const loaded: unknown = await import(specifier)
if (!isRecipe(loaded)) {
throw new Error(`${dir} did not export chatPersistence`)
}
return loaded.chatPersistence
}

export async function loadDrizzleRecipe(
id: 'drizzle-sqlite' | 'drizzle-pg',
): Promise<ChatPersistence> {
const skill = readSkill('build-drizzle-adapter')
const schemaId =
id === 'drizzle-sqlite' ? 'drizzle-sqlite-schema' : 'drizzle-pg-schema'
const harness = id === 'drizzle-sqlite' ? 'sqlite' : 'pg'
writeGenerated(id, {
'schema.ts': `${extractFence(skill, schemaId)}\n`,
'db.ts': `export { db } from '../../harness/${harness}'\n`,
'chat-persistence.ts': `${rewriteAppImports(
extractFence(skill, 'drizzle-chat-persistence'),
)}\n`,
})
return importRecipe(id)
}

export async function loadCustomRecipe(): Promise<ChatPersistence> {
const skill = readSkill('build-custom-adapter')
writeGenerated('custom-pg', {
'db.ts': `export { pool } from '../../harness/custom-pg'\n`,
'chat-persistence.ts': `${rewriteAppImports(
extractFence(skill, 'custom-chat-persistence'),
)}\n`,
})
return importRecipe('custom-pg')
}
50 changes: 50 additions & 0 deletions packages/ai-persistence/skills-test/recipe-shape.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { extractFence, readSkill } from './load-recipe'

const here = dirname(fileURLToPath(import.meta.url))

const skills = [
'build-drizzle-adapter',
'build-prisma-adapter',
'build-custom-adapter',
'build-cloudflare-adapter',
] as const

const tables = [
'chat_threads',
'chat_runs',
'chat_interrupts',
'chat_metadata',
] as const

const factories = [
'messages: createMessageStore',
'runs: createRunStore',
'interrupts: createInterruptStore',
'metadata: createMetadataStore',
] as const

describe('persistence adapter skills', () => {
for (const name of skills) {
it(`${name} names the four tables and store factories`, () => {
const text = readSkill(name)
for (const table of tables) expect(text).toContain(table)
for (const factory of factories) expect(text).toContain(factory)
})
}

it('keeps the sqlite schema stub equal to the skill fence', () => {
const stub = readFileSync(
join(here, 'stubs', 'drizzle-sqlite-schema.ts'),
'utf8',
)
const fence = extractFence(
readSkill('build-drizzle-adapter'),
'drizzle-sqlite-schema',
)
expect(stub.trim()).toBe(fence.trim())
})
})
Loading
Loading