Skip to content
Open
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
28 changes: 20 additions & 8 deletions src/chat-store/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,12 @@

import { sql } from 'drizzle-orm'
import { index, integer, real, sqliteTable, text } from 'drizzle-orm/sqlite-core'
import type { AnySQLiteColumn, AnySQLiteTable, SQLiteColumnBuilderBase } from 'drizzle-orm/sqlite-core'
import type {
AnySQLiteColumn,
AnySQLiteTable,
SQLiteColumnBuilderBase,
SQLiteTableExtraConfigValue,
} from 'drizzle-orm/sqlite-core'
import type { ChatMessagePart } from './parts'

/** A product table referenced by FK — only the `id` column is touched. */
Expand Down Expand Up @@ -84,11 +89,18 @@ export interface CreateChatTablesOptions<
}

/**
* Builds product indexes from a table's columns. Returns drizzle's
* `index(...)`/`uniqueIndex(...)` results; the column map is passed through
* untyped because its shape depends on the product's own extras.
* Builds product indexes from a table's columns.
*
* The column map is passed untyped because its shape depends on the product's
* own extras; the RETURN is drizzle's own extra-config type, so the spread
* below stays inside the union `sqliteTable` expects and the built table keeps
* its inferred column types. Returning `unknown[]` here would force a cast on
* the config array, which widens every column to the cross-dialect union and
* breaks `db.select()` at every call site.
*/
export type ChatExtraIndexes = (columns: Record<string, AnySQLiteColumn>) => unknown[]
export type ChatExtraIndexes = (
columns: Record<string, AnySQLiteColumn>,
) => SQLiteTableExtraConfigValue[]

const hexId = () => text('id').primaryKey().default(sql`(lower(hex(randomblob(16))))`)

Expand All @@ -107,7 +119,7 @@ export function createChatTables<
const extraIndexes = (
build: ChatExtraIndexes | undefined,
table: Record<string, AnySQLiteColumn>,
): unknown[] => build?.(table) ?? []
): SQLiteTableExtraConfigValue[] => build?.(table) ?? []

const threads = sqliteTable(`${tablePrefix}thread`, {
id: hexId(),
Expand All @@ -125,7 +137,7 @@ export function createChatTables<
// Supports the store's list ordering (updatedAt desc within a workspace).
index(`idx_${tablePrefix}thread_workspace_updated`).on(table.workspaceId, table.updatedAt),
...extraIndexes(options.threadExtraIndexes, table as unknown as Record<string, AnySQLiteColumn>),
] as never)
])

const messages = sqliteTable(`${tablePrefix}message`, {
id: hexId(),
Expand Down Expand Up @@ -153,7 +165,7 @@ export function createChatTables<
index(`idx_${tablePrefix}message_thread`).on(table.threadId),
index(`idx_${tablePrefix}message_thread_created`).on(table.threadId, table.createdAt),
...extraIndexes(options.messageExtraIndexes, table as unknown as Record<string, AnySQLiteColumn>),
] as never)
])

return { threads, messages }
}
Expand Down
65 changes: 65 additions & 0 deletions tests/chat-store/chat-schema.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest'
import { getTableName } from 'drizzle-orm'
import { getTableConfig, index, text, uniqueIndex } from 'drizzle-orm/sqlite-core'
import type { SQLiteColumn } from 'drizzle-orm/sqlite-core'
import type { Part as HarnessWirePart } from '@tangle-network/agent-interface'
import { createChatTables } from '../../src/chat-store/schema'
import type { ChatMessagePart } from '../../src/chat-store/parts'
Expand Down Expand Up @@ -258,3 +259,67 @@ describe('createChatTables — product indexes', () => {
expect(indexNames(plain.messages)).toEqual(['idx_message_thread', 'idx_message_thread_created'])
})
})

/**
* A table built WITH product indexes still behaves like one built without.
*
* Worth stating plainly: these assertions did NOT catch the regression that
* prompted them. Typing the callback `unknown[]` forced a cast on the whole
* extra-config array, and the resulting column widening
* (`PgColumn | MySqlColumn | …`) is invisible inside this package — it appears
* only through the emitted `.d.ts`, in a consumer whose own `db` meets the
* table. gtm's typecheck is what failed, with five errors on one `db.select`.
*
* They stay because what they pin is real and cheap: the seam does not change
* insert/select behaviour or the column types this package can see. The guard
* for the emitted-type shape is a consumer's typecheck, which is where the
* defect surfaced and where it is caught again.
*/
describe('createChatTables — the seam preserves table inference', () => {
const t = createChatTables({
workspaceTable: workspacesTable,
threadExtraColumns: { scopeKey: text('scope_key') },
messageExtraColumns: { turnId: text('turn_id') },
threadExtraIndexes: (c) => [index('idx_thread_scope').on(c.scopeKey!)],
messageExtraIndexes: (c) => [
uniqueIndex('uniq_message_thread_role_turn').on(c.threadId!, c.role!, c.turnId!),
],
})

it('still supports a projected select over core AND extra columns', async () => {
const db = openDatabase([workspacesTable, t.threads, t.messages])
await db.insert(workspacesTable).values({ id: 'ws1', organizationId: 'org1', name: 'WS' })
const [thread] = await db
.insert(t.threads)
.values({ workspaceId: 'ws1', title: 'T', scopeKey: 'deal-1' })
.returning()
await db
.insert(t.messages)
.values({ threadId: thread!.id, role: 'user', content: 'hi', turnId: 'turn-1' })

// Projected select — the exact shape that broke when the columns widened.
const rows = await db
.select({ id: t.messages.id, role: t.messages.role, turnId: t.messages.turnId })
.from(t.messages)

expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({ role: 'user', turnId: 'turn-1' })
// `role` keeps its enum type, not `unknown` — a widened column loses this.
const role: 'user' | 'assistant' | 'system' | 'tool' = rows[0]!.role
expect(role).toBe('user')
// And the product's extra column keeps `string | null`, not `unknown`.
const turnId: string | null = rows[0]!.turnId
expect(turnId).toBe('turn-1')
})

/** Column types this package CAN see stay SQLite-shaped. */
it('keeps SQLite column types on both tables', () => {
const threadWorkspaceId: SQLiteColumn = t.threads.workspaceId
const threadScopeKey: SQLiteColumn = t.threads.scopeKey
const messageRole: SQLiteColumn = t.messages.role
const messageTurnId: SQLiteColumn = t.messages.turnId
for (const column of [threadWorkspaceId, threadScopeKey, messageRole, messageTurnId]) {
expect(column.name).toBeTruthy()
}
})
})