From f4e9c62e96f5f9e815ffa047cbe01e2aa99444fb Mon Sep 17 00:00:00 2001 From: Avraham Sakal Date: Sun, 31 Aug 2025 12:04:18 -0400 Subject: [PATCH] transition to postgresql for persistence layer --- .kanelrc.cjs | 37 +++- database/common.ts | 64 +++++- database/generated/public/Conversations.ts | 10 +- database/generated/public/FactTriggers.ts | 15 +- database/generated/public/Facts.ts | 12 +- database/generated/public/Messages.ts | 8 +- database/generated/public/Tools.ts | 4 +- database/generated/public/Users.ts | 4 +- database/index.ts | 2 + database/lowdb.ts | 220 +++++++++----------- database/postgres.ts | 230 ++++++++++++++++++++- package.json | 1 + pages/chat/conversations.ts | 17 +- pages/chat/fact-triggers.ts | 9 +- pages/chat/facts.ts | 8 +- pages/chat/messages.ts | 4 +- pages/chat/provider.ts | 4 +- pages/chat/trpc.ts | 42 ++-- pnpm-lock.yaml | 3 + server/authjs-handler.ts | 28 +-- types.ts | 2 +- 21 files changed, 511 insertions(+), 213 deletions(-) create mode 100644 database/index.ts diff --git a/.kanelrc.cjs b/.kanelrc.cjs index 2117b91..289c8d2 100644 --- a/.kanelrc.cjs +++ b/.kanelrc.cjs @@ -1,11 +1,42 @@ const { makeKyselyHook } = require("kanel-kysely"); +const { resolveType, escapeIdentifier } = require("kanel"); +const { recase } = require("@kristiandupont/recase"); +const toPascalCase = recase("snake", "pascal"); module.exports = { - connection: "postgres://neondb_owner:npg_sOVmj8vWq2zG@ep-withered-king-adiz9gpi-pooler.c-2.us-east-1.aws.neon.tech:5432/neondb?sslmode=require&channel_binding=true", + connection: + "postgres://neondb_owner:npg_sOVmj8vWq2zG@ep-withered-king-adiz9gpi-pooler.c-2.us-east-1.aws.neon.tech:5432/neondb?sslmode=require&channel_binding=true", enumStyle: "type", outputPath: "./database/generated", preRenderHooks: [makeKyselyHook()], + generateIdentifierType: (column, details, config) => { + const name = escapeIdentifier( + toPascalCase(details.name) + toPascalCase(column.name) + ); + const innerType = resolveType(column, details, { + ...config, + // Explicitly disable identifier resolution so we get the actual inner type here + generateIdentifierType: undefined, + }); + const imports = []; + + let type = innerType; + if (typeof innerType === "object") { + // Handle non-primitives + type = innerType.name; + imports.push(...innerType.typeImports); + } + + return { + declarationType: "typeDeclaration", + name, + exportAs: "named", + typeDefinition: [`${type}`], + typeImports: imports, + comment: [`Identifier type for ${details.schemaName}.${details.name}`], + }; + }, customTypeMap: { "pg_catalog.timestamp": "string", - } -}; \ No newline at end of file + }, +}; diff --git a/database/common.ts b/database/common.ts index 10fcaa0..a5c66a5 100644 --- a/database/common.ts +++ b/database/common.ts @@ -1,9 +1,61 @@ -export interface Entity { +import type { CommittedMessage } from "../types"; + +export type Conversation = { + id: string; + title: string; + userId: string; + createdAt?: string; +}; + +export type Fact = { + id: string; + userId: string; + sourceMessageId: string; + content: string; + createdAt?: string; +}; + +export type FactTrigger = { + id: string; + sourceFactId: string; + content: string; + priorityMultiplier: number; + priorityMultiplierReason: string | null; + scopeConversationId: string; + createdAt?: string; +}; + +export interface Entity { construct: (data: T) => T; - create: (data: T) => Promise; - createMany: (data: T[]) => Promise; + create: (data: Omit) => Promise; + createMany: (data: Omit[]) => Promise; findAll: () => Promise; - findById: (id: ID) => Promise; - update: (id: ID, data: Partial) => Promise; - delete: (id: ID) => Promise; + findById: (id: string) => Promise; + update: (id: string, data: Partial) => Promise; + delete: (id: string) => Promise; +} + +export interface ConversationEntity extends Entity { + fetchMessages: (conversationId: string) => Promise>; +} + +export interface FactEntity extends Entity { + findByConversationId: (conversationId: string) => Promise>; +} + +export interface MessageEntity extends Entity { + findByConversationId: ( + conversationId: string + ) => Promise>; +} + +export type FactTriggerEntity = Entity & { + findByFactId: (factId: string) => Promise>; +}; + +export interface ApplicationDatabase { + conversations: ConversationEntity; + factTriggers: FactTriggerEntity; + facts: FactEntity; + messages: MessageEntity; } diff --git a/database/generated/public/Conversations.ts b/database/generated/public/Conversations.ts index 6b0dce3..fd321fe 100644 --- a/database/generated/public/Conversations.ts +++ b/database/generated/public/Conversations.ts @@ -5,17 +5,17 @@ import type { UsersId } from './Users'; import type { ColumnType, Selectable, Insertable, Updateable } from 'kysely'; /** Identifier type for public.conversations */ -export type ConversationsId = number & { __brand: 'public.conversations' }; +export type ConversationsId = string; /** Represents the table public.conversations */ export default interface ConversationsTable { - id: ColumnType; + id: ColumnType; - title: ColumnType; + title: ColumnType; - createdAt: ColumnType; + createdAt: ColumnType; - userId: ColumnType; + userId: ColumnType; } export type Conversations = Selectable; diff --git a/database/generated/public/FactTriggers.ts b/database/generated/public/FactTriggers.ts index 7734f06..1cbc092 100644 --- a/database/generated/public/FactTriggers.ts +++ b/database/generated/public/FactTriggers.ts @@ -2,27 +2,26 @@ // This file is automatically generated by Kanel. Do not modify manually. import type { FactsId } from './Facts'; -import type { ConversationsId } from './Conversations'; import type { ColumnType, Selectable, Insertable, Updateable } from 'kysely'; /** Identifier type for public.fact_triggers */ -export type FactTriggersId = number & { __brand: 'public.fact_triggers' }; +export type FactTriggersId = string; /** Represents the table public.fact_triggers */ export default interface FactTriggersTable { - id: ColumnType; + id: ColumnType; - sourceFactId: ColumnType; + sourceFactId: ColumnType; - content: ColumnType; + content: ColumnType; - priorityMultiplier: ColumnType; + priorityMultiplier: ColumnType; priorityMultiplierReason: ColumnType; - scopeConversationId: ColumnType; + scopeConversationId: ColumnType; - createdAt: ColumnType; + createdAt: ColumnType; } export type FactTriggers = Selectable; diff --git a/database/generated/public/Facts.ts b/database/generated/public/Facts.ts index 271bc32..406e040 100644 --- a/database/generated/public/Facts.ts +++ b/database/generated/public/Facts.ts @@ -6,19 +6,19 @@ import type { MessagesId } from './Messages'; import type { ColumnType, Selectable, Insertable, Updateable } from 'kysely'; /** Identifier type for public.facts */ -export type FactsId = number & { __brand: 'public.facts' }; +export type FactsId = string; /** Represents the table public.facts */ export default interface FactsTable { - id: ColumnType; + id: ColumnType; - userId: ColumnType; + userId: ColumnType; - sourceMessageId: ColumnType; + sourceMessageId: ColumnType; - content: ColumnType; + content: ColumnType; - createdAt: ColumnType; + createdAt: ColumnType; } export type Facts = Selectable; diff --git a/database/generated/public/Messages.ts b/database/generated/public/Messages.ts index 7b2c4e5..db92df7 100644 --- a/database/generated/public/Messages.ts +++ b/database/generated/public/Messages.ts @@ -6,11 +6,11 @@ import type { default as Role } from './Role'; import type { ColumnType, Selectable, Insertable, Updateable } from 'kysely'; /** Identifier type for public.messages */ -export type MessagesId = number & { __brand: 'public.messages' }; +export type MessagesId = string; /** Represents the table public.messages */ export default interface MessagesTable { - id: ColumnType; + id: ColumnType; conversationId: ColumnType; @@ -18,9 +18,9 @@ export default interface MessagesTable { runningSummary: ColumnType; - created_at: ColumnType; + createdAt: ColumnType; - role: ColumnType; + role: ColumnType; parts: ColumnType; } diff --git a/database/generated/public/Tools.ts b/database/generated/public/Tools.ts index 0c028b6..757c8cd 100644 --- a/database/generated/public/Tools.ts +++ b/database/generated/public/Tools.ts @@ -6,11 +6,11 @@ import type { MessagesId } from './Messages'; import type { ColumnType, Selectable, Insertable, Updateable } from 'kysely'; /** Identifier type for public.tools */ -export type ToolsId = number & { __brand: 'public.tools' }; +export type ToolsId = string; /** Represents the table public.tools */ export default interface ToolsTable { - id: ColumnType; + id: ColumnType; userId: ColumnType; diff --git a/database/generated/public/Users.ts b/database/generated/public/Users.ts index 07ef8ed..db19d53 100644 --- a/database/generated/public/Users.ts +++ b/database/generated/public/Users.ts @@ -4,11 +4,11 @@ import type { ColumnType, Selectable, Insertable, Updateable } from 'kysely'; /** Identifier type for public.users */ -export type UsersId = number & { __brand: 'public.users' }; +export type UsersId = string; /** Represents the table public.users */ export default interface UsersTable { - id: ColumnType; + id: ColumnType; username: ColumnType; diff --git a/database/index.ts b/database/index.ts new file mode 100644 index 0000000..b318451 --- /dev/null +++ b/database/index.ts @@ -0,0 +1,2 @@ +// export { db } from "./lowdb"; +export { db } from "./postgres"; diff --git a/database/lowdb.ts b/database/lowdb.ts index 00b1b3a..1db0b27 100644 --- a/database/lowdb.ts +++ b/database/lowdb.ts @@ -1,33 +1,17 @@ import { Low } from "lowdb"; import { JSONFile } from "lowdb/node"; import type { CommittedMessage } from "../types"; -import type { Entity } from "./common"; +import type { + Conversation, + ConversationEntity, + Fact, + FactEntity, + FactTrigger, + FactTriggerEntity, + MessageEntity, +} from "./common"; import { nanoid } from "nanoid"; -export type Conversation = { - id: string; - title: string; - userId: string; -}; - -export type Fact = { - id: string; - userId: string; - sourceMessageId: string; - content: string; - createdAt: string; -}; - -export type FactTrigger = { - id: string; - sourceFactId: string; - content: string; - priorityMultiplier: number; - priorityMultiplierReason: string; - scopeConversationId: string; - createdAt: string; -}; - type DB = { conversations: Array; messages: Array; }; -export const db = new Low(new JSONFile("db.json"), { +export const dbClient = new Low(new JSONFile("db.json"), { conversations: [], messages: [], facts: [], factTriggers: [], }); /** Initialize the database. Sets `db.data` to the default state if the file doesn't exist. */ -await db.read(); +await dbClient.read(); /** Write the database to the file, in case it didn't exist before. */ -await db.write(); +await dbClient.write(); -const conversations: Entity & { - fetchMessages: (conversationId: string) => Promise>; -} = { +const conversations: ConversationEntity = { construct: (conversation: Conversation) => conversation, - create: async (conversation: Conversation) => { - conversation.id = conversation.id ?? nanoid(); - await db.data.conversations.push(conversation); - await db.write(); + create: async (_conversation) => { + const conversation = { ..._conversation, id: nanoid() }; + await dbClient.data.conversations.push(conversation); + await dbClient.write(); return conversation; }, - createMany: async (conversations: Array) => { - await db.data.conversations.push(...conversations); - await db.write(); + createMany: async (_conversations) => { + const conversations = _conversations.map((c) => ({ ...c, id: nanoid() })); + await dbClient.data.conversations.push(...conversations); + await dbClient.write(); return conversations; }, findAll: async () => { - return db.data.conversations; + return dbClient.data.conversations; }, findById: async (id) => { - return db.data.conversations.find((c) => c.id === id); + return dbClient.data.conversations.find((c) => c.id === id); }, update: async (id, data: Partial) => { - const conversationIndex = db.data.conversations.findIndex( + const conversationIndex = dbClient.data.conversations.findIndex( (c) => c.id === id ); if (conversationIndex === -1) throw new Error("Conversation not found"); - db.data.conversations[conversationIndex] = { - ...db.data.conversations[conversationIndex], + dbClient.data.conversations[conversationIndex] = { + ...dbClient.data.conversations[conversationIndex], ...data, }; - await db.write(); + await dbClient.write(); }, delete: async (id) => { - db.data.conversations.splice( - db.data.conversations.findIndex((c) => c.id === id), + dbClient.data.conversations.splice( + dbClient.data.conversations.findIndex((c) => c.id === id), 1 ); - const deletedMessageIds = db.data.messages + const deletedMessageIds = dbClient.data.messages .filter((m) => m.conversationId === id) .map((m) => m.id); - db.data.messages = db.data.messages.filter((m) => m.conversationId !== id); - const deletedFactIds = db.data.facts + dbClient.data.messages = dbClient.data.messages.filter( + (m) => m.conversationId !== id + ); + const deletedFactIds = dbClient.data.facts .filter((fact) => deletedMessageIds.includes(fact.sourceMessageId)) .map((fact) => fact.id); - db.data.facts = db.data.facts.filter( + dbClient.data.facts = dbClient.data.facts.filter( (fact) => !deletedFactIds.includes(fact.id) ); - db.data.factTriggers = db.data.factTriggers.filter( + dbClient.data.factTriggers = dbClient.data.factTriggers.filter( (factTrigger) => !deletedFactIds.includes(factTrigger.sourceFactId) ); - await db.write(); + await dbClient.write(); }, fetchMessages: async (conversationId) => { - const rows = await db.data.messages.filter( + const rows = await dbClient.data.messages.filter( (m) => m.conversationId === conversationId ); return rows as Array; }, }; -const factTriggers: Entity & { - findByFactId: (factId: string) => Promise>; -} = { +const factTriggers: FactTriggerEntity = { construct: (factTrigger: FactTrigger) => factTrigger, - create: async (factTrigger: FactTrigger) => { - factTrigger.id = factTrigger.id ?? nanoid(); - await db.data.factTriggers.push(factTrigger); - await db.write(); + create: async (_factTrigger) => { + const factTrigger = { ..._factTrigger, id: nanoid() }; + await dbClient.data.factTriggers.push(factTrigger); + await dbClient.write(); return factTrigger; }, - createMany: async (factTriggers: Array) => { - await db.data.factTriggers.push(...factTriggers); - await db.write(); + createMany: async (_factTriggers) => { + const factTriggers = _factTriggers.map((f) => ({ ...f, id: nanoid() })); + await dbClient.data.factTriggers.push(...factTriggers); + await dbClient.write(); return factTriggers; }, findAll: async () => { - return db.data.factTriggers; + return dbClient.data.factTriggers; }, findById: async (id) => { - return db.data.factTriggers.find((factTrigger) => factTrigger.id === id); + return dbClient.data.factTriggers.find( + (factTrigger) => factTrigger.id === id + ); }, update: async (id, data: Partial) => { - const factTriggerIndex = db.data.factTriggers.findIndex( + const factTriggerIndex = dbClient.data.factTriggers.findIndex( (factTrigger) => factTrigger.id === id ); if (factTriggerIndex === -1) throw new Error("Fact trigger not found"); - db.data.factTriggers[factTriggerIndex] = { - ...db.data.factTriggers[factTriggerIndex], + dbClient.data.factTriggers[factTriggerIndex] = { + ...dbClient.data.factTriggers[factTriggerIndex], ...data, }; - await db.write(); + await dbClient.write(); }, delete: async (id) => { - const deletedFactTriggerIndex = db.data.factTriggers.findIndex( + const deletedFactTriggerIndex = dbClient.data.factTriggers.findIndex( (factTrigger) => factTrigger.id === id ); if (deletedFactTriggerIndex === -1) throw new Error("Fact trigger not found"); - db.data.factTriggers.splice(deletedFactTriggerIndex, 1); - await db.write(); + dbClient.data.factTriggers.splice(deletedFactTriggerIndex, 1); + await dbClient.write(); }, findByFactId: async (factId: string) => { - return db.data.factTriggers.filter( + return dbClient.data.factTriggers.filter( (factTrigger) => factTrigger.sourceFactId === factId ); }, }; -const facts: Entity & { - findByConversationId: (conversationId: string) => Promise>; -} = { +const facts: FactEntity = { construct: (fact: Fact) => fact, - create: async (fact: Fact) => { - fact.id = fact.id ?? nanoid(); - await db.data.facts.push(fact); - await db.write(); + create: async (_fact) => { + const fact = { ..._fact, id: nanoid() }; + await dbClient.data.facts.push(fact); + await dbClient.write(); return fact; }, - createMany: async (facts: Array) => { - await db.data.facts.push(...facts); - await db.write(); + createMany: async (_facts) => { + const facts = _facts.map((f) => ({ ...f, id: nanoid() })); + await dbClient.data.facts.push(...facts); + await dbClient.write(); return facts; }, findAll: async () => { - return db.data.facts; + return dbClient.data.facts; }, findById: async (id) => { - return db.data.facts.find((fact) => fact.id === id); + return dbClient.data.facts.find((fact) => fact.id === id); }, update: async (id, data: Partial) => { - const factIndex = db.data.facts.findIndex((fact) => fact.id === id); + const factIndex = dbClient.data.facts.findIndex((fact) => fact.id === id); if (factIndex === -1) throw new Error("Fact not found"); - db.data.facts[factIndex] = { - ...db.data.facts[factIndex], + dbClient.data.facts[factIndex] = { + ...dbClient.data.facts[factIndex], ...data, }; - await db.write(); + await dbClient.write(); }, delete: async (id) => { - const deletedFactId = db.data.facts.findIndex((fact) => fact.id === id); + const deletedFactId = dbClient.data.facts.findIndex( + (fact) => fact.id === id + ); if (deletedFactId === -1) throw new Error("Fact not found"); - db.data.facts.splice(deletedFactId, 1); - await db.write(); + dbClient.data.facts.splice(deletedFactId, 1); + await dbClient.write(); }, findByConversationId: async (conversationId) => { - const conversationMessageIds = db.data.messages + const conversationMessageIds = dbClient.data.messages .filter((m) => m.conversationId === conversationId) .map((m) => m.id); - const rows = await db.data.facts.filter((f) => + const rows = await dbClient.data.facts.filter((f) => conversationMessageIds.includes(f.sourceMessageId) ); return rows as Array; }, }; -const messages: Entity & { - findByConversationId: ( - conversationId: string - ) => Promise>; -} = { +const messages: MessageEntity = { construct: (message: CommittedMessage) => message, - create: async (message: CommittedMessage) => { - message.id = message.id ?? nanoid(); - await db.data.messages.push(message); - await db.write(); + create: async (_message) => { + const message = { ..._message, id: nanoid() }; + await dbClient.data.messages.push(message); + await dbClient.write(); return message; }, - createMany: async (messages: Array) => { - await db.data.messages.push(...messages); - await db.write(); + createMany: async (_messages) => { + const messages = _messages.map((m) => ({ ...m, id: nanoid() })); + await dbClient.data.messages.push(...messages); + await dbClient.write(); return messages; }, findAll: async () => { - return db.data.messages; + return dbClient.data.messages; }, findById: async (id) => { - return db.data.messages.find((m) => m.id === id); + return dbClient.data.messages.find((m) => m.id === id); }, update: async (id, data: Partial) => { - const messageIndex = db.data.messages.findIndex((m) => m.id === id); + const messageIndex = dbClient.data.messages.findIndex((m) => m.id === id); if (messageIndex === -1) throw new Error("Message not found"); - db.data.messages[messageIndex] = { - ...db.data.messages[messageIndex], + dbClient.data.messages[messageIndex] = { + ...dbClient.data.messages[messageIndex], ...data, }; - await db.write(); + await dbClient.write(); }, delete: async (id) => { - db.data.messages.splice( - db.data.messages.findIndex((m) => m.id === id), + dbClient.data.messages.splice( + dbClient.data.messages.findIndex((m) => m.id === id), 1 ); - await db.write(); + await dbClient.write(); }, findByConversationId: async (conversationId) => { - return db.data.messages.filter((m) => m.conversationId === conversationId); + return dbClient.data.messages.filter( + (m) => m.conversationId === conversationId + ); }, }; -export const _db = { +export const db = { conversations, factTriggers, facts, diff --git a/database/postgres.ts b/database/postgres.ts index ef75c4c..31f268b 100644 --- a/database/postgres.ts +++ b/database/postgres.ts @@ -1,6 +1,14 @@ import { Pool } from "pg"; import { Kysely, PostgresDialect } from "kysely"; import type Database from "./generated/Database"; +import type { + ConversationEntity, + FactEntity, + FactTriggerEntity, + MessageEntity, +} from "./common.ts"; +import type { Messages } from "./generated/public/Messages"; +import type { CommittedMessage } from "../types"; export const pool = new Pool({ connectionString: @@ -16,6 +24,226 @@ const dialect = new PostgresDialect({ // knows your database structure. // Dialect is passed to Kysely's constructor, and from now on, Kysely knows how // to communicate with your database. -export const db = new Kysely({ +export const dbClient = new Kysely({ dialect, }); + +const conversations: ConversationEntity = { + construct: (conversation) => conversation, + create: async (conversation) => { + const insertedRows = await dbClient + .insertInto("conversations") + .values(conversation) + .returningAll() + .execute(); + return insertedRows[0]; + }, + createMany: async (conversations) => { + const insertedRows = await dbClient + .insertInto("conversations") + .values(conversations) + .returningAll() + .execute(); + return insertedRows; + }, + findAll: async () => { + const rows = await dbClient + .selectFrom("conversations") + .selectAll() + .execute(); + return rows; + }, + findById: async (id) => { + const row = await dbClient + .selectFrom("conversations") + .selectAll() + .where("id", "=", id) + .execute(); + return row[0]; + }, + update: async (id, data) => { + await dbClient + .updateTable("conversations") + .set(data) + .where("id", "=", id) + .execute(); + }, + delete: async (id) => { + await dbClient.deleteFrom("conversations").where("id", "=", id).execute(); + }, + fetchMessages: async (conversationId) => { + const rows = await dbClient + .selectFrom("messages") + .selectAll() + .where("conversationId", "=", conversationId) + .execute(); + return rows as Array; + }, +}; + +const facts: FactEntity = { + construct: (fact) => fact, + create: async (fact) => { + const insertedRows = await dbClient + .insertInto("facts") + .values(fact) + .returningAll() + .execute(); + return insertedRows[0]; + }, + createMany: async (facts) => { + const insertedRows = await dbClient + .insertInto("facts") + .values(facts) + .returningAll() + .execute(); + return insertedRows; + }, + findAll: async () => { + const rows = await dbClient.selectFrom("facts").selectAll().execute(); + return rows; + }, + findById: async (id) => { + const row = await dbClient + .selectFrom("facts") + .selectAll() + .where("id", "=", id) + .execute(); + return row[0]; + }, + update: async (id, data) => { + await dbClient + .updateTable("facts") + .set(data) + .where("id", "=", id) + .execute(); + }, + delete: async (id) => { + await dbClient.deleteFrom("facts").where("id", "=", id).execute(); + }, + findByConversationId: async (conversationId) => { + const rows = await dbClient + .selectFrom("facts") + .innerJoin("messages", "messages.id", "facts.sourceMessageId") + .selectAll("facts") + .where("conversationId", "=", conversationId) + .execute(); + return rows; + }, +}; + +const factTriggers: FactTriggerEntity = { + construct: (factTrigger) => factTrigger, + create: async (factTrigger) => { + const insertedRows = await dbClient + .insertInto("fact_triggers") + .values(factTrigger) + .returningAll() + .execute(); + return insertedRows[0]; + }, + createMany: async (factTriggers) => { + const insertedRows = await dbClient + .insertInto("fact_triggers") + .values(factTriggers) + .returningAll() + .execute(); + return insertedRows; + }, + findAll: async () => { + const rows = await dbClient + .selectFrom("fact_triggers") + .selectAll() + .execute(); + return rows; + }, + findById: async (id) => { + const row = await dbClient + .selectFrom("fact_triggers") + .selectAll() + .where("id", "=", id) + .execute(); + return row[0]; + }, + update: async (id, data) => { + await dbClient + .updateTable("fact_triggers") + .set(data) + .where("id", "=", id) + .execute(); + }, + delete: async (id) => { + await dbClient.deleteFrom("fact_triggers").where("id", "=", id).execute(); + }, + findByFactId: async (factId) => { + const rows = await dbClient + .selectFrom("fact_triggers") + .innerJoin("facts", "facts.id", "fact_triggers.sourceFactId") + .selectAll("fact_triggers") + .where("sourceFactId", "=", factId) + .execute(); + return rows; + }, +}; + +const messages: MessageEntity = { + construct: (message) => message, + create: async (message) => { + const insertedRows = await dbClient + .insertInto("messages") + .values({ ...message, parts: JSON.stringify(message.parts) }) + .returningAll() + .execute(); + return insertedRows[0] as CommittedMessage; + }, + createMany: async (messages) => { + const insertedRows = await dbClient + .insertInto("messages") + .values( + messages.map((message) => ({ + ...message, + parts: JSON.stringify(message.parts), + })) + ) + .returningAll() + .execute(); + return insertedRows as Array; + }, + findAll: async () => { + const rows = await dbClient.selectFrom("messages").selectAll().execute(); + return rows as Array; + }, + findById: async (id) => { + const row = await dbClient + .selectFrom("messages") + .selectAll() + .where("id", "=", id) + .execute(); + return row[0] as CommittedMessage; + }, + update: async (id, data) => { + await dbClient + .updateTable("messages") + .set(data) + .where("id", "=", id) + .execute(); + }, + delete: async (id) => { + await dbClient.deleteFrom("messages").where("id", "=", id).execute(); + }, + findByConversationId: async (conversationId) => { + const rows = await dbClient + .selectFrom("messages") + .selectAll() + .where("conversationId", "=", conversationId) + .execute(); + return rows as Array; + }, +}; + +export const db = { + conversations, + facts, + factTriggers, + messages, +}; diff --git a/package.json b/package.json index 88d99de..fa86f9a 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "@biomejs/biome": "1.9.4", "@cloudflare/workers-types": "^4.20250620.0", "@hono/vite-dev-server": "^0.19.1", + "@kristiandupont/recase": "^1.4.1", "@types/node": "^20.19.0", "@types/pg": "^8.15.4", "@types/react": "^19.1.8", diff --git a/pages/chat/conversations.ts b/pages/chat/conversations.ts index 9fd4bce..2cf313f 100644 --- a/pages/chat/conversations.ts +++ b/pages/chat/conversations.ts @@ -3,29 +3,28 @@ import { publicProcedure, createCallerFactory, } from "../../trpc/server"; -import { _db } from "../../database/lowdb"; +import { db } from "../../database/index"; export const conversations = router({ fetchAll: publicProcedure.query(async () => { - return await _db.conversations.findAll(); + return await db.conversations.findAll(); }), fetchOne: publicProcedure .input((x) => x as { id: string }) .query(async ({ input: { id } }) => { - return await _db.conversations.findById(id); + return await db.conversations.findById(id); }), start: publicProcedure.mutation(async () => { const row = { - id: "", title: "New Conversation", - userId: "1", + userId: "019900bb-61b3-7333-b760-b27784dfe33b", }; - return await _db.conversations.create(row); + return await db.conversations.create(row); }), deleteOne: publicProcedure .input((x) => x as { id: string }) .mutation(async ({ input: { id } }) => { - await _db.conversations.delete(id); + await db.conversations.delete(id); return { ok: true }; }), updateTitle: publicProcedure @@ -37,13 +36,13 @@ export const conversations = router({ } ) .mutation(async ({ input: { id, title } }) => { - await _db.conversations.update(id, { title }); + await db.conversations.update(id, { title }); return { ok: true }; }), fetchMessages: publicProcedure .input((x) => x as { conversationId: string }) .query(async ({ input: { conversationId } }) => { - return await _db.conversations.fetchMessages(conversationId); + return await db.conversations.fetchMessages(conversationId); }), }); diff --git a/pages/chat/fact-triggers.ts b/pages/chat/fact-triggers.ts index 8f32f84..287f3de 100644 --- a/pages/chat/fact-triggers.ts +++ b/pages/chat/fact-triggers.ts @@ -3,10 +3,11 @@ import { publicProcedure, createCallerFactory, } from "../../trpc/server.js"; -import { _db, type Fact } from "../../database/lowdb.js"; +import { db } from "../../database/index.js"; import type { DraftMessage } from "../../types.js"; import { openrouter, MODEL_NAME } from "./provider.js"; import { generateObject, generateText, jsonSchema } from "ai"; +import type { Fact } from "../../database/common.js"; const factTriggersSystemPrompt = ({ previousRunningSummary, @@ -61,7 +62,7 @@ export const factTriggers = router({ fetchByFactId: publicProcedure .input((x) => x as { factId: string }) .query(async ({ input: { factId } }) => { - return _db.factTriggers.findByFactId(factId); + return db.factTriggers.findByFactId(factId); }), deleteOne: publicProcedure .input( @@ -71,7 +72,7 @@ export const factTriggers = router({ } ) .mutation(async ({ input: { factTriggerId } }) => { - await _db.factTriggers.delete(factTriggerId); + await db.factTriggers.delete(factTriggerId); return { ok: true }; }), update: publicProcedure @@ -83,7 +84,7 @@ export const factTriggers = router({ } ) .mutation(async ({ input: { factTriggerId, content } }) => { - _db.factTriggers.update(factTriggerId, { content }); + db.factTriggers.update(factTriggerId, { content }); return { ok: true }; }), generateFromFact: publicProcedure diff --git a/pages/chat/facts.ts b/pages/chat/facts.ts index 20e7c08..f9a8cc4 100644 --- a/pages/chat/facts.ts +++ b/pages/chat/facts.ts @@ -3,7 +3,7 @@ import { publicProcedure, createCallerFactory, } from "../../trpc/server.js"; -import { _db } from "../../database/lowdb.js"; +import { db } from "../../database/index.js"; import type { DraftMessage } from "../../types.js"; import { MODEL_NAME, openrouter } from "./provider.js"; import { generateObject, generateText, jsonSchema } from "ai"; @@ -57,7 +57,7 @@ export const facts = router({ fetchByConversationId: publicProcedure .input((x) => x as { conversationId: string }) .query(async ({ input: { conversationId } }) => { - return await _db.facts.findByConversationId(conversationId); + return await db.facts.findByConversationId(conversationId); }), deleteOne: publicProcedure .input( @@ -67,7 +67,7 @@ export const facts = router({ } ) .mutation(async ({ input: { factId } }) => { - await _db.facts.delete(factId); + await db.facts.delete(factId); return { ok: true }; }), update: publicProcedure @@ -79,7 +79,7 @@ export const facts = router({ } ) .mutation(async ({ input: { factId, content } }) => { - await _db.facts.update(factId, { content }); + await db.facts.update(factId, { content }); return { ok: true }; }), extractFromNewMessages: publicProcedure diff --git a/pages/chat/messages.ts b/pages/chat/messages.ts index 827175b..e756d16 100644 --- a/pages/chat/messages.ts +++ b/pages/chat/messages.ts @@ -6,7 +6,7 @@ import { import { MODEL_NAME, openrouter } from "./provider.js"; import { generateObject, generateText, jsonSchema } from "ai"; import type { DraftMessage } from "../../types.js"; -import { _db } from "../../database/lowdb"; +import { db } from "../../database/index"; const runningSummarySystemPrompt = ({ previousRunningSummary, @@ -52,7 +52,7 @@ export const messages = router({ fetchByConversationId: publicProcedure .input((x) => x as { conversationId: string }) .query(async ({ input: { conversationId } }) => { - return await _db.messages.findByConversationId(conversationId); + return await db.messages.findByConversationId(conversationId); }), generateRunningSummary: publicProcedure .input( diff --git a/pages/chat/provider.ts b/pages/chat/provider.ts index a0906c5..e78285a 100644 --- a/pages/chat/provider.ts +++ b/pages/chat/provider.ts @@ -5,5 +5,5 @@ export const openrouter = createOpenRouter({ }); // export const MODEL_NAME = "mistralai/mistral-nemo"; -// export const MODEL_NAME = "openai/gpt-oss-20b"; -export const MODEL_NAME = "openai/gpt-5-mini"; +export const MODEL_NAME = "openai/gpt-oss-20b"; +// export const MODEL_NAME = "openai/gpt-5-mini"; diff --git a/pages/chat/trpc.ts b/pages/chat/trpc.ts index c1484f8..d590294 100644 --- a/pages/chat/trpc.ts +++ b/pages/chat/trpc.ts @@ -14,8 +14,7 @@ import type { // ConsistencyLevelEnum, // type NumberArrayId, // } from "@zilliz/milvus2-sdk-node"; -import { db, type FactTrigger, type Fact, _db } from "../../database/lowdb.js"; -import { nanoid } from "nanoid"; +import { db } from "../../database/index.js"; import { conversations } from "./conversations.js"; import { messages } from "./messages.js"; import { facts, createCaller as createCallerFacts } from "./facts.js"; @@ -23,6 +22,7 @@ import { createCaller as createCallerMessages } from "./messages.js"; import { createCaller as createCallerFactTriggers } from "./fact-triggers.js"; import { factTriggers } from "./fact-triggers.js"; import { MODEL_NAME, openrouter } from "./provider.js"; +import type { Fact, FactTrigger } from "../../database/common.js"; const factsCaller = createCallerFacts({}); const messagesCaller = createCallerMessages({}); @@ -96,16 +96,14 @@ export const chat = router({ previousRunningSummaryIndex + 1 ); /** Save the incoming message to the database. */ - const insertedUserMessage: CommittedMessage = { - id: nanoid(), + const insertedUserMessage = await db.messages.create({ conversationId, // content: messages[messages.length - 1].content, // role: "user" as const, ...messages[messages.length - 1], index: messages.length - 1, createdAt: new Date().toISOString(), - }; - await _db.messages.create(insertedUserMessage); + }); /** Generate a new message from the model, but hold-off on adding it to * the database until we produce the associated running-summary, below. @@ -154,15 +152,13 @@ export const chat = router({ messagesSincePreviousRunningSummary: [], newMessages: messagesSincePreviousRunningSummary, }); - const insertedFactsFromUserMessage: Array = + const insertedFactsFromUserMessage = await db.facts.createMany( factsFromUserMessageResponse.object.facts.map((fact) => ({ - id: nanoid(), - userId: "1", + userId: "019900bb-61b3-7333-b760-b27784dfe33b", sourceMessageId: insertedUserMessage.id, content: fact, - createdAt: new Date().toISOString(), - })); - _db.facts.createMany(insertedFactsFromUserMessage); + })) + ); /** Produce a running summary of the conversation, and save that along * with the model's response to the database. The new running summary is @@ -174,8 +170,7 @@ export const chat = router({ mainResponseContent: mainResponse.text, previousRunningSummary, }); - const insertedAssistantMessage: CommittedMessage = { - id: nanoid(), + const insertedAssistantMessage = await db.messages.create({ conversationId, // content: mainResponse.text, parts: [{ type: "text", text: mainResponse.text }], @@ -183,8 +178,7 @@ export const chat = router({ role: "assistant" as const, index: messages.length, createdAt: new Date().toISOString(), - }; - await _db.messages.create(insertedAssistantMessage); + }); /** Extract Facts from the model's response, and add them to the database, * linking the Facts with the messages they came from. */ const factsFromAssistantMessageResponse = @@ -200,15 +194,14 @@ export const chat = router({ ], }); - const insertedFactsFromAssistantMessage: Array = + const insertedFactsFromAssistantMessage = await db.facts.createMany( factsFromAssistantMessageResponse.object.facts.map((factContent) => ({ - id: nanoid(), - userId: "1", + userId: "019900bb-61b3-7333-b760-b27784dfe33b", sourceMessageId: insertedAssistantMessage.id, content: factContent, createdAt: new Date().toISOString(), - })); - _db.facts.createMany(insertedFactsFromAssistantMessage); + })) + ); const insertedFacts = [ ...insertedFactsFromUserMessage, @@ -227,9 +220,8 @@ export const chat = router({ messagesSincePreviousRunningSummary, fact, }); - const insertedFactTriggers: Array = + const insertedFactTriggers: Array> = factTriggers.object.factTriggers.map((factTrigger) => ({ - id: nanoid(), sourceFactId: fact.id, content: factTrigger, priorityMultiplier: 1, @@ -237,10 +229,10 @@ export const chat = router({ scopeConversationId: conversationId, createdAt: new Date().toISOString(), })); - _db.factTriggers.createMany(insertedFactTriggers); + db.factTriggers.createMany(insertedFactTriggers); } - await db.write(); + // await db.write(); return { insertedAssistantMessage, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 31dbc37..6496e41 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -117,6 +117,9 @@ importers: '@hono/vite-dev-server': specifier: ^0.19.1 version: 0.19.1(hono@4.8.3)(miniflare@4.20250617.4)(wrangler@4.22.0(@cloudflare/workers-types@4.20250627.0)) + '@kristiandupont/recase': + specifier: ^1.4.1 + version: 1.4.1 '@types/node': specifier: ^20.19.0 version: 20.19.1 diff --git a/server/authjs-handler.ts b/server/authjs-handler.ts index c6777fd..87e9df8 100644 --- a/server/authjs-handler.ts +++ b/server/authjs-handler.ts @@ -17,12 +17,12 @@ const env: Record = typeof process?.env !== "undefined" ? process.env : import.meta && "env" in import.meta - ? ( - import.meta as ImportMeta & { - env: Record; - } - ).env - : {}; + ? ( + import.meta as ImportMeta & { + env: Record; + } + ).env + : {}; if (!globalThis.crypto) { /** @@ -30,7 +30,7 @@ if (!globalThis.crypto) { */ Object.defineProperty(globalThis, "crypto", { value: await import("node:crypto").then( - (crypto) => crypto.webcrypto as Crypto, + (crypto) => crypto.webcrypto as Crypto ), writable: false, configurable: true, @@ -40,7 +40,7 @@ if (!globalThis.crypto) { const authjsConfig = { basePath: "/api/auth", trustHost: Boolean( - env.AUTH_TRUST_HOST ?? env.VERCEL ?? env.NODE_ENV !== "production", + env.AUTH_TRUST_HOST ?? env.VERCEL ?? env.NODE_ENV !== "production" ), // TODO: Replace secret {@see https://authjs.dev/reference/core#secret} secret: "MY_SECRET", @@ -54,7 +54,11 @@ const authjsConfig = { }, async authorize() { // Add logic here to look up the user from the credentials supplied - const user = { id: "1", name: "J Smith", email: "jsmith@example.com" }; + const user = { + id: "019900bb-61b3-7333-b760-b27784dfe33b", + name: "J Smith", + email: "jsmith@example.com", + }; // Any object returned will be saved in `user` property of the JWT // If you return null then an error will be displayed advising the user to check their details. @@ -70,7 +74,7 @@ const authjsConfig = { */ export async function getSession( req: Request, - config: Omit, + config: Omit ): Promise { setEnvDefaults(process.env, config); const requestURL = new URL(req.url); @@ -79,12 +83,12 @@ export async function getSession( requestURL.protocol, req.headers, process.env, - config, + config ); const response = await Auth( new Request(url, { headers: { cookie: req.headers.get("cookie") ?? "" } }), - config, + config ); const { status = 200 } = response; diff --git a/types.ts b/types.ts index 80564b3..05463f6 100644 --- a/types.ts +++ b/types.ts @@ -1,6 +1,6 @@ import type { UIMessage } from "ai"; import type { generateText } from "ai"; -import type { Conversation, Fact, FactTrigger } from "./database/lowdb.js"; +import type { Conversation, Fact, FactTrigger } from "./database/common"; export type OtherParameters = Omit< Parameters[0],