transition to postgresql for persistence layer

master
Avraham Sakal 1 month ago
parent d827412feb
commit f4e9c62e96

@ -1,11 +1,42 @@
const { makeKyselyHook } = require("kanel-kysely"); const { makeKyselyHook } = require("kanel-kysely");
const { resolveType, escapeIdentifier } = require("kanel");
const { recase } = require("@kristiandupont/recase");
const toPascalCase = recase("snake", "pascal");
module.exports = { 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", enumStyle: "type",
outputPath: "./database/generated", outputPath: "./database/generated",
preRenderHooks: [makeKyselyHook()], 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: { customTypeMap: {
"pg_catalog.timestamp": "string", "pg_catalog.timestamp": "string",
} },
}; };

@ -1,9 +1,61 @@
export interface Entity<T, ID> { 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<T> {
construct: (data: T) => T; construct: (data: T) => T;
create: (data: T) => Promise<T>; create: (data: Omit<T, "id">) => Promise<T>;
createMany: (data: T[]) => Promise<T[]>; createMany: (data: Omit<T, "id">[]) => Promise<T[]>;
findAll: () => Promise<T[]>; findAll: () => Promise<T[]>;
findById: (id: ID) => Promise<T | undefined>; findById: (id: string) => Promise<T | undefined>;
update: (id: ID, data: Partial<T>) => Promise<void>; update: (id: string, data: Partial<T>) => Promise<void>;
delete: (id: ID) => Promise<void>; delete: (id: string) => Promise<void>;
}
export interface ConversationEntity extends Entity<Conversation> {
fetchMessages: (conversationId: string) => Promise<Array<CommittedMessage>>;
}
export interface FactEntity extends Entity<Fact> {
findByConversationId: (conversationId: string) => Promise<Array<Fact>>;
}
export interface MessageEntity extends Entity<CommittedMessage> {
findByConversationId: (
conversationId: string
) => Promise<Array<CommittedMessage>>;
}
export type FactTriggerEntity = Entity<FactTrigger> & {
findByFactId: (factId: string) => Promise<Array<FactTrigger>>;
};
export interface ApplicationDatabase {
conversations: ConversationEntity;
factTriggers: FactTriggerEntity;
facts: FactEntity;
messages: MessageEntity;
} }

@ -5,17 +5,17 @@ import type { UsersId } from './Users';
import type { ColumnType, Selectable, Insertable, Updateable } from 'kysely'; import type { ColumnType, Selectable, Insertable, Updateable } from 'kysely';
/** Identifier type for public.conversations */ /** Identifier type for public.conversations */
export type ConversationsId = number & { __brand: 'public.conversations' }; export type ConversationsId = string;
/** Represents the table public.conversations */ /** Represents the table public.conversations */
export default interface ConversationsTable { export default interface ConversationsTable {
id: ColumnType<ConversationsId, never, never>; id: ColumnType<ConversationsId, ConversationsId | undefined, ConversationsId>;
title: ColumnType<string | null, string | null, string | null>; title: ColumnType<string, string, string>;
createdAt: ColumnType<string | null, string | null, string | null>; createdAt: ColumnType<string, string | undefined, string>;
userId: ColumnType<UsersId | null, UsersId | null, UsersId | null>; userId: ColumnType<UsersId, UsersId, UsersId>;
} }
export type Conversations = Selectable<ConversationsTable>; export type Conversations = Selectable<ConversationsTable>;

@ -2,27 +2,26 @@
// This file is automatically generated by Kanel. Do not modify manually. // This file is automatically generated by Kanel. Do not modify manually.
import type { FactsId } from './Facts'; import type { FactsId } from './Facts';
import type { ConversationsId } from './Conversations';
import type { ColumnType, Selectable, Insertable, Updateable } from 'kysely'; import type { ColumnType, Selectable, Insertable, Updateable } from 'kysely';
/** Identifier type for public.fact_triggers */ /** Identifier type for public.fact_triggers */
export type FactTriggersId = number & { __brand: 'public.fact_triggers' }; export type FactTriggersId = string;
/** Represents the table public.fact_triggers */ /** Represents the table public.fact_triggers */
export default interface FactTriggersTable { export default interface FactTriggersTable {
id: ColumnType<FactTriggersId, never, never>; id: ColumnType<FactTriggersId, FactTriggersId | undefined, FactTriggersId>;
sourceFactId: ColumnType<FactsId | null, FactsId | null, FactsId | null>; sourceFactId: ColumnType<FactsId, FactsId, FactsId>;
content: ColumnType<string | null, string | null, string | null>; content: ColumnType<string, string, string>;
priorityMultiplier: ColumnType<number | null, number | null, number | null>; priorityMultiplier: ColumnType<number, number | undefined, number>;
priorityMultiplierReason: ColumnType<string | null, string | null, string | null>; priorityMultiplierReason: ColumnType<string | null, string | null, string | null>;
scopeConversationId: ColumnType<ConversationsId | null, ConversationsId | null, ConversationsId | null>; scopeConversationId: ColumnType<string, string, string>;
createdAt: ColumnType<string | null, string | null, string | null>; createdAt: ColumnType<string, string | undefined, string>;
} }
export type FactTriggers = Selectable<FactTriggersTable>; export type FactTriggers = Selectable<FactTriggersTable>;

@ -6,19 +6,19 @@ import type { MessagesId } from './Messages';
import type { ColumnType, Selectable, Insertable, Updateable } from 'kysely'; import type { ColumnType, Selectable, Insertable, Updateable } from 'kysely';
/** Identifier type for public.facts */ /** Identifier type for public.facts */
export type FactsId = number & { __brand: 'public.facts' }; export type FactsId = string;
/** Represents the table public.facts */ /** Represents the table public.facts */
export default interface FactsTable { export default interface FactsTable {
id: ColumnType<FactsId, never, never>; id: ColumnType<FactsId, FactsId | undefined, FactsId>;
userId: ColumnType<UsersId | null, UsersId | null, UsersId | null>; userId: ColumnType<UsersId, UsersId, UsersId>;
sourceMessageId: ColumnType<MessagesId | null, MessagesId | null, MessagesId | null>; sourceMessageId: ColumnType<MessagesId, MessagesId, MessagesId>;
content: ColumnType<string | null, string | null, string | null>; content: ColumnType<string, string, string>;
createdAt: ColumnType<string | null, string | null, string | null>; createdAt: ColumnType<string, string | undefined, string>;
} }
export type Facts = Selectable<FactsTable>; export type Facts = Selectable<FactsTable>;

@ -6,11 +6,11 @@ import type { default as Role } from './Role';
import type { ColumnType, Selectable, Insertable, Updateable } from 'kysely'; import type { ColumnType, Selectable, Insertable, Updateable } from 'kysely';
/** Identifier type for public.messages */ /** Identifier type for public.messages */
export type MessagesId = number & { __brand: 'public.messages' }; export type MessagesId = string;
/** Represents the table public.messages */ /** Represents the table public.messages */
export default interface MessagesTable { export default interface MessagesTable {
id: ColumnType<MessagesId, never, never>; id: ColumnType<MessagesId, MessagesId | undefined, MessagesId>;
conversationId: ColumnType<ConversationsId | null, ConversationsId | null, ConversationsId | null>; conversationId: ColumnType<ConversationsId | null, ConversationsId | null, ConversationsId | null>;
@ -18,9 +18,9 @@ export default interface MessagesTable {
runningSummary: ColumnType<string | null, string | null, string | null>; runningSummary: ColumnType<string | null, string | null, string | null>;
created_at: ColumnType<string | null, string | null, string | null>; createdAt: ColumnType<string | null, string | null, string | null>;
role: ColumnType<Role | null, Role | null, Role | null>; role: ColumnType<Role, Role, Role>;
parts: ColumnType<unknown | null, unknown | null, unknown | null>; parts: ColumnType<unknown | null, unknown | null, unknown | null>;
} }

@ -6,11 +6,11 @@ import type { MessagesId } from './Messages';
import type { ColumnType, Selectable, Insertable, Updateable } from 'kysely'; import type { ColumnType, Selectable, Insertable, Updateable } from 'kysely';
/** Identifier type for public.tools */ /** Identifier type for public.tools */
export type ToolsId = number & { __brand: 'public.tools' }; export type ToolsId = string;
/** Represents the table public.tools */ /** Represents the table public.tools */
export default interface ToolsTable { export default interface ToolsTable {
id: ColumnType<ToolsId, never, never>; id: ColumnType<ToolsId, ToolsId | undefined, ToolsId>;
userId: ColumnType<UsersId | null, UsersId | null, UsersId | null>; userId: ColumnType<UsersId | null, UsersId | null, UsersId | null>;

@ -4,11 +4,11 @@
import type { ColumnType, Selectable, Insertable, Updateable } from 'kysely'; import type { ColumnType, Selectable, Insertable, Updateable } from 'kysely';
/** Identifier type for public.users */ /** Identifier type for public.users */
export type UsersId = number & { __brand: 'public.users' }; export type UsersId = string;
/** Represents the table public.users */ /** Represents the table public.users */
export default interface UsersTable { export default interface UsersTable {
id: ColumnType<UsersId, never, never>; id: ColumnType<UsersId, UsersId | undefined, UsersId>;
username: ColumnType<string | null, string | null, string | null>; username: ColumnType<string | null, string | null, string | null>;

@ -0,0 +1,2 @@
// export { db } from "./lowdb";
export { db } from "./postgres";

@ -1,33 +1,17 @@
import { Low } from "lowdb"; import { Low } from "lowdb";
import { JSONFile } from "lowdb/node"; import { JSONFile } from "lowdb/node";
import type { CommittedMessage } from "../types"; 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"; 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 = { type DB = {
conversations: Array<Conversation>; conversations: Array<Conversation>;
messages: Array</*{ messages: Array</*{
@ -43,217 +27,219 @@ type DB = {
factTriggers: Array<FactTrigger>; factTriggers: Array<FactTrigger>;
}; };
export const db = new Low<DB>(new JSONFile("db.json"), { export const dbClient = new Low<DB>(new JSONFile("db.json"), {
conversations: [], conversations: [],
messages: [], messages: [],
facts: [], facts: [],
factTriggers: [], factTriggers: [],
}); });
/** Initialize the database. Sets `db.data` to the default state if the file doesn't exist. */ /** 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. */ /** Write the database to the file, in case it didn't exist before. */
await db.write(); await dbClient.write();
const conversations: Entity<Conversation, string> & { const conversations: ConversationEntity = {
fetchMessages: (conversationId: string) => Promise<Array<CommittedMessage>>;
} = {
construct: (conversation: Conversation) => conversation, construct: (conversation: Conversation) => conversation,
create: async (conversation: Conversation) => { create: async (_conversation) => {
conversation.id = conversation.id ?? nanoid(); const conversation = { ..._conversation, id: nanoid() };
await db.data.conversations.push(conversation); await dbClient.data.conversations.push(conversation);
await db.write(); await dbClient.write();
return conversation; return conversation;
}, },
createMany: async (conversations: Array<Conversation>) => { createMany: async (_conversations) => {
await db.data.conversations.push(...conversations); const conversations = _conversations.map((c) => ({ ...c, id: nanoid() }));
await db.write(); await dbClient.data.conversations.push(...conversations);
await dbClient.write();
return conversations; return conversations;
}, },
findAll: async () => { findAll: async () => {
return db.data.conversations; return dbClient.data.conversations;
}, },
findById: async (id) => { 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<Conversation>) => { update: async (id, data: Partial<Conversation>) => {
const conversationIndex = db.data.conversations.findIndex( const conversationIndex = dbClient.data.conversations.findIndex(
(c) => c.id === id (c) => c.id === id
); );
if (conversationIndex === -1) throw new Error("Conversation not found"); if (conversationIndex === -1) throw new Error("Conversation not found");
db.data.conversations[conversationIndex] = { dbClient.data.conversations[conversationIndex] = {
...db.data.conversations[conversationIndex], ...dbClient.data.conversations[conversationIndex],
...data, ...data,
}; };
await db.write(); await dbClient.write();
}, },
delete: async (id) => { delete: async (id) => {
db.data.conversations.splice( dbClient.data.conversations.splice(
db.data.conversations.findIndex((c) => c.id === id), dbClient.data.conversations.findIndex((c) => c.id === id),
1 1
); );
const deletedMessageIds = db.data.messages const deletedMessageIds = dbClient.data.messages
.filter((m) => m.conversationId === id) .filter((m) => m.conversationId === id)
.map((m) => m.id); .map((m) => m.id);
db.data.messages = db.data.messages.filter((m) => m.conversationId !== id); dbClient.data.messages = dbClient.data.messages.filter(
const deletedFactIds = db.data.facts (m) => m.conversationId !== id
);
const deletedFactIds = dbClient.data.facts
.filter((fact) => deletedMessageIds.includes(fact.sourceMessageId)) .filter((fact) => deletedMessageIds.includes(fact.sourceMessageId))
.map((fact) => fact.id); .map((fact) => fact.id);
db.data.facts = db.data.facts.filter( dbClient.data.facts = dbClient.data.facts.filter(
(fact) => !deletedFactIds.includes(fact.id) (fact) => !deletedFactIds.includes(fact.id)
); );
db.data.factTriggers = db.data.factTriggers.filter( dbClient.data.factTriggers = dbClient.data.factTriggers.filter(
(factTrigger) => !deletedFactIds.includes(factTrigger.sourceFactId) (factTrigger) => !deletedFactIds.includes(factTrigger.sourceFactId)
); );
await db.write(); await dbClient.write();
}, },
fetchMessages: async (conversationId) => { fetchMessages: async (conversationId) => {
const rows = await db.data.messages.filter( const rows = await dbClient.data.messages.filter(
(m) => m.conversationId === conversationId (m) => m.conversationId === conversationId
); );
return rows as Array<CommittedMessage>; return rows as Array<CommittedMessage>;
}, },
}; };
const factTriggers: Entity<FactTrigger, string> & { const factTriggers: FactTriggerEntity = {
findByFactId: (factId: string) => Promise<Array<FactTrigger>>;
} = {
construct: (factTrigger: FactTrigger) => factTrigger, construct: (factTrigger: FactTrigger) => factTrigger,
create: async (factTrigger: FactTrigger) => { create: async (_factTrigger) => {
factTrigger.id = factTrigger.id ?? nanoid(); const factTrigger = { ..._factTrigger, id: nanoid() };
await db.data.factTriggers.push(factTrigger); await dbClient.data.factTriggers.push(factTrigger);
await db.write(); await dbClient.write();
return factTrigger; return factTrigger;
}, },
createMany: async (factTriggers: Array<FactTrigger>) => { createMany: async (_factTriggers) => {
await db.data.factTriggers.push(...factTriggers); const factTriggers = _factTriggers.map((f) => ({ ...f, id: nanoid() }));
await db.write(); await dbClient.data.factTriggers.push(...factTriggers);
await dbClient.write();
return factTriggers; return factTriggers;
}, },
findAll: async () => { findAll: async () => {
return db.data.factTriggers; return dbClient.data.factTriggers;
}, },
findById: async (id) => { 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<FactTrigger>) => { update: async (id, data: Partial<FactTrigger>) => {
const factTriggerIndex = db.data.factTriggers.findIndex( const factTriggerIndex = dbClient.data.factTriggers.findIndex(
(factTrigger) => factTrigger.id === id (factTrigger) => factTrigger.id === id
); );
if (factTriggerIndex === -1) throw new Error("Fact trigger not found"); if (factTriggerIndex === -1) throw new Error("Fact trigger not found");
db.data.factTriggers[factTriggerIndex] = { dbClient.data.factTriggers[factTriggerIndex] = {
...db.data.factTriggers[factTriggerIndex], ...dbClient.data.factTriggers[factTriggerIndex],
...data, ...data,
}; };
await db.write(); await dbClient.write();
}, },
delete: async (id) => { delete: async (id) => {
const deletedFactTriggerIndex = db.data.factTriggers.findIndex( const deletedFactTriggerIndex = dbClient.data.factTriggers.findIndex(
(factTrigger) => factTrigger.id === id (factTrigger) => factTrigger.id === id
); );
if (deletedFactTriggerIndex === -1) if (deletedFactTriggerIndex === -1)
throw new Error("Fact trigger not found"); throw new Error("Fact trigger not found");
db.data.factTriggers.splice(deletedFactTriggerIndex, 1); dbClient.data.factTriggers.splice(deletedFactTriggerIndex, 1);
await db.write(); await dbClient.write();
}, },
findByFactId: async (factId: string) => { findByFactId: async (factId: string) => {
return db.data.factTriggers.filter( return dbClient.data.factTriggers.filter(
(factTrigger) => factTrigger.sourceFactId === factId (factTrigger) => factTrigger.sourceFactId === factId
); );
}, },
}; };
const facts: Entity<Fact, string> & { const facts: FactEntity = {
findByConversationId: (conversationId: string) => Promise<Array<Fact>>;
} = {
construct: (fact: Fact) => fact, construct: (fact: Fact) => fact,
create: async (fact: Fact) => { create: async (_fact) => {
fact.id = fact.id ?? nanoid(); const fact = { ..._fact, id: nanoid() };
await db.data.facts.push(fact); await dbClient.data.facts.push(fact);
await db.write(); await dbClient.write();
return fact; return fact;
}, },
createMany: async (facts: Array<Fact>) => { createMany: async (_facts) => {
await db.data.facts.push(...facts); const facts = _facts.map((f) => ({ ...f, id: nanoid() }));
await db.write(); await dbClient.data.facts.push(...facts);
await dbClient.write();
return facts; return facts;
}, },
findAll: async () => { findAll: async () => {
return db.data.facts; return dbClient.data.facts;
}, },
findById: async (id) => { 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<Fact>) => { update: async (id, data: Partial<Fact>) => {
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"); if (factIndex === -1) throw new Error("Fact not found");
db.data.facts[factIndex] = { dbClient.data.facts[factIndex] = {
...db.data.facts[factIndex], ...dbClient.data.facts[factIndex],
...data, ...data,
}; };
await db.write(); await dbClient.write();
}, },
delete: async (id) => { 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"); if (deletedFactId === -1) throw new Error("Fact not found");
db.data.facts.splice(deletedFactId, 1); dbClient.data.facts.splice(deletedFactId, 1);
await db.write(); await dbClient.write();
}, },
findByConversationId: async (conversationId) => { findByConversationId: async (conversationId) => {
const conversationMessageIds = db.data.messages const conversationMessageIds = dbClient.data.messages
.filter((m) => m.conversationId === conversationId) .filter((m) => m.conversationId === conversationId)
.map((m) => m.id); .map((m) => m.id);
const rows = await db.data.facts.filter((f) => const rows = await dbClient.data.facts.filter((f) =>
conversationMessageIds.includes(f.sourceMessageId) conversationMessageIds.includes(f.sourceMessageId)
); );
return rows as Array<Fact>; return rows as Array<Fact>;
}, },
}; };
const messages: Entity<CommittedMessage, string> & { const messages: MessageEntity = {
findByConversationId: (
conversationId: string
) => Promise<Array<CommittedMessage>>;
} = {
construct: (message: CommittedMessage) => message, construct: (message: CommittedMessage) => message,
create: async (message: CommittedMessage) => { create: async (_message) => {
message.id = message.id ?? nanoid(); const message = { ..._message, id: nanoid() };
await db.data.messages.push(message); await dbClient.data.messages.push(message);
await db.write(); await dbClient.write();
return message; return message;
}, },
createMany: async (messages: Array<CommittedMessage>) => { createMany: async (_messages) => {
await db.data.messages.push(...messages); const messages = _messages.map((m) => ({ ...m, id: nanoid() }));
await db.write(); await dbClient.data.messages.push(...messages);
await dbClient.write();
return messages; return messages;
}, },
findAll: async () => { findAll: async () => {
return db.data.messages; return dbClient.data.messages;
}, },
findById: async (id) => { 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<CommittedMessage>) => { update: async (id, data: Partial<CommittedMessage>) => {
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"); if (messageIndex === -1) throw new Error("Message not found");
db.data.messages[messageIndex] = { dbClient.data.messages[messageIndex] = {
...db.data.messages[messageIndex], ...dbClient.data.messages[messageIndex],
...data, ...data,
}; };
await db.write(); await dbClient.write();
}, },
delete: async (id) => { delete: async (id) => {
db.data.messages.splice( dbClient.data.messages.splice(
db.data.messages.findIndex((m) => m.id === id), dbClient.data.messages.findIndex((m) => m.id === id),
1 1
); );
await db.write(); await dbClient.write();
}, },
findByConversationId: async (conversationId) => { 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, conversations,
factTriggers, factTriggers,
facts, facts,

@ -1,6 +1,14 @@
import { Pool } from "pg"; import { Pool } from "pg";
import { Kysely, PostgresDialect } from "kysely"; import { Kysely, PostgresDialect } from "kysely";
import type Database from "./generated/Database"; 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({ export const pool = new Pool({
connectionString: connectionString:
@ -16,6 +24,226 @@ const dialect = new PostgresDialect({
// knows your database structure. // knows your database structure.
// Dialect is passed to Kysely's constructor, and from now on, Kysely knows how // Dialect is passed to Kysely's constructor, and from now on, Kysely knows how
// to communicate with your database. // to communicate with your database.
export const db = new Kysely<Database>({ export const dbClient = new Kysely<Database>({
dialect, 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<CommittedMessage>;
},
};
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<CommittedMessage>;
},
findAll: async () => {
const rows = await dbClient.selectFrom("messages").selectAll().execute();
return rows as Array<CommittedMessage>;
},
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<CommittedMessage>;
},
};
export const db = {
conversations,
facts,
factTriggers,
messages,
};

@ -49,6 +49,7 @@
"@biomejs/biome": "1.9.4", "@biomejs/biome": "1.9.4",
"@cloudflare/workers-types": "^4.20250620.0", "@cloudflare/workers-types": "^4.20250620.0",
"@hono/vite-dev-server": "^0.19.1", "@hono/vite-dev-server": "^0.19.1",
"@kristiandupont/recase": "^1.4.1",
"@types/node": "^20.19.0", "@types/node": "^20.19.0",
"@types/pg": "^8.15.4", "@types/pg": "^8.15.4",
"@types/react": "^19.1.8", "@types/react": "^19.1.8",

@ -3,29 +3,28 @@ import {
publicProcedure, publicProcedure,
createCallerFactory, createCallerFactory,
} from "../../trpc/server"; } from "../../trpc/server";
import { _db } from "../../database/lowdb"; import { db } from "../../database/index";
export const conversations = router({ export const conversations = router({
fetchAll: publicProcedure.query(async () => { fetchAll: publicProcedure.query(async () => {
return await _db.conversations.findAll(); return await db.conversations.findAll();
}), }),
fetchOne: publicProcedure fetchOne: publicProcedure
.input((x) => x as { id: string }) .input((x) => x as { id: string })
.query(async ({ input: { id } }) => { .query(async ({ input: { id } }) => {
return await _db.conversations.findById(id); return await db.conversations.findById(id);
}), }),
start: publicProcedure.mutation(async () => { start: publicProcedure.mutation(async () => {
const row = { const row = {
id: "",
title: "New Conversation", 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 deleteOne: publicProcedure
.input((x) => x as { id: string }) .input((x) => x as { id: string })
.mutation(async ({ input: { id } }) => { .mutation(async ({ input: { id } }) => {
await _db.conversations.delete(id); await db.conversations.delete(id);
return { ok: true }; return { ok: true };
}), }),
updateTitle: publicProcedure updateTitle: publicProcedure
@ -37,13 +36,13 @@ export const conversations = router({
} }
) )
.mutation(async ({ input: { id, title } }) => { .mutation(async ({ input: { id, title } }) => {
await _db.conversations.update(id, { title }); await db.conversations.update(id, { title });
return { ok: true }; return { ok: true };
}), }),
fetchMessages: publicProcedure fetchMessages: publicProcedure
.input((x) => x as { conversationId: string }) .input((x) => x as { conversationId: string })
.query(async ({ input: { conversationId } }) => { .query(async ({ input: { conversationId } }) => {
return await _db.conversations.fetchMessages(conversationId); return await db.conversations.fetchMessages(conversationId);
}), }),
}); });

@ -3,10 +3,11 @@ import {
publicProcedure, publicProcedure,
createCallerFactory, createCallerFactory,
} from "../../trpc/server.js"; } 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 type { DraftMessage } from "../../types.js";
import { openrouter, MODEL_NAME } from "./provider.js"; import { openrouter, MODEL_NAME } from "./provider.js";
import { generateObject, generateText, jsonSchema } from "ai"; import { generateObject, generateText, jsonSchema } from "ai";
import type { Fact } from "../../database/common.js";
const factTriggersSystemPrompt = ({ const factTriggersSystemPrompt = ({
previousRunningSummary, previousRunningSummary,
@ -61,7 +62,7 @@ export const factTriggers = router({
fetchByFactId: publicProcedure fetchByFactId: publicProcedure
.input((x) => x as { factId: string }) .input((x) => x as { factId: string })
.query(async ({ input: { factId } }) => { .query(async ({ input: { factId } }) => {
return _db.factTriggers.findByFactId(factId); return db.factTriggers.findByFactId(factId);
}), }),
deleteOne: publicProcedure deleteOne: publicProcedure
.input( .input(
@ -71,7 +72,7 @@ export const factTriggers = router({
} }
) )
.mutation(async ({ input: { factTriggerId } }) => { .mutation(async ({ input: { factTriggerId } }) => {
await _db.factTriggers.delete(factTriggerId); await db.factTriggers.delete(factTriggerId);
return { ok: true }; return { ok: true };
}), }),
update: publicProcedure update: publicProcedure
@ -83,7 +84,7 @@ export const factTriggers = router({
} }
) )
.mutation(async ({ input: { factTriggerId, content } }) => { .mutation(async ({ input: { factTriggerId, content } }) => {
_db.factTriggers.update(factTriggerId, { content }); db.factTriggers.update(factTriggerId, { content });
return { ok: true }; return { ok: true };
}), }),
generateFromFact: publicProcedure generateFromFact: publicProcedure

@ -3,7 +3,7 @@ import {
publicProcedure, publicProcedure,
createCallerFactory, createCallerFactory,
} from "../../trpc/server.js"; } from "../../trpc/server.js";
import { _db } from "../../database/lowdb.js"; import { db } from "../../database/index.js";
import type { DraftMessage } from "../../types.js"; import type { DraftMessage } from "../../types.js";
import { MODEL_NAME, openrouter } from "./provider.js"; import { MODEL_NAME, openrouter } from "./provider.js";
import { generateObject, generateText, jsonSchema } from "ai"; import { generateObject, generateText, jsonSchema } from "ai";
@ -57,7 +57,7 @@ export const facts = router({
fetchByConversationId: publicProcedure fetchByConversationId: publicProcedure
.input((x) => x as { conversationId: string }) .input((x) => x as { conversationId: string })
.query(async ({ input: { conversationId } }) => { .query(async ({ input: { conversationId } }) => {
return await _db.facts.findByConversationId(conversationId); return await db.facts.findByConversationId(conversationId);
}), }),
deleteOne: publicProcedure deleteOne: publicProcedure
.input( .input(
@ -67,7 +67,7 @@ export const facts = router({
} }
) )
.mutation(async ({ input: { factId } }) => { .mutation(async ({ input: { factId } }) => {
await _db.facts.delete(factId); await db.facts.delete(factId);
return { ok: true }; return { ok: true };
}), }),
update: publicProcedure update: publicProcedure
@ -79,7 +79,7 @@ export const facts = router({
} }
) )
.mutation(async ({ input: { factId, content } }) => { .mutation(async ({ input: { factId, content } }) => {
await _db.facts.update(factId, { content }); await db.facts.update(factId, { content });
return { ok: true }; return { ok: true };
}), }),
extractFromNewMessages: publicProcedure extractFromNewMessages: publicProcedure

@ -6,7 +6,7 @@ import {
import { MODEL_NAME, openrouter } from "./provider.js"; import { MODEL_NAME, openrouter } from "./provider.js";
import { generateObject, generateText, jsonSchema } from "ai"; import { generateObject, generateText, jsonSchema } from "ai";
import type { DraftMessage } from "../../types.js"; import type { DraftMessage } from "../../types.js";
import { _db } from "../../database/lowdb"; import { db } from "../../database/index";
const runningSummarySystemPrompt = ({ const runningSummarySystemPrompt = ({
previousRunningSummary, previousRunningSummary,
@ -52,7 +52,7 @@ export const messages = router({
fetchByConversationId: publicProcedure fetchByConversationId: publicProcedure
.input((x) => x as { conversationId: string }) .input((x) => x as { conversationId: string })
.query(async ({ input: { conversationId } }) => { .query(async ({ input: { conversationId } }) => {
return await _db.messages.findByConversationId(conversationId); return await db.messages.findByConversationId(conversationId);
}), }),
generateRunningSummary: publicProcedure generateRunningSummary: publicProcedure
.input( .input(

@ -5,5 +5,5 @@ export const openrouter = createOpenRouter({
}); });
// export const MODEL_NAME = "mistralai/mistral-nemo"; // export const MODEL_NAME = "mistralai/mistral-nemo";
// export const MODEL_NAME = "openai/gpt-oss-20b"; export const MODEL_NAME = "openai/gpt-oss-20b";
export const MODEL_NAME = "openai/gpt-5-mini"; // export const MODEL_NAME = "openai/gpt-5-mini";

@ -14,8 +14,7 @@ import type {
// ConsistencyLevelEnum, // ConsistencyLevelEnum,
// type NumberArrayId, // type NumberArrayId,
// } from "@zilliz/milvus2-sdk-node"; // } from "@zilliz/milvus2-sdk-node";
import { db, type FactTrigger, type Fact, _db } from "../../database/lowdb.js"; import { db } from "../../database/index.js";
import { nanoid } from "nanoid";
import { conversations } from "./conversations.js"; import { conversations } from "./conversations.js";
import { messages } from "./messages.js"; import { messages } from "./messages.js";
import { facts, createCaller as createCallerFacts } from "./facts.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 { createCaller as createCallerFactTriggers } from "./fact-triggers.js";
import { factTriggers } from "./fact-triggers.js"; import { factTriggers } from "./fact-triggers.js";
import { MODEL_NAME, openrouter } from "./provider.js"; import { MODEL_NAME, openrouter } from "./provider.js";
import type { Fact, FactTrigger } from "../../database/common.js";
const factsCaller = createCallerFacts({}); const factsCaller = createCallerFacts({});
const messagesCaller = createCallerMessages({}); const messagesCaller = createCallerMessages({});
@ -96,16 +96,14 @@ export const chat = router({
previousRunningSummaryIndex + 1 previousRunningSummaryIndex + 1
); );
/** Save the incoming message to the database. */ /** Save the incoming message to the database. */
const insertedUserMessage: CommittedMessage = { const insertedUserMessage = await db.messages.create({
id: nanoid(),
conversationId, conversationId,
// content: messages[messages.length - 1].content, // content: messages[messages.length - 1].content,
// role: "user" as const, // role: "user" as const,
...messages[messages.length - 1], ...messages[messages.length - 1],
index: messages.length - 1, index: messages.length - 1,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
}; });
await _db.messages.create(insertedUserMessage);
/** Generate a new message from the model, but hold-off on adding it to /** Generate a new message from the model, but hold-off on adding it to
* the database until we produce the associated running-summary, below. * the database until we produce the associated running-summary, below.
@ -154,15 +152,13 @@ export const chat = router({
messagesSincePreviousRunningSummary: [], messagesSincePreviousRunningSummary: [],
newMessages: messagesSincePreviousRunningSummary, newMessages: messagesSincePreviousRunningSummary,
}); });
const insertedFactsFromUserMessage: Array<Fact> = const insertedFactsFromUserMessage = await db.facts.createMany(
factsFromUserMessageResponse.object.facts.map((fact) => ({ factsFromUserMessageResponse.object.facts.map((fact) => ({
id: nanoid(), userId: "019900bb-61b3-7333-b760-b27784dfe33b",
userId: "1",
sourceMessageId: insertedUserMessage.id, sourceMessageId: insertedUserMessage.id,
content: fact, content: fact,
createdAt: new Date().toISOString(), }))
})); );
_db.facts.createMany(insertedFactsFromUserMessage);
/** Produce a running summary of the conversation, and save that along /** Produce a running summary of the conversation, and save that along
* with the model's response to the database. The new running summary is * with the model's response to the database. The new running summary is
@ -174,8 +170,7 @@ export const chat = router({
mainResponseContent: mainResponse.text, mainResponseContent: mainResponse.text,
previousRunningSummary, previousRunningSummary,
}); });
const insertedAssistantMessage: CommittedMessage = { const insertedAssistantMessage = await db.messages.create({
id: nanoid(),
conversationId, conversationId,
// content: mainResponse.text, // content: mainResponse.text,
parts: [{ type: "text", text: mainResponse.text }], parts: [{ type: "text", text: mainResponse.text }],
@ -183,8 +178,7 @@ export const chat = router({
role: "assistant" as const, role: "assistant" as const,
index: messages.length, index: messages.length,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
}; });
await _db.messages.create(insertedAssistantMessage);
/** Extract Facts from the model's response, and add them to the database, /** Extract Facts from the model's response, and add them to the database,
* linking the Facts with the messages they came from. */ * linking the Facts with the messages they came from. */
const factsFromAssistantMessageResponse = const factsFromAssistantMessageResponse =
@ -200,15 +194,14 @@ export const chat = router({
], ],
}); });
const insertedFactsFromAssistantMessage: Array<Fact> = const insertedFactsFromAssistantMessage = await db.facts.createMany(
factsFromAssistantMessageResponse.object.facts.map((factContent) => ({ factsFromAssistantMessageResponse.object.facts.map((factContent) => ({
id: nanoid(), userId: "019900bb-61b3-7333-b760-b27784dfe33b",
userId: "1",
sourceMessageId: insertedAssistantMessage.id, sourceMessageId: insertedAssistantMessage.id,
content: factContent, content: factContent,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
})); }))
_db.facts.createMany(insertedFactsFromAssistantMessage); );
const insertedFacts = [ const insertedFacts = [
...insertedFactsFromUserMessage, ...insertedFactsFromUserMessage,
@ -227,9 +220,8 @@ export const chat = router({
messagesSincePreviousRunningSummary, messagesSincePreviousRunningSummary,
fact, fact,
}); });
const insertedFactTriggers: Array<FactTrigger> = const insertedFactTriggers: Array<Omit<FactTrigger, "id">> =
factTriggers.object.factTriggers.map((factTrigger) => ({ factTriggers.object.factTriggers.map((factTrigger) => ({
id: nanoid(),
sourceFactId: fact.id, sourceFactId: fact.id,
content: factTrigger, content: factTrigger,
priorityMultiplier: 1, priorityMultiplier: 1,
@ -237,10 +229,10 @@ export const chat = router({
scopeConversationId: conversationId, scopeConversationId: conversationId,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
})); }));
_db.factTriggers.createMany(insertedFactTriggers); db.factTriggers.createMany(insertedFactTriggers);
} }
await db.write(); // await db.write();
return { return {
insertedAssistantMessage, insertedAssistantMessage,

@ -117,6 +117,9 @@ importers:
'@hono/vite-dev-server': '@hono/vite-dev-server':
specifier: ^0.19.1 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)) 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': '@types/node':
specifier: ^20.19.0 specifier: ^20.19.0
version: 20.19.1 version: 20.19.1

@ -17,12 +17,12 @@ const env: Record<string, string | undefined> =
typeof process?.env !== "undefined" typeof process?.env !== "undefined"
? process.env ? process.env
: import.meta && "env" in import.meta : import.meta && "env" in import.meta
? ( ? (
import.meta as ImportMeta & { import.meta as ImportMeta & {
env: Record<string, string | undefined>; env: Record<string, string | undefined>;
} }
).env ).env
: {}; : {};
if (!globalThis.crypto) { if (!globalThis.crypto) {
/** /**
@ -30,7 +30,7 @@ if (!globalThis.crypto) {
*/ */
Object.defineProperty(globalThis, "crypto", { Object.defineProperty(globalThis, "crypto", {
value: await import("node:crypto").then( value: await import("node:crypto").then(
(crypto) => crypto.webcrypto as Crypto, (crypto) => crypto.webcrypto as Crypto
), ),
writable: false, writable: false,
configurable: true, configurable: true,
@ -40,7 +40,7 @@ if (!globalThis.crypto) {
const authjsConfig = { const authjsConfig = {
basePath: "/api/auth", basePath: "/api/auth",
trustHost: Boolean( 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} // TODO: Replace secret {@see https://authjs.dev/reference/core#secret}
secret: "MY_SECRET", secret: "MY_SECRET",
@ -54,7 +54,11 @@ const authjsConfig = {
}, },
async authorize() { async authorize() {
// Add logic here to look up the user from the credentials supplied // 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 // 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. // 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( export async function getSession(
req: Request, req: Request,
config: Omit<AuthConfig, "raw">, config: Omit<AuthConfig, "raw">
): Promise<Session | null> { ): Promise<Session | null> {
setEnvDefaults(process.env, config); setEnvDefaults(process.env, config);
const requestURL = new URL(req.url); const requestURL = new URL(req.url);
@ -79,12 +83,12 @@ export async function getSession(
requestURL.protocol, requestURL.protocol,
req.headers, req.headers,
process.env, process.env,
config, config
); );
const response = await Auth( const response = await Auth(
new Request(url, { headers: { cookie: req.headers.get("cookie") ?? "" } }), new Request(url, { headers: { cookie: req.headers.get("cookie") ?? "" } }),
config, config
); );
const { status = 200 } = response; const { status = 200 } = response;

@ -1,6 +1,6 @@
import type { UIMessage } from "ai"; import type { UIMessage } from "ai";
import type { generateText } 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< export type OtherParameters = Omit<
Parameters<typeof generateText>[0], Parameters<typeof generateText>[0],

Loading…
Cancel
Save