@mordn/chat-widget 0.7.1 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,238 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc2) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc2 = __getOwnPropDesc(from, key)) || desc2.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/server/stores/drizzle/index.ts
31
+ var drizzle_exports = {};
32
+ __export(drizzle_exports, {
33
+ createDrizzleChatStore: () => createDrizzleChatStore,
34
+ getDrizzleDb: () => getDrizzleDb,
35
+ schema: () => schema_exports
36
+ });
37
+ module.exports = __toCommonJS(drizzle_exports);
38
+ var import_server_only3 = require("server-only");
39
+
40
+ // src/server/stores/drizzle/store.ts
41
+ var import_server_only2 = require("server-only");
42
+ var import_drizzle_orm = require("drizzle-orm");
43
+ var import_ai = require("ai");
44
+
45
+ // src/server/chat-store.ts
46
+ var ConversationOwnershipError = class extends Error {
47
+ constructor(conversationId) {
48
+ super(`Conversation ${conversationId} is not owned by the current user`);
49
+ this.conversationId = conversationId;
50
+ this.name = "ConversationOwnershipError";
51
+ }
52
+ };
53
+
54
+ // src/server/stores/drizzle/client.ts
55
+ var import_server_only = require("server-only");
56
+ var import_postgres_js = require("drizzle-orm/postgres-js");
57
+ var import_postgres = __toESM(require("postgres"));
58
+
59
+ // src/server/stores/drizzle/schema.ts
60
+ var schema_exports = {};
61
+ __export(schema_exports, {
62
+ conversations: () => conversations,
63
+ messages: () => messages
64
+ });
65
+ var import_pg_core = require("drizzle-orm/pg-core");
66
+ var conversations = (0, import_pg_core.pgTable)(
67
+ "chat_conversations",
68
+ {
69
+ id: (0, import_pg_core.text)("id").primaryKey(),
70
+ userId: (0, import_pg_core.text)("user_id").notNull(),
71
+ title: (0, import_pg_core.text)("title").notNull().default("New Chat"),
72
+ /** Free-form host-app metadata. Never read by the core. */
73
+ metadata: (0, import_pg_core.jsonb)("metadata").$type(),
74
+ createdAt: (0, import_pg_core.timestamp)("created_at", { withTimezone: true }).defaultNow().notNull(),
75
+ updatedAt: (0, import_pg_core.timestamp)("updated_at", { withTimezone: true }).defaultNow().notNull()
76
+ },
77
+ (table) => [
78
+ // Drives the history list (WHERE user_id = ? ORDER BY updated_at DESC).
79
+ (0, import_pg_core.index)("chat_conversations_user_updated_idx").on(table.userId, table.updatedAt)
80
+ ]
81
+ );
82
+ var messages = (0, import_pg_core.pgTable)(
83
+ "chat_messages",
84
+ {
85
+ id: (0, import_pg_core.text)("id").primaryKey(),
86
+ conversationId: (0, import_pg_core.text)("conversation_id").notNull().references(() => conversations.id, { onDelete: "cascade" }),
87
+ role: (0, import_pg_core.text)("role").notNull().$type(),
88
+ /** Canonical AI SDK parts — source of truth. */
89
+ parts: (0, import_pg_core.jsonb)("parts").$type().notNull(),
90
+ /** Denormalised plain-text projection for previews/search. */
91
+ text: (0, import_pg_core.text)("text").notNull().default(""),
92
+ /** Model that produced this message (assistant turns). */
93
+ model: (0, import_pg_core.text)("model"),
94
+ createdAt: (0, import_pg_core.timestamp)("created_at", { withTimezone: true }).defaultNow().notNull()
95
+ },
96
+ (table) => [
97
+ // Drives history load (WHERE conversation_id = ? ORDER BY created_at).
98
+ (0, import_pg_core.index)("chat_messages_conversation_created_idx").on(table.conversationId, table.createdAt)
99
+ ]
100
+ );
101
+
102
+ // src/server/stores/drizzle/client.ts
103
+ var globalForDb = globalThis;
104
+ function getDrizzleDb(options = {}) {
105
+ const connectionString = options.connectionString ?? process.env.DATABASE_URL;
106
+ if (!connectionString) {
107
+ throw new Error(
108
+ "[chat-widget] DATABASE_URL is not set and no connectionString was provided to the default Drizzle store."
109
+ );
110
+ }
111
+ const cache = globalForDb.__mordnChatWidgetDb ?? (globalForDb.__mordnChatWidgetDb = /* @__PURE__ */ new Map());
112
+ const cached = cache.get(connectionString);
113
+ if (cached) return cached;
114
+ const client = (0, import_postgres.default)(connectionString, {
115
+ prepare: false,
116
+ // required for Supabase transaction-mode pooler
117
+ max: options.max ?? 5,
118
+ idle_timeout: 20,
119
+ connect_timeout: 10
120
+ });
121
+ const db = (0, import_postgres_js.drizzle)(client, { schema: schema_exports });
122
+ if (process.env.NODE_ENV !== "production") cache.set(connectionString, db);
123
+ return db;
124
+ }
125
+
126
+ // src/server/stores/drizzle/store.ts
127
+ var MAX_PAGE = 100;
128
+ function textFromParts(parts) {
129
+ if (!Array.isArray(parts)) return "";
130
+ return parts.filter(
131
+ (p) => p.type === "text" && typeof p.text === "string"
132
+ ).map((p) => p.text).join("").trim();
133
+ }
134
+ function toStoredConversation(row, messageCount) {
135
+ return {
136
+ id: row.id,
137
+ title: row.title,
138
+ metadata: row.metadata ?? null,
139
+ createdAt: row.createdAt,
140
+ updatedAt: row.updatedAt,
141
+ messageCount
142
+ };
143
+ }
144
+ function toStoredMessage(row) {
145
+ return {
146
+ id: row.id,
147
+ role: row.role,
148
+ parts: row.parts,
149
+ text: row.text,
150
+ model: row.model ?? void 0,
151
+ createdAt: row.createdAt
152
+ };
153
+ }
154
+ var DrizzleChatStore = class {
155
+ constructor(userId, db) {
156
+ this.userId = userId;
157
+ this.db = db;
158
+ }
159
+ async listConversations() {
160
+ const rows = await this.db.select({
161
+ id: conversations.id,
162
+ userId: conversations.userId,
163
+ title: conversations.title,
164
+ metadata: conversations.metadata,
165
+ createdAt: conversations.createdAt,
166
+ updatedAt: conversations.updatedAt,
167
+ messageCount: import_drizzle_orm.sql`(
168
+ SELECT COUNT(*)::int FROM ${messages}
169
+ WHERE ${messages.conversationId} = ${conversations.id}
170
+ )`
171
+ }).from(conversations).where((0, import_drizzle_orm.eq)(conversations.userId, this.userId)).orderBy((0, import_drizzle_orm.desc)(conversations.updatedAt));
172
+ return rows.map((r) => toStoredConversation(r, r.messageCount));
173
+ }
174
+ async getConversation(id) {
175
+ const rows = await this.db.select().from(conversations).where((0, import_drizzle_orm.and)((0, import_drizzle_orm.eq)(conversations.id, id), (0, import_drizzle_orm.eq)(conversations.userId, this.userId))).limit(1);
176
+ return rows.length ? toStoredConversation(rows[0]) : null;
177
+ }
178
+ async ensureConversation(id, init) {
179
+ const existing = await this.db.select({ id: conversations.id, userId: conversations.userId }).from(conversations).where((0, import_drizzle_orm.eq)(conversations.id, id)).limit(1);
180
+ if (existing.length) {
181
+ if (existing[0].userId !== this.userId) throw new ConversationOwnershipError(id);
182
+ const full = await this.getConversation(id);
183
+ if (full) return full;
184
+ }
185
+ await this.db.insert(conversations).values({ id, userId: this.userId, title: init?.title ?? "New Chat", metadata: {} }).onConflictDoNothing({ target: conversations.id });
186
+ const created = await this.getConversation(id);
187
+ if (created) return created;
188
+ throw new ConversationOwnershipError(id);
189
+ }
190
+ async renameConversation(id, title) {
191
+ await this.db.update(conversations).set({ title, updatedAt: /* @__PURE__ */ new Date() }).where((0, import_drizzle_orm.and)((0, import_drizzle_orm.eq)(conversations.id, id), (0, import_drizzle_orm.eq)(conversations.userId, this.userId)));
192
+ }
193
+ async deleteConversation(id) {
194
+ const deleted = await this.db.delete(conversations).where((0, import_drizzle_orm.and)((0, import_drizzle_orm.eq)(conversations.id, id), (0, import_drizzle_orm.eq)(conversations.userId, this.userId))).returning({ id: conversations.id });
195
+ return deleted.length > 0;
196
+ }
197
+ async listMessages(conversationId, opts) {
198
+ const owned = await this.getConversation(conversationId);
199
+ if (!owned) return [];
200
+ const limit = Math.min(Math.max(opts?.limit ?? MAX_PAGE, 1), MAX_PAGE);
201
+ const where = opts?.before ? (0, import_drizzle_orm.and)((0, import_drizzle_orm.eq)(messages.conversationId, conversationId), (0, import_drizzle_orm.lt)(messages.createdAt, opts.before)) : (0, import_drizzle_orm.eq)(messages.conversationId, conversationId);
202
+ const rows = await this.db.select().from(messages).where(where).orderBy((0, import_drizzle_orm.desc)(messages.createdAt)).limit(limit);
203
+ return rows.reverse().map(toStoredMessage);
204
+ }
205
+ async saveTurn(input) {
206
+ const { conversationId, messages: turnMessages, model } = input;
207
+ const owned = await this.getConversation(conversationId);
208
+ if (!owned) throw new ConversationOwnershipError(conversationId);
209
+ if (turnMessages.length === 0) return;
210
+ const values = turnMessages.map((m) => ({
211
+ id: m.id && m.id.length > 0 ? m.id : (0, import_ai.generateId)(),
212
+ conversationId,
213
+ role: m.role,
214
+ parts: m.parts,
215
+ text: textFromParts(m.parts),
216
+ model: m.role === "assistant" ? model ?? null : null
217
+ }));
218
+ await this.db.insert(messages).values(values).onConflictDoNothing({ target: messages.id });
219
+ if (owned.title === "New Chat") {
220
+ const firstUserText = turnMessages.filter((m) => m.role === "user").map((m) => textFromParts(m.parts)).find((t) => t.length > 0);
221
+ if (firstUserText) {
222
+ await this.renameConversation(conversationId, firstUserText.slice(0, 100));
223
+ }
224
+ }
225
+ await this.db.update(conversations).set({ updatedAt: /* @__PURE__ */ new Date() }).where((0, import_drizzle_orm.and)((0, import_drizzle_orm.eq)(conversations.id, conversationId), (0, import_drizzle_orm.eq)(conversations.userId, this.userId)));
226
+ }
227
+ };
228
+ function createDrizzleChatStore(options) {
229
+ const db = getDrizzleDb(options);
230
+ return (userId) => new DrizzleChatStore(userId, db);
231
+ }
232
+ // Annotate the CommonJS export names for ESM import in node:
233
+ 0 && (module.exports = {
234
+ createDrizzleChatStore,
235
+ getDrizzleDb,
236
+ schema
237
+ });
238
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../src/server/stores/drizzle/index.ts","../../../src/server/stores/drizzle/store.ts","../../../src/server/chat-store.ts","../../../src/server/stores/drizzle/client.ts","../../../src/server/stores/drizzle/schema.ts"],"sourcesContent":["/**\n * Default Drizzle/Postgres ChatStore — public entry.\n *\n * Imported via `@mordn/chat-widget/server/drizzle` so a BYO consumer who\n * passes their own `store` never pulls `postgres`/`drizzle-orm` into their\n * bundle. Consumers on the default path:\n *\n * import { createDrizzleChatStore } from '@mordn/chat-widget/server/drizzle';\n * createChatHandler({ store: createDrizzleChatStore(), ... });\n *\n * The schema is exported so `drizzle-kit` can generate/push migrations.\n */\nimport 'server-only';\n\nexport { createDrizzleChatStore } from './store';\nexport { getDrizzleDb, type DrizzleClientOptions, type DrizzleDb } from './client';\nexport * as schema from './schema';\nexport type {\n ConversationRow,\n NewConversationRow,\n MessageRow,\n NewMessageRow,\n} from './schema';\n","/**\n * Default ChatStore implementation, on Postgres via Drizzle.\n *\n * This is the \"hosted/default\" persistence the widget ships with. It is just\n * one implementation of the `ChatStore` interface — the interface, not this\n * file, is the contract. Every method here upholds the interface's security\n * invariants:\n *\n * • The store is bound to one `userId` (constructor arg from the verified\n * server session). No method takes a userId.\n * • Reads are implicitly scoped to that user. `getConversation` /\n * `listMessages` return null/[] for rows the user doesn't own — never\n * another user's data, and not distinguishable from \"not found\".\n * • Mutations verify ownership and throw `ConversationOwnershipError` on a\n * foreign row.\n * • `saveTurn` is idempotent on message id and bumps `updatedAt`.\n */\n\nimport 'server-only';\nimport { and, desc, eq, lt, sql } from 'drizzle-orm';\nimport { generateId, type UIMessage } from 'ai';\n\nimport {\n ConversationOwnershipError,\n type ChatStore,\n} from '../../chat-store';\nimport type {\n ListMessagesOptions,\n SaveTurnInput,\n StoredConversation,\n StoredMessage,\n} from '../../types';\nimport { getDrizzleDb, type DrizzleClientOptions, type DrizzleDb } from './client';\nimport { conversations, messages, type MessageRow, type ConversationRow } from './schema';\n\nconst MAX_PAGE = 100;\n\n/** Project the plain-text of a UIMessage's parts for the `text` column. */\nfunction textFromParts(parts: UIMessage['parts']): string {\n if (!Array.isArray(parts)) return '';\n return parts\n .filter((p): p is { type: 'text'; text: string } =>\n (p as { type?: string }).type === 'text' &&\n typeof (p as { text?: unknown }).text === 'string',\n )\n .map((p) => p.text)\n .join('')\n .trim();\n}\n\nfunction toStoredConversation(row: ConversationRow, messageCount?: number): StoredConversation {\n return {\n id: row.id,\n title: row.title,\n metadata: row.metadata ?? null,\n createdAt: row.createdAt,\n updatedAt: row.updatedAt,\n messageCount,\n };\n}\n\nfunction toStoredMessage(row: MessageRow): StoredMessage {\n return {\n id: row.id,\n role: row.role,\n parts: row.parts,\n text: row.text,\n model: row.model ?? undefined,\n createdAt: row.createdAt,\n };\n}\n\nclass DrizzleChatStore implements ChatStore {\n constructor(\n public readonly userId: string,\n private readonly db: DrizzleDb,\n ) {}\n\n async listConversations(): Promise<StoredConversation[]> {\n const rows = await this.db\n .select({\n id: conversations.id,\n userId: conversations.userId,\n title: conversations.title,\n metadata: conversations.metadata,\n createdAt: conversations.createdAt,\n updatedAt: conversations.updatedAt,\n messageCount: sql<number>`(\n SELECT COUNT(*)::int FROM ${messages}\n WHERE ${messages.conversationId} = ${conversations.id}\n )`,\n })\n .from(conversations)\n .where(eq(conversations.userId, this.userId))\n .orderBy(desc(conversations.updatedAt));\n\n return rows.map((r) => toStoredConversation(r as ConversationRow, r.messageCount));\n }\n\n async getConversation(id: string): Promise<StoredConversation | null> {\n const rows = await this.db\n .select()\n .from(conversations)\n .where(and(eq(conversations.id, id), eq(conversations.userId, this.userId)))\n .limit(1);\n return rows.length ? toStoredConversation(rows[0]) : null;\n }\n\n async ensureConversation(id: string, init?: { title?: string }): Promise<StoredConversation> {\n // Look up WITHOUT the user filter so we can distinguish \"doesn't exist\"\n // (safe to create) from \"exists but owned by someone else\" (must reject).\n // Filtering by user here would make a forged foreign id look identical to\n // a brand-new id and we'd silently create a duplicate under this user.\n const existing = await this.db\n .select({ id: conversations.id, userId: conversations.userId })\n .from(conversations)\n .where(eq(conversations.id, id))\n .limit(1);\n\n if (existing.length) {\n if (existing[0].userId !== this.userId) throw new ConversationOwnershipError(id);\n const full = await this.getConversation(id);\n // getConversation can't return null here (we just confirmed ownership),\n // but satisfy the type and guard against a race-delete.\n if (full) return full;\n }\n\n // Insert; tolerate a concurrent create of the same id (idempotent).\n await this.db\n .insert(conversations)\n .values({ id, userId: this.userId, title: init?.title ?? 'New Chat', metadata: {} })\n .onConflictDoNothing({ target: conversations.id });\n\n const created = await this.getConversation(id);\n if (created) return created;\n // If we still can't read it back, a concurrent transaction created it under\n // a different user between our check and insert — treat as ownership error.\n throw new ConversationOwnershipError(id);\n }\n\n async renameConversation(id: string, title: string): Promise<void> {\n await this.db\n .update(conversations)\n .set({ title, updatedAt: new Date() })\n .where(and(eq(conversations.id, id), eq(conversations.userId, this.userId)));\n }\n\n async deleteConversation(id: string): Promise<boolean> {\n const deleted = await this.db\n .delete(conversations)\n .where(and(eq(conversations.id, id), eq(conversations.userId, this.userId)))\n .returning({ id: conversations.id });\n return deleted.length > 0;\n }\n\n async listMessages(conversationId: string, opts?: ListMessagesOptions): Promise<StoredMessage[]> {\n // Scope to the user FIRST: confirm ownership before reading messages, so a\n // foreign conversation id yields [] rather than someone else's messages.\n const owned = await this.getConversation(conversationId);\n if (!owned) return [];\n\n const limit = Math.min(Math.max(opts?.limit ?? MAX_PAGE, 1), MAX_PAGE);\n const where = opts?.before\n ? and(eq(messages.conversationId, conversationId), lt(messages.createdAt, opts.before))\n : eq(messages.conversationId, conversationId);\n\n // Fetch newest-first for the limit, then reverse to chronological order so\n // the UI renders oldest → newest without holding the whole history.\n const rows = await this.db\n .select()\n .from(messages)\n .where(where)\n .orderBy(desc(messages.createdAt))\n .limit(limit);\n\n return rows.reverse().map(toStoredMessage);\n }\n\n async saveTurn(input: SaveTurnInput): Promise<void> {\n const { conversationId, messages: turnMessages, model } = input;\n\n // Defence in depth: verify ownership even though the router already called\n // ensureConversation. saveTurn must never trust its caller did so.\n const owned = await this.getConversation(conversationId);\n if (!owned) throw new ConversationOwnershipError(conversationId);\n\n if (turnMessages.length === 0) return;\n\n // Idempotent insert keyed on message id. The handler configures the AI SDK\n // to assign a stable id to every message (generateMessageId), and replays/\n // retries re-deliver the same id — so onConflictDoNothing dedupes safely.\n //\n // Defence in depth: if a message arrives without an id (empty string),\n // mint one rather than inserting it. Multiple id-less messages would\n // otherwise all collide on the '' primary key and silently vanish — the\n // exact bug an empty assistant id caused before generateMessageId was set.\n const values = turnMessages.map((m) => ({\n id: m.id && m.id.length > 0 ? m.id : generateId(),\n conversationId,\n role: m.role as 'user' | 'assistant' | 'system',\n parts: m.parts,\n text: textFromParts(m.parts),\n model: m.role === 'assistant' ? model ?? null : null,\n }));\n\n await this.db.insert(messages).values(values).onConflictDoNothing({ target: messages.id });\n\n // Auto-title from the first user message while the title is still default.\n if (owned.title === 'New Chat') {\n const firstUserText = turnMessages\n .filter((m) => m.role === 'user')\n .map((m) => textFromParts(m.parts))\n .find((t) => t.length > 0);\n if (firstUserText) {\n await this.renameConversation(conversationId, firstUserText.slice(0, 100));\n }\n }\n\n // Bump updatedAt so the conversation surfaces at the top of the history list.\n await this.db\n .update(conversations)\n .set({ updatedAt: new Date() })\n .where(and(eq(conversations.id, conversationId), eq(conversations.userId, this.userId)));\n }\n}\n\n/**\n * Create a `ChatStoreFactory` backed by the default Drizzle/Postgres store.\n *\n * Pass to `createChatHandler({ store: createDrizzleChatStore() })`. The\n * factory binds each store instance to the verified `userId` the handler\n * provides per request. The underlying connection pool is shared.\n */\nexport function createDrizzleChatStore(options?: DrizzleClientOptions) {\n const db = getDrizzleDb(options);\n return (userId: string): ChatStore => new DrizzleChatStore(userId, db);\n}\n","/**\n * ChatStore — the persistence contract for chat conversations and messages.\n *\n * This is one of the two pluggable backends of the widget (the other is\n * `StorageAdapter` for attachments). The package ships a Drizzle/Postgres\n * implementation as the default; a hosted backend or a BYO store (Prisma,\n * raw SQL, DynamoDB, a test double) is simply another implementation of this\n * same interface.\n *\n * ──────────────────────────────────────────────────────────────────────────\n * The security model is in the shape of this API, not in its callers.\n * ──────────────────────────────────────────────────────────────────────────\n *\n * A `ChatStore` is *bound to one verified user* at construction time (see\n * `ChatStoreFactory`). None of its methods accept a `userId`. This is\n * deliberate and it is the core defence against the IDOR class of bug:\n *\n * - You cannot ask the store for \"conversation X belonging to user Y\",\n * because there is no parameter through which a foreign `userId` could\n * enter. The only user the store will ever read or write is the one it\n * was constructed with.\n *\n * - Every method is therefore *implicitly scoped*. `listConversations()`\n * returns only the bound user's conversations. `getConversation(id)`\n * returns `null` — not someone else's row — when `id` exists but belongs\n * to a different user. `saveTurn(...)` refuses (throws\n * `ConversationOwnershipError`) if `conversationId` exists under another\n * user.\n *\n * The route layer's job shrinks to: authenticate the request, derive the\n * real `userId` from the *server* session, construct a store bound to it,\n * and call methods. There is no per-route ownership check to forget, because\n * the store cannot be made to cross users.\n *\n * Implementations MUST uphold the contract documented on each method. The\n * Drizzle default does; if you write your own, these invariants are the\n * security boundary — treat them as load-bearing, not advisory.\n */\n\nimport type {\n ListMessagesOptions,\n SaveTurnInput,\n StoredConversation,\n StoredMessage,\n} from './types';\n\n/**\n * Thrown by mutating methods when the target conversation exists but is owned\n * by a different user than the one this store is bound to. Callers should map\n * this to an HTTP 403. (Read methods don't throw — they return `null`/`[]` —\n * so that probing for existence can't distinguish \"not found\" from\n * \"forbidden\", which would itself leak information.)\n */\nexport class ConversationOwnershipError extends Error {\n constructor(public readonly conversationId: string) {\n super(`Conversation ${conversationId} is not owned by the current user`);\n this.name = 'ConversationOwnershipError';\n }\n}\n\nexport interface ChatStore {\n /**\n * The user this store instance is bound to. Read-only; set at construction.\n * Exposed so the router can stamp it onto storage paths, logs, etc. — never\n * as something a caller can change.\n */\n readonly userId: string;\n\n // ── Conversations ──────────────────────────────────────────────────────\n\n /**\n * List the bound user's conversations, most-recently-updated first.\n * Returns `messageCount` on each row for the history list. Returns `[]`\n * (never throws) when the user has none.\n */\n listConversations(): Promise<StoredConversation[]>;\n\n /**\n * Fetch a single conversation by id, scoped to the bound user.\n *\n * Returns `null` when the conversation does not exist OR exists but belongs\n * to another user — the two cases are intentionally indistinguishable to\n * the caller (and thus to an attacker). Never returns another user's row.\n */\n getConversation(id: string): Promise<StoredConversation | null>;\n\n /**\n * Ensure a conversation row exists for `id`, owned by the bound user.\n *\n * - If no row exists for `id`: creates it, owned by the bound user, and\n * returns it.\n * - If a row exists and is owned by the bound user: returns it unchanged\n * (idempotent — safe to call at the top of every request).\n * - If a row exists but is owned by a *different* user: throws\n * `ConversationOwnershipError` and writes nothing.\n *\n * This is the single chokepoint that makes \"write into someone else's\n * conversation\" impossible: the router calls it before persisting any\n * message, so a forged conversation id is rejected before any data lands.\n */\n ensureConversation(id: string, init?: { title?: string }): Promise<StoredConversation>;\n\n /**\n * Rename a conversation owned by the bound user. No-op (does not throw) if\n * the conversation doesn't exist or isn't owned by the user — renaming is\n * not security-sensitive and silent failure is friendlier here.\n */\n renameConversation(id: string, title: string): Promise<void>;\n\n /**\n * Delete a conversation (and cascade its messages + attachment rows) owned\n * by the bound user. No-op if it doesn't exist or isn't owned by the user.\n * Returns `true` if a row was actually deleted, `false` otherwise — lets\n * the route return 404 vs 200 honestly without a separate existence check.\n *\n * Note: this deletes message *rows*. Purging the underlying attachment\n * blobs from storage is the router's job (it has the `StorageAdapter`),\n * driven off the attachments this method returns having referenced.\n */\n deleteConversation(id: string): Promise<boolean>;\n\n // ── Messages ───────────────────────────────────────────────────────────\n\n /**\n * Load messages for a conversation, scoped to the bound user, newest-first\n * internally but returned in chronological order (oldest → newest) ready to\n * render. Returns `[]` if the conversation doesn't exist or isn't owned by\n * the user — same non-distinguishing contract as `getConversation`.\n *\n * Honours `ListMessagesOptions` for pagination. Implementations MUST clamp\n * `limit` to a ceiling (default ceiling: 100) so a hostile client can't\n * request an unbounded page.\n */\n listMessages(conversationId: string, opts?: ListMessagesOptions): Promise<StoredMessage[]>;\n\n /**\n * Persist the final messages of a completed turn.\n *\n * Contract:\n * - MUST verify the conversation is owned by the bound user first; throws\n * `ConversationOwnershipError` otherwise (defence in depth — the router\n * already called `ensureConversation`, but `saveTurn` must not trust\n * that).\n * - MUST be idempotent on message id: a message whose id already exists is\n * skipped, not duplicated. (The AI SDK delivers stable ids; replays and\n * retries re-deliver them.)\n * - MUST persist each message's full `parts` array as the source of truth,\n * plus a denormalised text projection for previews.\n * - MUST bump the conversation's `updatedAt`.\n *\n * Errors other than ownership (e.g. a transient DB failure) propagate so\n * the router can log them loudly — a silently-dropped assistant turn is\n * exactly the bug we're trying to design out.\n */\n saveTurn(input: SaveTurnInput): Promise<void>;\n}\n\n/**\n * Constructs a `ChatStore` bound to a specific, already-verified user.\n *\n * The router calls this *after* it has authenticated the request and derived\n * `userId` from the server session — never from anything client-supplied.\n * Passing a client-controlled value here would reintroduce the very IDOR the\n * bound-store design exists to prevent, so implementations should treat\n * `userId` as a trusted server secret, not as request input.\n *\n * Construction is intended to be cheap (the underlying DB pool/connection is\n * shared across instances) so a fresh store per request is the norm.\n */\nexport type ChatStoreFactory = (userId: string) => ChatStore;\n","/**\n * Postgres connection for the default Drizzle store.\n *\n * Carries forward the connection hygiene the original package got right:\n * • cache the client on globalThis so Next.js dev HMR doesn't leak a fresh\n * pool on every reload (which eventually exhausts Supabase's pooler),\n * • `prepare: false` for the Supabase transaction-mode pooler,\n * • a small, bounded pool.\n *\n * The connection string is read from `DATABASE_URL` by default but can be\n * passed explicitly so a host app with multiple databases stays in control.\n */\n\nimport 'server-only';\nimport { drizzle } from 'drizzle-orm/postgres-js';\nimport postgres from 'postgres';\nimport * as schema from './schema';\n\nexport type DrizzleDb = ReturnType<typeof drizzle<typeof schema>>;\n\nexport interface DrizzleClientOptions {\n /** Postgres connection string. Defaults to `process.env.DATABASE_URL`. */\n connectionString?: string;\n /** Max pool connections. Defaults to 5 (dev-friendly; raise in prod). */\n max?: number;\n}\n\nconst globalForDb = globalThis as unknown as {\n __mordnChatWidgetDb?: Map<string, DrizzleDb>;\n};\n\n/**\n * Get (or lazily create + cache) a Drizzle db for a connection string. Cached\n * by connection string so multiple stores over the same DB share one pool.\n */\nexport function getDrizzleDb(options: DrizzleClientOptions = {}): DrizzleDb {\n const connectionString = options.connectionString ?? process.env.DATABASE_URL;\n if (!connectionString) {\n throw new Error(\n '[chat-widget] DATABASE_URL is not set and no connectionString was ' +\n 'provided to the default Drizzle store.',\n );\n }\n\n const cache = (globalForDb.__mordnChatWidgetDb ??= new Map());\n const cached = cache.get(connectionString);\n if (cached) return cached;\n\n const client = postgres(connectionString, {\n prepare: false, // required for Supabase transaction-mode pooler\n max: options.max ?? 5,\n idle_timeout: 20,\n connect_timeout: 10,\n });\n const db = drizzle(client, { schema });\n\n // Only cache in non-prod: in prod each server instance loads the module once\n // so caching buys nothing and would pin a pool across the process lifetime.\n if (process.env.NODE_ENV !== 'production') cache.set(connectionString, db);\n return db;\n}\n","/**\n * Drizzle schema for the default ChatStore (v2, parts-first).\n *\n * This is the schema the *default* store uses. A BYO store may use any schema\n * it likes — this one is not part of the public contract, the `ChatStore`\n * interface is. It's exported so consumers on the default path can run\n * `drizzle-kit` against it and so the migration can reference it.\n *\n * What changed from v0.7.1\n * ------------------------\n * The old schema stored a flattened `content: text` as the apparent source of\n * truth and tucked the real AI SDK `parts` into a `metadata` jsonb blob. That\n * inverted the actual authority — `parts` (text + reasoning + tool calls +\n * sources + files) is what the AI SDK round-trips and what rendering needs;\n * `content` was a lossy shadow.\n *\n * v2 makes that authority explicit:\n * • `parts` — jsonb NOT NULL — the canonical AI SDK message parts. Source\n * of truth for rendering and model replay.\n * • `text` — text — a denormalised projection of the text parts, for\n * cheap previews / titles / search. Never authoritative.\n *\n * A backfill migration populates these from the old columns so existing\n * installs upgrade without data loss (see migrations/).\n */\n\nimport { pgTable, text, timestamp, jsonb, index } from 'drizzle-orm/pg-core';\nimport type { UIMessage } from 'ai';\n\nexport const conversations = pgTable(\n 'chat_conversations',\n {\n id: text('id').primaryKey(),\n userId: text('user_id').notNull(),\n title: text('title').notNull().default('New Chat'),\n /** Free-form host-app metadata. Never read by the core. */\n metadata: jsonb('metadata').$type<Record<string, unknown>>(),\n createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),\n updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),\n },\n (table) => [\n // Drives the history list (WHERE user_id = ? ORDER BY updated_at DESC).\n index('chat_conversations_user_updated_idx').on(table.userId, table.updatedAt),\n ],\n);\n\nexport const messages = pgTable(\n 'chat_messages',\n {\n id: text('id').primaryKey(),\n conversationId: text('conversation_id')\n .notNull()\n .references(() => conversations.id, { onDelete: 'cascade' }),\n role: text('role').notNull().$type<'user' | 'assistant' | 'system'>(),\n /** Canonical AI SDK parts — source of truth. */\n parts: jsonb('parts').$type<UIMessage['parts']>().notNull(),\n /** Denormalised plain-text projection for previews/search. */\n text: text('text').notNull().default(''),\n /** Model that produced this message (assistant turns). */\n model: text('model'),\n createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),\n },\n (table) => [\n // Drives history load (WHERE conversation_id = ? ORDER BY created_at).\n index('chat_messages_conversation_created_idx').on(table.conversationId, table.createdAt),\n ],\n);\n\nexport type ConversationRow = typeof conversations.$inferSelect;\nexport type NewConversationRow = typeof conversations.$inferInsert;\nexport type MessageRow = typeof messages.$inferSelect;\nexport type NewMessageRow = typeof messages.$inferInsert;\n"],"mappings":"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","names":["import_server_only","import_server_only","postgres"]}
@@ -0,0 +1,207 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __export = (target, all) => {
3
+ for (var name in all)
4
+ __defProp(target, name, { get: all[name], enumerable: true });
5
+ };
6
+
7
+ // src/server/stores/drizzle/index.ts
8
+ import "server-only";
9
+
10
+ // src/server/stores/drizzle/store.ts
11
+ import "server-only";
12
+ import { and, desc, eq, lt, sql } from "drizzle-orm";
13
+ import { generateId } from "ai";
14
+
15
+ // src/server/chat-store.ts
16
+ var ConversationOwnershipError = class extends Error {
17
+ constructor(conversationId) {
18
+ super(`Conversation ${conversationId} is not owned by the current user`);
19
+ this.conversationId = conversationId;
20
+ this.name = "ConversationOwnershipError";
21
+ }
22
+ };
23
+
24
+ // src/server/stores/drizzle/client.ts
25
+ import "server-only";
26
+ import { drizzle } from "drizzle-orm/postgres-js";
27
+ import postgres from "postgres";
28
+
29
+ // src/server/stores/drizzle/schema.ts
30
+ var schema_exports = {};
31
+ __export(schema_exports, {
32
+ conversations: () => conversations,
33
+ messages: () => messages
34
+ });
35
+ import { pgTable, text, timestamp, jsonb, index } from "drizzle-orm/pg-core";
36
+ var conversations = pgTable(
37
+ "chat_conversations",
38
+ {
39
+ id: text("id").primaryKey(),
40
+ userId: text("user_id").notNull(),
41
+ title: text("title").notNull().default("New Chat"),
42
+ /** Free-form host-app metadata. Never read by the core. */
43
+ metadata: jsonb("metadata").$type(),
44
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
45
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull()
46
+ },
47
+ (table) => [
48
+ // Drives the history list (WHERE user_id = ? ORDER BY updated_at DESC).
49
+ index("chat_conversations_user_updated_idx").on(table.userId, table.updatedAt)
50
+ ]
51
+ );
52
+ var messages = pgTable(
53
+ "chat_messages",
54
+ {
55
+ id: text("id").primaryKey(),
56
+ conversationId: text("conversation_id").notNull().references(() => conversations.id, { onDelete: "cascade" }),
57
+ role: text("role").notNull().$type(),
58
+ /** Canonical AI SDK parts — source of truth. */
59
+ parts: jsonb("parts").$type().notNull(),
60
+ /** Denormalised plain-text projection for previews/search. */
61
+ text: text("text").notNull().default(""),
62
+ /** Model that produced this message (assistant turns). */
63
+ model: text("model"),
64
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull()
65
+ },
66
+ (table) => [
67
+ // Drives history load (WHERE conversation_id = ? ORDER BY created_at).
68
+ index("chat_messages_conversation_created_idx").on(table.conversationId, table.createdAt)
69
+ ]
70
+ );
71
+
72
+ // src/server/stores/drizzle/client.ts
73
+ var globalForDb = globalThis;
74
+ function getDrizzleDb(options = {}) {
75
+ const connectionString = options.connectionString ?? process.env.DATABASE_URL;
76
+ if (!connectionString) {
77
+ throw new Error(
78
+ "[chat-widget] DATABASE_URL is not set and no connectionString was provided to the default Drizzle store."
79
+ );
80
+ }
81
+ const cache = globalForDb.__mordnChatWidgetDb ?? (globalForDb.__mordnChatWidgetDb = /* @__PURE__ */ new Map());
82
+ const cached = cache.get(connectionString);
83
+ if (cached) return cached;
84
+ const client = postgres(connectionString, {
85
+ prepare: false,
86
+ // required for Supabase transaction-mode pooler
87
+ max: options.max ?? 5,
88
+ idle_timeout: 20,
89
+ connect_timeout: 10
90
+ });
91
+ const db = drizzle(client, { schema: schema_exports });
92
+ if (process.env.NODE_ENV !== "production") cache.set(connectionString, db);
93
+ return db;
94
+ }
95
+
96
+ // src/server/stores/drizzle/store.ts
97
+ var MAX_PAGE = 100;
98
+ function textFromParts(parts) {
99
+ if (!Array.isArray(parts)) return "";
100
+ return parts.filter(
101
+ (p) => p.type === "text" && typeof p.text === "string"
102
+ ).map((p) => p.text).join("").trim();
103
+ }
104
+ function toStoredConversation(row, messageCount) {
105
+ return {
106
+ id: row.id,
107
+ title: row.title,
108
+ metadata: row.metadata ?? null,
109
+ createdAt: row.createdAt,
110
+ updatedAt: row.updatedAt,
111
+ messageCount
112
+ };
113
+ }
114
+ function toStoredMessage(row) {
115
+ return {
116
+ id: row.id,
117
+ role: row.role,
118
+ parts: row.parts,
119
+ text: row.text,
120
+ model: row.model ?? void 0,
121
+ createdAt: row.createdAt
122
+ };
123
+ }
124
+ var DrizzleChatStore = class {
125
+ constructor(userId, db) {
126
+ this.userId = userId;
127
+ this.db = db;
128
+ }
129
+ async listConversations() {
130
+ const rows = await this.db.select({
131
+ id: conversations.id,
132
+ userId: conversations.userId,
133
+ title: conversations.title,
134
+ metadata: conversations.metadata,
135
+ createdAt: conversations.createdAt,
136
+ updatedAt: conversations.updatedAt,
137
+ messageCount: sql`(
138
+ SELECT COUNT(*)::int FROM ${messages}
139
+ WHERE ${messages.conversationId} = ${conversations.id}
140
+ )`
141
+ }).from(conversations).where(eq(conversations.userId, this.userId)).orderBy(desc(conversations.updatedAt));
142
+ return rows.map((r) => toStoredConversation(r, r.messageCount));
143
+ }
144
+ async getConversation(id) {
145
+ const rows = await this.db.select().from(conversations).where(and(eq(conversations.id, id), eq(conversations.userId, this.userId))).limit(1);
146
+ return rows.length ? toStoredConversation(rows[0]) : null;
147
+ }
148
+ async ensureConversation(id, init) {
149
+ const existing = await this.db.select({ id: conversations.id, userId: conversations.userId }).from(conversations).where(eq(conversations.id, id)).limit(1);
150
+ if (existing.length) {
151
+ if (existing[0].userId !== this.userId) throw new ConversationOwnershipError(id);
152
+ const full = await this.getConversation(id);
153
+ if (full) return full;
154
+ }
155
+ await this.db.insert(conversations).values({ id, userId: this.userId, title: init?.title ?? "New Chat", metadata: {} }).onConflictDoNothing({ target: conversations.id });
156
+ const created = await this.getConversation(id);
157
+ if (created) return created;
158
+ throw new ConversationOwnershipError(id);
159
+ }
160
+ async renameConversation(id, title) {
161
+ await this.db.update(conversations).set({ title, updatedAt: /* @__PURE__ */ new Date() }).where(and(eq(conversations.id, id), eq(conversations.userId, this.userId)));
162
+ }
163
+ async deleteConversation(id) {
164
+ const deleted = await this.db.delete(conversations).where(and(eq(conversations.id, id), eq(conversations.userId, this.userId))).returning({ id: conversations.id });
165
+ return deleted.length > 0;
166
+ }
167
+ async listMessages(conversationId, opts) {
168
+ const owned = await this.getConversation(conversationId);
169
+ if (!owned) return [];
170
+ const limit = Math.min(Math.max(opts?.limit ?? MAX_PAGE, 1), MAX_PAGE);
171
+ const where = opts?.before ? and(eq(messages.conversationId, conversationId), lt(messages.createdAt, opts.before)) : eq(messages.conversationId, conversationId);
172
+ const rows = await this.db.select().from(messages).where(where).orderBy(desc(messages.createdAt)).limit(limit);
173
+ return rows.reverse().map(toStoredMessage);
174
+ }
175
+ async saveTurn(input) {
176
+ const { conversationId, messages: turnMessages, model } = input;
177
+ const owned = await this.getConversation(conversationId);
178
+ if (!owned) throw new ConversationOwnershipError(conversationId);
179
+ if (turnMessages.length === 0) return;
180
+ const values = turnMessages.map((m) => ({
181
+ id: m.id && m.id.length > 0 ? m.id : generateId(),
182
+ conversationId,
183
+ role: m.role,
184
+ parts: m.parts,
185
+ text: textFromParts(m.parts),
186
+ model: m.role === "assistant" ? model ?? null : null
187
+ }));
188
+ await this.db.insert(messages).values(values).onConflictDoNothing({ target: messages.id });
189
+ if (owned.title === "New Chat") {
190
+ const firstUserText = turnMessages.filter((m) => m.role === "user").map((m) => textFromParts(m.parts)).find((t) => t.length > 0);
191
+ if (firstUserText) {
192
+ await this.renameConversation(conversationId, firstUserText.slice(0, 100));
193
+ }
194
+ }
195
+ await this.db.update(conversations).set({ updatedAt: /* @__PURE__ */ new Date() }).where(and(eq(conversations.id, conversationId), eq(conversations.userId, this.userId)));
196
+ }
197
+ };
198
+ function createDrizzleChatStore(options) {
199
+ const db = getDrizzleDb(options);
200
+ return (userId) => new DrizzleChatStore(userId, db);
201
+ }
202
+ export {
203
+ createDrizzleChatStore,
204
+ getDrizzleDb,
205
+ schema_exports as schema
206
+ };
207
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../src/server/stores/drizzle/index.ts","../../../src/server/stores/drizzle/store.ts","../../../src/server/chat-store.ts","../../../src/server/stores/drizzle/client.ts","../../../src/server/stores/drizzle/schema.ts"],"sourcesContent":["/**\n * Default Drizzle/Postgres ChatStore — public entry.\n *\n * Imported via `@mordn/chat-widget/server/drizzle` so a BYO consumer who\n * passes their own `store` never pulls `postgres`/`drizzle-orm` into their\n * bundle. Consumers on the default path:\n *\n * import { createDrizzleChatStore } from '@mordn/chat-widget/server/drizzle';\n * createChatHandler({ store: createDrizzleChatStore(), ... });\n *\n * The schema is exported so `drizzle-kit` can generate/push migrations.\n */\nimport 'server-only';\n\nexport { createDrizzleChatStore } from './store';\nexport { getDrizzleDb, type DrizzleClientOptions, type DrizzleDb } from './client';\nexport * as schema from './schema';\nexport type {\n ConversationRow,\n NewConversationRow,\n MessageRow,\n NewMessageRow,\n} from './schema';\n","/**\n * Default ChatStore implementation, on Postgres via Drizzle.\n *\n * This is the \"hosted/default\" persistence the widget ships with. It is just\n * one implementation of the `ChatStore` interface — the interface, not this\n * file, is the contract. Every method here upholds the interface's security\n * invariants:\n *\n * • The store is bound to one `userId` (constructor arg from the verified\n * server session). No method takes a userId.\n * • Reads are implicitly scoped to that user. `getConversation` /\n * `listMessages` return null/[] for rows the user doesn't own — never\n * another user's data, and not distinguishable from \"not found\".\n * • Mutations verify ownership and throw `ConversationOwnershipError` on a\n * foreign row.\n * • `saveTurn` is idempotent on message id and bumps `updatedAt`.\n */\n\nimport 'server-only';\nimport { and, desc, eq, lt, sql } from 'drizzle-orm';\nimport { generateId, type UIMessage } from 'ai';\n\nimport {\n ConversationOwnershipError,\n type ChatStore,\n} from '../../chat-store';\nimport type {\n ListMessagesOptions,\n SaveTurnInput,\n StoredConversation,\n StoredMessage,\n} from '../../types';\nimport { getDrizzleDb, type DrizzleClientOptions, type DrizzleDb } from './client';\nimport { conversations, messages, type MessageRow, type ConversationRow } from './schema';\n\nconst MAX_PAGE = 100;\n\n/** Project the plain-text of a UIMessage's parts for the `text` column. */\nfunction textFromParts(parts: UIMessage['parts']): string {\n if (!Array.isArray(parts)) return '';\n return parts\n .filter((p): p is { type: 'text'; text: string } =>\n (p as { type?: string }).type === 'text' &&\n typeof (p as { text?: unknown }).text === 'string',\n )\n .map((p) => p.text)\n .join('')\n .trim();\n}\n\nfunction toStoredConversation(row: ConversationRow, messageCount?: number): StoredConversation {\n return {\n id: row.id,\n title: row.title,\n metadata: row.metadata ?? null,\n createdAt: row.createdAt,\n updatedAt: row.updatedAt,\n messageCount,\n };\n}\n\nfunction toStoredMessage(row: MessageRow): StoredMessage {\n return {\n id: row.id,\n role: row.role,\n parts: row.parts,\n text: row.text,\n model: row.model ?? undefined,\n createdAt: row.createdAt,\n };\n}\n\nclass DrizzleChatStore implements ChatStore {\n constructor(\n public readonly userId: string,\n private readonly db: DrizzleDb,\n ) {}\n\n async listConversations(): Promise<StoredConversation[]> {\n const rows = await this.db\n .select({\n id: conversations.id,\n userId: conversations.userId,\n title: conversations.title,\n metadata: conversations.metadata,\n createdAt: conversations.createdAt,\n updatedAt: conversations.updatedAt,\n messageCount: sql<number>`(\n SELECT COUNT(*)::int FROM ${messages}\n WHERE ${messages.conversationId} = ${conversations.id}\n )`,\n })\n .from(conversations)\n .where(eq(conversations.userId, this.userId))\n .orderBy(desc(conversations.updatedAt));\n\n return rows.map((r) => toStoredConversation(r as ConversationRow, r.messageCount));\n }\n\n async getConversation(id: string): Promise<StoredConversation | null> {\n const rows = await this.db\n .select()\n .from(conversations)\n .where(and(eq(conversations.id, id), eq(conversations.userId, this.userId)))\n .limit(1);\n return rows.length ? toStoredConversation(rows[0]) : null;\n }\n\n async ensureConversation(id: string, init?: { title?: string }): Promise<StoredConversation> {\n // Look up WITHOUT the user filter so we can distinguish \"doesn't exist\"\n // (safe to create) from \"exists but owned by someone else\" (must reject).\n // Filtering by user here would make a forged foreign id look identical to\n // a brand-new id and we'd silently create a duplicate under this user.\n const existing = await this.db\n .select({ id: conversations.id, userId: conversations.userId })\n .from(conversations)\n .where(eq(conversations.id, id))\n .limit(1);\n\n if (existing.length) {\n if (existing[0].userId !== this.userId) throw new ConversationOwnershipError(id);\n const full = await this.getConversation(id);\n // getConversation can't return null here (we just confirmed ownership),\n // but satisfy the type and guard against a race-delete.\n if (full) return full;\n }\n\n // Insert; tolerate a concurrent create of the same id (idempotent).\n await this.db\n .insert(conversations)\n .values({ id, userId: this.userId, title: init?.title ?? 'New Chat', metadata: {} })\n .onConflictDoNothing({ target: conversations.id });\n\n const created = await this.getConversation(id);\n if (created) return created;\n // If we still can't read it back, a concurrent transaction created it under\n // a different user between our check and insert — treat as ownership error.\n throw new ConversationOwnershipError(id);\n }\n\n async renameConversation(id: string, title: string): Promise<void> {\n await this.db\n .update(conversations)\n .set({ title, updatedAt: new Date() })\n .where(and(eq(conversations.id, id), eq(conversations.userId, this.userId)));\n }\n\n async deleteConversation(id: string): Promise<boolean> {\n const deleted = await this.db\n .delete(conversations)\n .where(and(eq(conversations.id, id), eq(conversations.userId, this.userId)))\n .returning({ id: conversations.id });\n return deleted.length > 0;\n }\n\n async listMessages(conversationId: string, opts?: ListMessagesOptions): Promise<StoredMessage[]> {\n // Scope to the user FIRST: confirm ownership before reading messages, so a\n // foreign conversation id yields [] rather than someone else's messages.\n const owned = await this.getConversation(conversationId);\n if (!owned) return [];\n\n const limit = Math.min(Math.max(opts?.limit ?? MAX_PAGE, 1), MAX_PAGE);\n const where = opts?.before\n ? and(eq(messages.conversationId, conversationId), lt(messages.createdAt, opts.before))\n : eq(messages.conversationId, conversationId);\n\n // Fetch newest-first for the limit, then reverse to chronological order so\n // the UI renders oldest → newest without holding the whole history.\n const rows = await this.db\n .select()\n .from(messages)\n .where(where)\n .orderBy(desc(messages.createdAt))\n .limit(limit);\n\n return rows.reverse().map(toStoredMessage);\n }\n\n async saveTurn(input: SaveTurnInput): Promise<void> {\n const { conversationId, messages: turnMessages, model } = input;\n\n // Defence in depth: verify ownership even though the router already called\n // ensureConversation. saveTurn must never trust its caller did so.\n const owned = await this.getConversation(conversationId);\n if (!owned) throw new ConversationOwnershipError(conversationId);\n\n if (turnMessages.length === 0) return;\n\n // Idempotent insert keyed on message id. The handler configures the AI SDK\n // to assign a stable id to every message (generateMessageId), and replays/\n // retries re-deliver the same id — so onConflictDoNothing dedupes safely.\n //\n // Defence in depth: if a message arrives without an id (empty string),\n // mint one rather than inserting it. Multiple id-less messages would\n // otherwise all collide on the '' primary key and silently vanish — the\n // exact bug an empty assistant id caused before generateMessageId was set.\n const values = turnMessages.map((m) => ({\n id: m.id && m.id.length > 0 ? m.id : generateId(),\n conversationId,\n role: m.role as 'user' | 'assistant' | 'system',\n parts: m.parts,\n text: textFromParts(m.parts),\n model: m.role === 'assistant' ? model ?? null : null,\n }));\n\n await this.db.insert(messages).values(values).onConflictDoNothing({ target: messages.id });\n\n // Auto-title from the first user message while the title is still default.\n if (owned.title === 'New Chat') {\n const firstUserText = turnMessages\n .filter((m) => m.role === 'user')\n .map((m) => textFromParts(m.parts))\n .find((t) => t.length > 0);\n if (firstUserText) {\n await this.renameConversation(conversationId, firstUserText.slice(0, 100));\n }\n }\n\n // Bump updatedAt so the conversation surfaces at the top of the history list.\n await this.db\n .update(conversations)\n .set({ updatedAt: new Date() })\n .where(and(eq(conversations.id, conversationId), eq(conversations.userId, this.userId)));\n }\n}\n\n/**\n * Create a `ChatStoreFactory` backed by the default Drizzle/Postgres store.\n *\n * Pass to `createChatHandler({ store: createDrizzleChatStore() })`. The\n * factory binds each store instance to the verified `userId` the handler\n * provides per request. The underlying connection pool is shared.\n */\nexport function createDrizzleChatStore(options?: DrizzleClientOptions) {\n const db = getDrizzleDb(options);\n return (userId: string): ChatStore => new DrizzleChatStore(userId, db);\n}\n","/**\n * ChatStore — the persistence contract for chat conversations and messages.\n *\n * This is one of the two pluggable backends of the widget (the other is\n * `StorageAdapter` for attachments). The package ships a Drizzle/Postgres\n * implementation as the default; a hosted backend or a BYO store (Prisma,\n * raw SQL, DynamoDB, a test double) is simply another implementation of this\n * same interface.\n *\n * ──────────────────────────────────────────────────────────────────────────\n * The security model is in the shape of this API, not in its callers.\n * ──────────────────────────────────────────────────────────────────────────\n *\n * A `ChatStore` is *bound to one verified user* at construction time (see\n * `ChatStoreFactory`). None of its methods accept a `userId`. This is\n * deliberate and it is the core defence against the IDOR class of bug:\n *\n * - You cannot ask the store for \"conversation X belonging to user Y\",\n * because there is no parameter through which a foreign `userId` could\n * enter. The only user the store will ever read or write is the one it\n * was constructed with.\n *\n * - Every method is therefore *implicitly scoped*. `listConversations()`\n * returns only the bound user's conversations. `getConversation(id)`\n * returns `null` — not someone else's row — when `id` exists but belongs\n * to a different user. `saveTurn(...)` refuses (throws\n * `ConversationOwnershipError`) if `conversationId` exists under another\n * user.\n *\n * The route layer's job shrinks to: authenticate the request, derive the\n * real `userId` from the *server* session, construct a store bound to it,\n * and call methods. There is no per-route ownership check to forget, because\n * the store cannot be made to cross users.\n *\n * Implementations MUST uphold the contract documented on each method. The\n * Drizzle default does; if you write your own, these invariants are the\n * security boundary — treat them as load-bearing, not advisory.\n */\n\nimport type {\n ListMessagesOptions,\n SaveTurnInput,\n StoredConversation,\n StoredMessage,\n} from './types';\n\n/**\n * Thrown by mutating methods when the target conversation exists but is owned\n * by a different user than the one this store is bound to. Callers should map\n * this to an HTTP 403. (Read methods don't throw — they return `null`/`[]` —\n * so that probing for existence can't distinguish \"not found\" from\n * \"forbidden\", which would itself leak information.)\n */\nexport class ConversationOwnershipError extends Error {\n constructor(public readonly conversationId: string) {\n super(`Conversation ${conversationId} is not owned by the current user`);\n this.name = 'ConversationOwnershipError';\n }\n}\n\nexport interface ChatStore {\n /**\n * The user this store instance is bound to. Read-only; set at construction.\n * Exposed so the router can stamp it onto storage paths, logs, etc. — never\n * as something a caller can change.\n */\n readonly userId: string;\n\n // ── Conversations ──────────────────────────────────────────────────────\n\n /**\n * List the bound user's conversations, most-recently-updated first.\n * Returns `messageCount` on each row for the history list. Returns `[]`\n * (never throws) when the user has none.\n */\n listConversations(): Promise<StoredConversation[]>;\n\n /**\n * Fetch a single conversation by id, scoped to the bound user.\n *\n * Returns `null` when the conversation does not exist OR exists but belongs\n * to another user — the two cases are intentionally indistinguishable to\n * the caller (and thus to an attacker). Never returns another user's row.\n */\n getConversation(id: string): Promise<StoredConversation | null>;\n\n /**\n * Ensure a conversation row exists for `id`, owned by the bound user.\n *\n * - If no row exists for `id`: creates it, owned by the bound user, and\n * returns it.\n * - If a row exists and is owned by the bound user: returns it unchanged\n * (idempotent — safe to call at the top of every request).\n * - If a row exists but is owned by a *different* user: throws\n * `ConversationOwnershipError` and writes nothing.\n *\n * This is the single chokepoint that makes \"write into someone else's\n * conversation\" impossible: the router calls it before persisting any\n * message, so a forged conversation id is rejected before any data lands.\n */\n ensureConversation(id: string, init?: { title?: string }): Promise<StoredConversation>;\n\n /**\n * Rename a conversation owned by the bound user. No-op (does not throw) if\n * the conversation doesn't exist or isn't owned by the user — renaming is\n * not security-sensitive and silent failure is friendlier here.\n */\n renameConversation(id: string, title: string): Promise<void>;\n\n /**\n * Delete a conversation (and cascade its messages + attachment rows) owned\n * by the bound user. No-op if it doesn't exist or isn't owned by the user.\n * Returns `true` if a row was actually deleted, `false` otherwise — lets\n * the route return 404 vs 200 honestly without a separate existence check.\n *\n * Note: this deletes message *rows*. Purging the underlying attachment\n * blobs from storage is the router's job (it has the `StorageAdapter`),\n * driven off the attachments this method returns having referenced.\n */\n deleteConversation(id: string): Promise<boolean>;\n\n // ── Messages ───────────────────────────────────────────────────────────\n\n /**\n * Load messages for a conversation, scoped to the bound user, newest-first\n * internally but returned in chronological order (oldest → newest) ready to\n * render. Returns `[]` if the conversation doesn't exist or isn't owned by\n * the user — same non-distinguishing contract as `getConversation`.\n *\n * Honours `ListMessagesOptions` for pagination. Implementations MUST clamp\n * `limit` to a ceiling (default ceiling: 100) so a hostile client can't\n * request an unbounded page.\n */\n listMessages(conversationId: string, opts?: ListMessagesOptions): Promise<StoredMessage[]>;\n\n /**\n * Persist the final messages of a completed turn.\n *\n * Contract:\n * - MUST verify the conversation is owned by the bound user first; throws\n * `ConversationOwnershipError` otherwise (defence in depth — the router\n * already called `ensureConversation`, but `saveTurn` must not trust\n * that).\n * - MUST be idempotent on message id: a message whose id already exists is\n * skipped, not duplicated. (The AI SDK delivers stable ids; replays and\n * retries re-deliver them.)\n * - MUST persist each message's full `parts` array as the source of truth,\n * plus a denormalised text projection for previews.\n * - MUST bump the conversation's `updatedAt`.\n *\n * Errors other than ownership (e.g. a transient DB failure) propagate so\n * the router can log them loudly — a silently-dropped assistant turn is\n * exactly the bug we're trying to design out.\n */\n saveTurn(input: SaveTurnInput): Promise<void>;\n}\n\n/**\n * Constructs a `ChatStore` bound to a specific, already-verified user.\n *\n * The router calls this *after* it has authenticated the request and derived\n * `userId` from the server session — never from anything client-supplied.\n * Passing a client-controlled value here would reintroduce the very IDOR the\n * bound-store design exists to prevent, so implementations should treat\n * `userId` as a trusted server secret, not as request input.\n *\n * Construction is intended to be cheap (the underlying DB pool/connection is\n * shared across instances) so a fresh store per request is the norm.\n */\nexport type ChatStoreFactory = (userId: string) => ChatStore;\n","/**\n * Postgres connection for the default Drizzle store.\n *\n * Carries forward the connection hygiene the original package got right:\n * • cache the client on globalThis so Next.js dev HMR doesn't leak a fresh\n * pool on every reload (which eventually exhausts Supabase's pooler),\n * • `prepare: false` for the Supabase transaction-mode pooler,\n * • a small, bounded pool.\n *\n * The connection string is read from `DATABASE_URL` by default but can be\n * passed explicitly so a host app with multiple databases stays in control.\n */\n\nimport 'server-only';\nimport { drizzle } from 'drizzle-orm/postgres-js';\nimport postgres from 'postgres';\nimport * as schema from './schema';\n\nexport type DrizzleDb = ReturnType<typeof drizzle<typeof schema>>;\n\nexport interface DrizzleClientOptions {\n /** Postgres connection string. Defaults to `process.env.DATABASE_URL`. */\n connectionString?: string;\n /** Max pool connections. Defaults to 5 (dev-friendly; raise in prod). */\n max?: number;\n}\n\nconst globalForDb = globalThis as unknown as {\n __mordnChatWidgetDb?: Map<string, DrizzleDb>;\n};\n\n/**\n * Get (or lazily create + cache) a Drizzle db for a connection string. Cached\n * by connection string so multiple stores over the same DB share one pool.\n */\nexport function getDrizzleDb(options: DrizzleClientOptions = {}): DrizzleDb {\n const connectionString = options.connectionString ?? process.env.DATABASE_URL;\n if (!connectionString) {\n throw new Error(\n '[chat-widget] DATABASE_URL is not set and no connectionString was ' +\n 'provided to the default Drizzle store.',\n );\n }\n\n const cache = (globalForDb.__mordnChatWidgetDb ??= new Map());\n const cached = cache.get(connectionString);\n if (cached) return cached;\n\n const client = postgres(connectionString, {\n prepare: false, // required for Supabase transaction-mode pooler\n max: options.max ?? 5,\n idle_timeout: 20,\n connect_timeout: 10,\n });\n const db = drizzle(client, { schema });\n\n // Only cache in non-prod: in prod each server instance loads the module once\n // so caching buys nothing and would pin a pool across the process lifetime.\n if (process.env.NODE_ENV !== 'production') cache.set(connectionString, db);\n return db;\n}\n","/**\n * Drizzle schema for the default ChatStore (v2, parts-first).\n *\n * This is the schema the *default* store uses. A BYO store may use any schema\n * it likes — this one is not part of the public contract, the `ChatStore`\n * interface is. It's exported so consumers on the default path can run\n * `drizzle-kit` against it and so the migration can reference it.\n *\n * What changed from v0.7.1\n * ------------------------\n * The old schema stored a flattened `content: text` as the apparent source of\n * truth and tucked the real AI SDK `parts` into a `metadata` jsonb blob. That\n * inverted the actual authority — `parts` (text + reasoning + tool calls +\n * sources + files) is what the AI SDK round-trips and what rendering needs;\n * `content` was a lossy shadow.\n *\n * v2 makes that authority explicit:\n * • `parts` — jsonb NOT NULL — the canonical AI SDK message parts. Source\n * of truth for rendering and model replay.\n * • `text` — text — a denormalised projection of the text parts, for\n * cheap previews / titles / search. Never authoritative.\n *\n * A backfill migration populates these from the old columns so existing\n * installs upgrade without data loss (see migrations/).\n */\n\nimport { pgTable, text, timestamp, jsonb, index } from 'drizzle-orm/pg-core';\nimport type { UIMessage } from 'ai';\n\nexport const conversations = pgTable(\n 'chat_conversations',\n {\n id: text('id').primaryKey(),\n userId: text('user_id').notNull(),\n title: text('title').notNull().default('New Chat'),\n /** Free-form host-app metadata. Never read by the core. */\n metadata: jsonb('metadata').$type<Record<string, unknown>>(),\n createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),\n updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),\n },\n (table) => [\n // Drives the history list (WHERE user_id = ? ORDER BY updated_at DESC).\n index('chat_conversations_user_updated_idx').on(table.userId, table.updatedAt),\n ],\n);\n\nexport const messages = pgTable(\n 'chat_messages',\n {\n id: text('id').primaryKey(),\n conversationId: text('conversation_id')\n .notNull()\n .references(() => conversations.id, { onDelete: 'cascade' }),\n role: text('role').notNull().$type<'user' | 'assistant' | 'system'>(),\n /** Canonical AI SDK parts — source of truth. */\n parts: jsonb('parts').$type<UIMessage['parts']>().notNull(),\n /** Denormalised plain-text projection for previews/search. */\n text: text('text').notNull().default(''),\n /** Model that produced this message (assistant turns). */\n model: text('model'),\n createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),\n },\n (table) => [\n // Drives history load (WHERE conversation_id = ? ORDER BY created_at).\n index('chat_messages_conversation_created_idx').on(table.conversationId, table.createdAt),\n ],\n);\n\nexport type ConversationRow = typeof conversations.$inferSelect;\nexport type NewConversationRow = typeof conversations.$inferInsert;\nexport type MessageRow = typeof messages.$inferSelect;\nexport type NewMessageRow = typeof messages.$inferInsert;\n"],"mappings":"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","names":[]}