@jini-ai/chat 0.3.10 → 0.5.0
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.
- package/CHANGELOG.md +126 -0
- package/README.md +152 -6
- package/dist/core/ag-ui/entry.d.ts +53 -0
- package/dist/core/ag-ui/entry.js +135 -0
- package/dist/core/agentic/chat-capabilities.d.ts +1 -1
- package/dist/core/agentic/index.d.ts +1 -1
- package/dist/core/agentic/index.js +1 -1
- package/dist/core/assistant-content.d.ts +6 -2
- package/dist/core/assistant-content.js +2 -2
- package/dist/core/compact-events.d.ts +3 -1
- package/dist/core/compact-events.js +1 -1
- package/dist/core/events.d.ts +1 -1
- package/dist/core/events.js +1 -1
- package/dist/core/index.d.ts +3 -3
- package/dist/core/index.js +3 -3
- package/dist/core/messages.d.ts +4 -2
- package/dist/core/messages.js +2 -2
- package/dist/core/partial-json.d.ts +6 -2
- package/dist/core/partial-json.js +3 -3
- package/dist/core/persistence/index.d.ts +1 -1
- package/dist/core/persistence/index.js +1 -1
- package/dist/core/persistence/ports.d.ts +2 -123
- package/dist/core/persistence/title.d.ts +3 -1
- package/dist/core/persistence/title.js +1 -1
- package/dist/core/question-form/format.d.ts +16 -7
- package/dist/core/question-form/format.js +3 -3
- package/dist/core/question-form/normalize.d.ts +7 -2
- package/dist/core/question-form/normalize.js +5 -5
- package/dist/core/question-form/scan.d.ts +18 -6
- package/dist/core/question-form/scan.js +14 -14
- package/dist/core/run-activity.d.ts +16 -4
- package/dist/core/run-activity.js +11 -11
- package/dist/core/run-events/entry.d.ts +121 -0
- package/dist/core/run-events/entry.js +290 -0
- package/dist/core/todos.d.ts +20 -8
- package/dist/core/todos.js +11 -11
- package/dist/core/tool-events.d.ts +3 -1
- package/dist/core/tool-events.js +1 -1
- package/dist/core/tool-output.d.ts +3 -1
- package/dist/core/tool-output.js +1 -1
- package/dist/core/tools.d.ts +14 -3
- package/dist/core/tools.js +3 -3
- package/dist/core/transcript.d.ts +13 -5
- package/dist/core/transcript.js +5 -5
- package/dist/core/util/manifest.d.ts +9 -3
- package/dist/core/util/manifest.js +3 -3
- package/dist/core/util/markdown-context.d.ts +11 -3
- package/dist/core/util/markdown-context.js +3 -3
- package/dist/core/util/markup-attributes.d.ts +3 -1
- package/dist/core/util/markup-attributes.js +1 -1
- package/dist/core/util/parser.d.ts +9 -5
- package/dist/core/util/parser.js +13 -13
- package/dist/core/util/recover.d.ts +6 -2
- package/dist/core/util/recover.js +6 -6
- package/dist/core/util/strip.d.ts +19 -5
- package/dist/core/util/strip.js +17 -17
- package/dist/core/util/validate.d.ts +3 -1
- package/dist/core/util/validate.js +1 -1
- package/dist/embed-widget/browser/page-actions.d.ts +121 -0
- package/dist/embed-widget/browser/page-actions.js +113 -0
- package/dist/embed-widget/browser/session-store.d.ts +44 -0
- package/dist/embed-widget/browser/session-store.js +103 -0
- package/dist/embed-widget/react/embed.d.ts +45 -0
- package/dist/embed-widget/react/embed.js +87 -0
- package/dist/embed-widget/react/header.d.ts +21 -0
- package/dist/embed-widget/react/header.js +18 -0
- package/dist/embed-widget/transports/fetch-sse.d.ts +75 -0
- package/dist/embed-widget/transports/fetch-sse.js +175 -0
- package/dist/react/agent-bridge/frontend-session-bridge.d.ts +15 -3
- package/dist/react/agent-bridge/frontend-session-bridge.js +13 -6
- package/dist/react/artifact-types.d.ts +3 -3
- package/dist/react/artifact-types.js +3 -3
- package/dist/react/components/A2uiSurfaceCard.js +34 -13
- package/dist/react/components/AttachmentPreviewModal.hooks.d.ts +7 -2
- package/dist/react/components/AttachmentPreviewModal.hooks.js +5 -5
- package/dist/react/components/AttachmentPreviewModal.js +2 -2
- package/dist/react/components/AttachmentTray.d.ts +3 -1
- package/dist/react/components/AttachmentTray.js +2 -2
- package/dist/react/components/ChatFab.js +1 -1
- package/dist/react/components/Composer.js +9 -9
- package/dist/react/components/ConversationList.js +1 -1
- package/dist/react/components/McpUiSurfaceCard.d.ts +4 -2
- package/dist/react/components/McpUiSurfaceCard.js +5 -5
- package/dist/react/components/MessageList.js +1 -1
- package/dist/react/components/MessageRow.js +9 -9
- package/dist/react/components/QuestionForm.js +5 -5
- package/dist/react/components/ToolCard.js +9 -9
- package/dist/react/components/composer-discovery.d.ts +29 -8
- package/dist/react/components/composer-discovery.js +10 -10
- package/dist/react/ext-event-renderer-registry.d.ts +15 -5
- package/dist/react/ext-event-renderer-registry.js +4 -4
- package/dist/react/features/chat-pane/components/AgentRuntimePicker.d.ts +3 -1
- package/dist/react/features/chat-pane/components/AgentRuntimePicker.js +3 -3
- package/dist/react/features/chat-pane/components/ChatPane.js +43 -43
- package/dist/react/features/chat-pane/create-daemon-attachment-uploader.d.ts +4 -1
- package/dist/react/features/chat-pane/create-daemon-attachment-uploader.js +5 -35
- package/dist/react/features/chat-pane/create-mcp-ui-tool-caller.d.ts +5 -2
- package/dist/react/features/chat-pane/create-mcp-ui-tool-caller.js +1 -1
- package/dist/react/features/chat-pane/hooks/useAgentRuntimePicker.hooks.d.ts +38 -10
- package/dist/react/features/chat-pane/hooks/useAgentRuntimePicker.hooks.js +15 -15
- package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +31 -31
- package/dist/react/features/chat-pane/hooks/useChatPaneAgentControl.hooks.d.ts +3 -1
- package/dist/react/features/chat-pane/hooks/useChatPaneAgentControl.hooks.js +3 -3
- package/dist/react/features/chat-pane/hooks/useChatPaneComposerPlaceholder.hooks.d.ts +4 -1
- package/dist/react/features/chat-pane/hooks/useChatPaneComposerPlaceholder.hooks.js +1 -1
- package/dist/react/features/chat-pane/hooks/useChatPaneRuntimeInventory.hooks.d.ts +3 -0
- package/dist/react/features/chat-pane/hooks/useChatPaneRuntimeInventory.hooks.js +80 -38
- package/dist/react/features/chat-pane/hooks/useChatPaneWorkingDirectory.hooks.js +57 -57
- package/dist/react/features/chat-pane/rules.d.ts +13 -4
- package/dist/react/features/chat-pane/rules.js +5 -5
- package/dist/react/features/chat-pane/types.d.ts +1 -1
- package/dist/react/hooks/attachment-preview-cache.d.ts +7 -2
- package/dist/react/hooks/attachment-preview-cache.js +2 -2
- package/dist/react/hooks/composer-draft-cache.d.ts +17 -5
- package/dist/react/hooks/composer-draft-cache.js +6 -6
- package/dist/react/hooks/useArtifactStream.d.ts +5 -1
- package/dist/react/hooks/useArtifactStream.js +3 -3
- package/dist/react/hooks/useChatFabDrag.d.ts +9 -4
- package/dist/react/hooks/useChatFabDrag.js +4 -4
- package/dist/react/hooks/useComposer.js +8 -8
- package/dist/react/hooks/useConversation.js +56 -4
- package/dist/react/hooks/useExtEventGroups.d.ts +5 -1
- package/dist/react/hooks/useExtEventGroups.js +1 -1
- package/dist/react/hooks/useLatestOperation.d.ts +8 -3
- package/dist/react/hooks/useLatestOperation.js +4 -4
- package/dist/react/hooks/usePinnedTodos.d.ts +3 -1
- package/dist/react/hooks/usePinnedTodos.js +3 -3
- package/dist/react/hooks/useQuestionForms.d.ts +7 -2
- package/dist/react/hooks/useQuestionForms.js +7 -7
- package/dist/react/hooks/useRunActivity.d.ts +6 -1
- package/dist/react/hooks/useRunActivity.js +3 -3
- package/dist/react/hooks/useRunStream.d.ts +3 -1
- package/dist/react/hooks/useRunStream.js +1 -1
- package/dist/react/hooks/useToolTimeline.d.ts +3 -1
- package/dist/react/hooks/useToolTimeline.js +3 -3
- package/dist/react/index.d.ts +4 -4
- package/dist/react/index.js +6 -6
- package/dist/react/message-blocks.d.ts +10 -4
- package/dist/react/message-blocks.js +2 -2
- package/dist/react/styles/reference.css +3 -3
- package/dist/react/tool-renderer-registry.d.ts +8 -3
- package/dist/react/tool-renderer-registry.js +3 -3
- package/dist/react/util/defined-props.d.ts +3 -1
- package/dist/react/util/defined-props.js +1 -1
- package/dist/server/run-finalizer/entry.d.ts +47 -0
- package/dist/server/run-finalizer/entry.js +156 -0
- package/dist/server/run-finalizer/ports.d.ts +52 -0
- package/dist/store/contracts/index.d.ts +5 -0
- package/dist/store/contracts/index.js +5 -0
- package/dist/store/errors.d.ts +12 -0
- package/dist/store/errors.js +18 -0
- package/dist/store/index.d.ts +4 -0
- package/dist/store/index.js +2 -0
- package/dist/store/legacy/index.d.ts +3 -0
- package/dist/store/legacy/index.js +2 -0
- package/dist/store/legacy/sqlite/conversations.d.ts +129 -0
- package/dist/store/legacy/sqlite/conversations.js +274 -0
- package/dist/store/legacy/sqlite/index.d.ts +6 -0
- package/dist/store/legacy/sqlite/index.js +5 -0
- package/dist/store/legacy/sqlite/messages.d.ts +156 -0
- package/dist/store/legacy/sqlite/messages.js +246 -0
- package/dist/store/legacy/sqlite/schema.d.ts +3 -0
- package/dist/store/legacy/sqlite/schema.js +40 -0
- package/dist/store/pglite/index.d.ts +21 -0
- package/dist/store/pglite/index.js +17 -0
- package/dist/store/ports.d.ts +121 -0
- package/dist/store/ports.js +2 -0
- package/dist/store/postgres/index.d.ts +21 -0
- package/dist/store/postgres/index.js +17 -0
- package/dist/store/sql/boundary.d.ts +28 -0
- package/dist/store/sql/boundary.js +32 -0
- package/dist/store/sql/cursor.d.ts +24 -0
- package/dist/store/sql/cursor.js +49 -0
- package/dist/store/sql/maintenance.d.ts +9 -0
- package/dist/store/sql/maintenance.js +16 -0
- package/dist/store/sql/store.d.ts +22 -0
- package/dist/store/sql/store.js +340 -0
- package/dist/store/sql/tables.d.ts +34 -0
- package/dist/store/sql/tables.js +2 -0
- package/dist/store/sqlite/index.d.ts +36 -0
- package/dist/store/sqlite/index.js +29 -0
- package/dist/store/sqlite/schema.d.ts +49 -0
- package/dist/store/sqlite/schema.js +80 -0
- package/package.json +211 -14
- package/dist/react/features/model-picker/components/CredentialStatusBadge.d.ts +0 -7
- package/dist/react/features/model-picker/components/CredentialStatusBadge.js +0 -20
- package/dist/react/features/model-picker/components/ModelPicker.d.ts +0 -9
- package/dist/react/features/model-picker/components/ModelPicker.js +0 -34
- package/dist/react/features/model-picker/constants.d.ts +0 -6
- package/dist/react/features/model-picker/constants.js +0 -9
- package/dist/react/features/model-picker/dependencies.d.ts +0 -11
- package/dist/react/features/model-picker/dependencies.js +0 -10
- package/dist/react/features/model-picker/hooks/useModelPicker.hooks.d.ts +0 -27
- package/dist/react/features/model-picker/hooks/useModelPicker.hooks.js +0 -62
- package/dist/react/features/model-picker/index.d.ts +0 -32
- package/dist/react/features/model-picker/index.js +0 -29
- package/dist/react/features/model-picker/ports.d.ts +0 -11
- package/dist/react/features/model-picker/rules.d.ts +0 -26
- package/dist/react/features/model-picker/rules.js +0 -84
- package/dist/react/features/model-picker/types.d.ts +0 -38
- package/dist/react/features/model-picker/types.js +0 -2
- package/dist/react/poc-mcp-ui-official/PocMcpUiCard.d.ts +0 -9
- package/dist/react/poc-mcp-ui-official/PocMcpUiCard.js +0 -29
- package/dist/react/poc-mcp-ui-official/create-poc-resource.d.ts +0 -30
- package/dist/react/poc-mcp-ui-official/create-poc-resource.js +0 -44
- /package/dist/{react/features/model-picker → server/run-finalizer}/ports.js +0 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { ChatStoreError } from '../errors.js';
|
|
2
|
+
import { atStoreBoundary, runChatQuery } from './boundary.js';
|
|
3
|
+
/** Delete at most 500 expired parents; the host schema/foreign-key policy supplies cascades. */
|
|
4
|
+
export function createSqlChatMaintenance({ kernel }) {
|
|
5
|
+
return {
|
|
6
|
+
sweepExpired: ({ now }, { limit = 500 } = {}) => atStoreBoundary({ operation: async () => {
|
|
7
|
+
// Drain a backlog in chunks: an unbounded delete can hold the write lock until concurrent
|
|
8
|
+
// inserts exhaust their busy timeout. NULL expiry remains structurally outside the sweep.
|
|
9
|
+
if (!Number.isFinite(now) || !Number.isInteger(limit) || limit < 1 || limit > 500)
|
|
10
|
+
throw new ChatStoreError({ code: 'invalid-input' });
|
|
11
|
+
const result = await runChatQuery({ kernel: kernel, body: db => db.deleteFrom('ai_chats').where('id', 'in', db.selectFrom('ai_chats').select('id').where('expires_at', 'is not', null).where('expires_at', '<=', now).limit(limit)).executeTakeFirst() });
|
|
12
|
+
return Number(result.numDeletedRows);
|
|
13
|
+
} }),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
//# sourceMappingURL=maintenance.js.map
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type Clock } from '@jini-ai/core/primitives';
|
|
2
|
+
import { type StorageKernel } from '@jini-ai/db/kernel';
|
|
3
|
+
import type { ChatStore, ChatOwnerScope } from '../ports.js';
|
|
4
|
+
import type { ChatDatabase } from './tables.js';
|
|
5
|
+
/**
|
|
6
|
+
* Returns a {@link ChatHistoryStore} that can only ever see `scope`'s own conversations.
|
|
7
|
+
*
|
|
8
|
+
* @param kernel the borrowed kernel. Nothing here opens, closes or configures a database; SQLite's
|
|
9
|
+
* `ON DELETE CASCADE` needs `foreign_keys = ON`, which the host must enable.
|
|
10
|
+
* @param scope the isolation predicate, resolved from the host's authentication. For a guest,
|
|
11
|
+
* `ownerId` must already be a hash of the session key (`tenant-scope.ts`).
|
|
12
|
+
* @param optional Optional core Clock for deterministic host/tests; defaults to system wall time.
|
|
13
|
+
* @complexity each method is one to four indexed statements; `list` is O(owned conversations),
|
|
14
|
+
* `messages` O(messages in the conversation).
|
|
15
|
+
*/
|
|
16
|
+
export declare function createSqlChatStore<DB extends ChatDatabase>({ kernel, scope: inputScope }: {
|
|
17
|
+
kernel: StorageKernel<DB>;
|
|
18
|
+
scope: ChatOwnerScope;
|
|
19
|
+
}, { clock }?: {
|
|
20
|
+
clock?: Clock;
|
|
21
|
+
}): ChatStore;
|
|
22
|
+
//# sourceMappingURL=store.d.ts.map
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/** One SQL query body upstreamed from the existing host adapter; no resource/schema ownership. */
|
|
2
|
+
// Legacy project/conversation CRUD trusts access to a local database file; this owner-bound store
|
|
3
|
+
// serves shared databases instead. Choose by trust model: borrowing a handle must not grant access
|
|
4
|
+
// to every tenant, and this adapter neither chooses that handle's file nor owns its lifetime.
|
|
5
|
+
/**
|
|
6
|
+
* Every read/write uses the authenticated (scope_id, owner_kind, owner_id) tuple; store construction
|
|
7
|
+
* must not turn a shared database into access to other owners' conversations. Append reads and
|
|
8
|
+
* assigns position in one transaction under the conversation lock: PostgreSQL READ COMMITTED alone
|
|
9
|
+
* cannot serialize two appends as SQLite BEGIN IMMEDIATE does. The unique conversation/position
|
|
10
|
+
* constraint remains the backstop, and nested appends join the host's existing transaction.
|
|
11
|
+
*/
|
|
12
|
+
import { sql } from 'kysely';
|
|
13
|
+
import { createSystemClock } from '@jini-ai/core/primitives';
|
|
14
|
+
import { isUniqueViolation } from '@jini-ai/db/kernel';
|
|
15
|
+
import { ChatStoreError } from '../errors.js';
|
|
16
|
+
import { copyScope, atStoreBoundary, runChatQuery } from './boundary.js';
|
|
17
|
+
import { pageLimit, decodeCursor, encodeCursor } from './cursor.js';
|
|
18
|
+
/** UTF-8 byte ordinal ordering, identical on SQLite BINARY and PostgreSQL C collation. */
|
|
19
|
+
function ordinalId(dialect, column) {
|
|
20
|
+
return dialect === 'sqlite' ? sql `${sql.ref(column)} collate BINARY` : sql `${sql.ref(column)} collate "C"`;
|
|
21
|
+
}
|
|
22
|
+
/** The lock every position-assigning append to one conversation takes. */
|
|
23
|
+
function conversationLockKey(conversationId) {
|
|
24
|
+
return `chat-conversation:${conversationId}`;
|
|
25
|
+
}
|
|
26
|
+
function toConversation(row) {
|
|
27
|
+
return {
|
|
28
|
+
id: row.id,
|
|
29
|
+
title: row.title ?? null,
|
|
30
|
+
titleSource: (row.title_source ?? "fallback"),
|
|
31
|
+
messageCount: Number(row.message_count ?? 0),
|
|
32
|
+
createdAt: Number(row.created_at),
|
|
33
|
+
updatedAt: Number(row.updated_at),
|
|
34
|
+
...(row.expires_at === null ? {} : { expiresAt: Number(row.expires_at) }),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* A malformed JSON column yields `undefined` rather than throwing: one half-written `events_json`
|
|
39
|
+
* must not make the whole conversation unreadable (same rule as Jini's store).
|
|
40
|
+
*/
|
|
41
|
+
function safeParse(value) {
|
|
42
|
+
try {
|
|
43
|
+
return JSON.parse(value);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** Project only existing ai_* fields: resumable and lastRunEventId belong to run state. */
|
|
50
|
+
// Both fields disappear on a transcript round trip today. Adding columns alone would let the
|
|
51
|
+
// browser persist claims about a run it does not own. First associate the run with its conversation
|
|
52
|
+
// and let the daemon persist authoritative stream/terminal state, then extend storage for it.
|
|
53
|
+
// The legacy messages store does retain last_run_event_id; that is a separate trust model, not
|
|
54
|
+
// evidence that this projection preserves it. A schema extension also needs host-owned follow-up
|
|
55
|
+
// migrations, because changing the DDL constant cannot update databases already migrated.
|
|
56
|
+
function toMessage(row) {
|
|
57
|
+
const events = row.events_json ? safeParse(row.events_json) : undefined;
|
|
58
|
+
const attachments = row.attachments_json ? safeParse(row.attachments_json) : undefined;
|
|
59
|
+
return {
|
|
60
|
+
id: row.id, role: row.role, content: row.content,
|
|
61
|
+
...(row.agent_id ? { agentId: row.agent_id } : {}),
|
|
62
|
+
...(row.agent_name ? { agentName: row.agent_name } : {}),
|
|
63
|
+
...(events === undefined ? {} : { events }),
|
|
64
|
+
...(attachments === undefined ? {} : { attachments }),
|
|
65
|
+
...(row.run_id ? { runId: row.run_id } : {}),
|
|
66
|
+
...(row.run_status ? { runStatus: row.run_status } : {}),
|
|
67
|
+
...(row.created_at === null ? {} : { createdAt: Number(row.created_at) }),
|
|
68
|
+
...(row.started_at === null ? {} : { startedAt: Number(row.started_at) }),
|
|
69
|
+
...(row.ended_at === null ? {} : { endedAt: Number(row.ended_at) }),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Returns a {@link ChatHistoryStore} that can only ever see `scope`'s own conversations.
|
|
74
|
+
*
|
|
75
|
+
* @param kernel the borrowed kernel. Nothing here opens, closes or configures a database; SQLite's
|
|
76
|
+
* `ON DELETE CASCADE` needs `foreign_keys = ON`, which the host must enable.
|
|
77
|
+
* @param scope the isolation predicate, resolved from the host's authentication. For a guest,
|
|
78
|
+
* `ownerId` must already be a hash of the session key (`tenant-scope.ts`).
|
|
79
|
+
* @param optional Optional core Clock for deterministic host/tests; defaults to system wall time.
|
|
80
|
+
* @complexity each method is one to four indexed statements; `list` is O(owned conversations),
|
|
81
|
+
* `messages` O(messages in the conversation).
|
|
82
|
+
*/
|
|
83
|
+
export function createSqlChatStore({ kernel, scope: inputScope }, { clock = createSystemClock() } = {}) {
|
|
84
|
+
const scope = copyScope({ scope: inputScope });
|
|
85
|
+
const { scopeId, ownerKind, ownerId } = scope;
|
|
86
|
+
// The single subset boundary: DB must contain both required table definitions. Extra host tables
|
|
87
|
+
// stay on the exact borrowed kernel; its transaction context is never projected or recreated.
|
|
88
|
+
const run = (body) => runChatQuery({ kernel: kernel, body: body });
|
|
89
|
+
/** This scope's conversations, projected as {@link ConversationRow}s with their message count. */
|
|
90
|
+
function ownedConversations(db) {
|
|
91
|
+
return db
|
|
92
|
+
.selectFrom("ai_chats as c")
|
|
93
|
+
.select((eb) => [
|
|
94
|
+
"c.id",
|
|
95
|
+
"c.title",
|
|
96
|
+
"c.title_source",
|
|
97
|
+
"c.created_at",
|
|
98
|
+
"c.updated_at",
|
|
99
|
+
"c.expires_at",
|
|
100
|
+
eb
|
|
101
|
+
.selectFrom("ai_chat_messages as m")
|
|
102
|
+
.select((sb) => sb.fn.countAll().as("n"))
|
|
103
|
+
.whereRef("m.conversation_id", "=", "c.id")
|
|
104
|
+
.as("message_count"),
|
|
105
|
+
])
|
|
106
|
+
.where("c.scope_id", "=", scopeId)
|
|
107
|
+
.where("c.owner_kind", "=", ownerKind)
|
|
108
|
+
.where("c.owner_id", "=", ownerId);
|
|
109
|
+
}
|
|
110
|
+
/** One owner-filtered indexed lookup; missing and foreign IDs both return null. */
|
|
111
|
+
async function get({ id }, _optional = {}) {
|
|
112
|
+
const row = await run((db) => ownedConversations(db).where("c.id", "=", id).executeTakeFirst());
|
|
113
|
+
return row ? toConversation(row) : null;
|
|
114
|
+
}
|
|
115
|
+
/** One scoped projection for all message reads, including the row returned by an upsert. */
|
|
116
|
+
function ownedMessages(db, conversationId) {
|
|
117
|
+
return db
|
|
118
|
+
.selectFrom("ai_chat_messages as m")
|
|
119
|
+
.innerJoin("ai_chats as c", "c.id", "m.conversation_id")
|
|
120
|
+
.select([
|
|
121
|
+
"m.id",
|
|
122
|
+
"m.role",
|
|
123
|
+
"m.content",
|
|
124
|
+
"m.agent_id",
|
|
125
|
+
"m.agent_name",
|
|
126
|
+
"m.events_json",
|
|
127
|
+
"m.attachments_json",
|
|
128
|
+
"m.run_id",
|
|
129
|
+
"m.run_status",
|
|
130
|
+
"m.created_at",
|
|
131
|
+
"m.started_at",
|
|
132
|
+
"m.ended_at",
|
|
133
|
+
])
|
|
134
|
+
.where("m.conversation_id", "=", conversationId)
|
|
135
|
+
.where("c.scope_id", "=", scopeId)
|
|
136
|
+
.where("c.owner_kind", "=", ownerKind)
|
|
137
|
+
.where("c.owner_id", "=", ownerId);
|
|
138
|
+
}
|
|
139
|
+
/** Read owned messages in persisted position order; foreign IDs produce no rows. */
|
|
140
|
+
async function messages({ conversationId }, _optional = {}) {
|
|
141
|
+
const rows = await run(db => ownedMessages(db, conversationId).orderBy('m.position').execute());
|
|
142
|
+
return rows.map(toMessage);
|
|
143
|
+
}
|
|
144
|
+
/** Writes and projects one message inside the borrowed transaction; null for absent/foreign IDs. */
|
|
145
|
+
function writeMessage(conversationId, m) {
|
|
146
|
+
return kernel.transaction(async () => {
|
|
147
|
+
await kernel.lockKey(conversationLockKey(conversationId));
|
|
148
|
+
const owned = await run((db) => db
|
|
149
|
+
.selectFrom("ai_chats")
|
|
150
|
+
.select("id")
|
|
151
|
+
.where("id", "=", conversationId)
|
|
152
|
+
.where("scope_id", "=", scopeId)
|
|
153
|
+
.where("owner_kind", "=", ownerKind)
|
|
154
|
+
.where("owner_id", "=", ownerId)
|
|
155
|
+
.executeTakeFirst());
|
|
156
|
+
if (!owned)
|
|
157
|
+
return null;
|
|
158
|
+
const existing = await run((db) => db
|
|
159
|
+
.selectFrom("ai_chat_messages")
|
|
160
|
+
.select("position")
|
|
161
|
+
.where("id", "=", m.id)
|
|
162
|
+
.where("conversation_id", "=", conversationId)
|
|
163
|
+
.executeTakeFirst());
|
|
164
|
+
const position = existing ? Number(existing.position) : await nextPosition(conversationId);
|
|
165
|
+
// The conflict target is `id`, the GLOBAL key: the `where` keeps an id that already lives in
|
|
166
|
+
// another conversation from updating THAT row (the contract's "colliding id to its OWN chat" case).
|
|
167
|
+
await run((db) => db
|
|
168
|
+
.insertInto("ai_chat_messages")
|
|
169
|
+
.values({
|
|
170
|
+
id: m.id,
|
|
171
|
+
conversation_id: conversationId,
|
|
172
|
+
role: m.role,
|
|
173
|
+
content: m.content,
|
|
174
|
+
agent_id: m.agentId ?? null,
|
|
175
|
+
agent_name: m.agentName ?? null,
|
|
176
|
+
events_json: m.events ? JSON.stringify(m.events) : null,
|
|
177
|
+
attachments_json: m.attachments ? JSON.stringify(m.attachments) : null,
|
|
178
|
+
run_id: m.runId ?? null,
|
|
179
|
+
run_status: m.runStatus ?? null,
|
|
180
|
+
position,
|
|
181
|
+
created_at: m.createdAt ?? clock.nowMs(),
|
|
182
|
+
started_at: m.startedAt ?? null,
|
|
183
|
+
ended_at: m.endedAt ?? null,
|
|
184
|
+
})
|
|
185
|
+
.onConflict((oc) => oc
|
|
186
|
+
.column("id")
|
|
187
|
+
.doUpdateSet((eb) => ({
|
|
188
|
+
content: eb.ref("excluded.content"),
|
|
189
|
+
events_json: eb.ref("excluded.events_json"),
|
|
190
|
+
attachments_json: eb.ref("excluded.attachments_json"),
|
|
191
|
+
run_id: eb.ref("excluded.run_id"),
|
|
192
|
+
run_status: eb.ref("excluded.run_status"),
|
|
193
|
+
started_at: eb.ref("excluded.started_at"),
|
|
194
|
+
ended_at: eb.ref("excluded.ended_at"),
|
|
195
|
+
}))
|
|
196
|
+
.where((eb) => eb("ai_chat_messages.conversation_id", "=", eb.ref("excluded.conversation_id"))))
|
|
197
|
+
.execute());
|
|
198
|
+
// A new message is activity: the list orders by `updated_at`.
|
|
199
|
+
await run((db) => db.updateTable("ai_chats").set({ updated_at: clock.nowMs() }).where("id", "=", conversationId).execute());
|
|
200
|
+
// Re-read only the written row, while still in the same transaction and conversation lock.
|
|
201
|
+
const saved = await run(db => ownedMessages(db, conversationId).where('m.id', '=', m.id).executeTakeFirst());
|
|
202
|
+
return saved ? toMessage(saved) : null;
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
async function nextPosition(conversationId) {
|
|
206
|
+
const row = await run((db) => db
|
|
207
|
+
.selectFrom("ai_chat_messages")
|
|
208
|
+
.select((eb) => eb.fn.max("position").as("max"))
|
|
209
|
+
.where("conversation_id", "=", conversationId)
|
|
210
|
+
.executeTakeFirst());
|
|
211
|
+
return row?.max === null || row?.max === undefined ? 0 : Number(row.max) + 1;
|
|
212
|
+
}
|
|
213
|
+
const store = {
|
|
214
|
+
async list() {
|
|
215
|
+
const rows = await run((db) => ownedConversations(db).orderBy("c.updated_at", "desc").execute());
|
|
216
|
+
return rows.map((row) => toConversation(row));
|
|
217
|
+
},
|
|
218
|
+
get,
|
|
219
|
+
async create(input, _optional = {}) {
|
|
220
|
+
const ts = clock.nowMs();
|
|
221
|
+
try {
|
|
222
|
+
await run((db) => db
|
|
223
|
+
.insertInto("ai_chats")
|
|
224
|
+
.values({
|
|
225
|
+
id: input.id,
|
|
226
|
+
scope_id: scopeId,
|
|
227
|
+
owner_kind: ownerKind,
|
|
228
|
+
owner_id: ownerId,
|
|
229
|
+
title: input.title ?? null,
|
|
230
|
+
title_source: input.titleSource ?? "fallback",
|
|
231
|
+
created_at: ts,
|
|
232
|
+
updated_at: ts,
|
|
233
|
+
expires_at: input.expiresAt ?? null,
|
|
234
|
+
})
|
|
235
|
+
.execute());
|
|
236
|
+
}
|
|
237
|
+
catch (cause) {
|
|
238
|
+
if (isUniqueViolation(cause))
|
|
239
|
+
throw new ChatStoreError({ code: 'conflict' }, { cause });
|
|
240
|
+
throw cause;
|
|
241
|
+
}
|
|
242
|
+
const created = await get({ id: input.id });
|
|
243
|
+
// Unreachable: the insert used this exact scope. Asserted so a future change to either
|
|
244
|
+
// statement fails loudly instead of returning a malformed conversation.
|
|
245
|
+
if (!created)
|
|
246
|
+
throw new Error(`ai_chats: created conversation ${input.id} was not readable in its own scope`);
|
|
247
|
+
return created;
|
|
248
|
+
},
|
|
249
|
+
async rename({ id, title }, { source = "manual" } = {}) {
|
|
250
|
+
// A generated title never overwrites a typed one: in the WHERE clause, so a concurrent
|
|
251
|
+
// rename cannot slip between a read and this write.
|
|
252
|
+
await run((db) => db
|
|
253
|
+
.updateTable("ai_chats")
|
|
254
|
+
.set({ title, title_source: source, updated_at: clock.nowMs() })
|
|
255
|
+
.where("id", "=", id)
|
|
256
|
+
.where("scope_id", "=", scopeId)
|
|
257
|
+
.where("owner_kind", "=", ownerKind)
|
|
258
|
+
.where("owner_id", "=", ownerId)
|
|
259
|
+
.$if(source === "generated", (qb) => qb.where("title_source", "<>", "manual"))
|
|
260
|
+
.execute());
|
|
261
|
+
return get({ id });
|
|
262
|
+
},
|
|
263
|
+
async touch({ id }, options = {}) {
|
|
264
|
+
const ts = clock.nowMs();
|
|
265
|
+
await run((db) => db
|
|
266
|
+
.updateTable("ai_chats")
|
|
267
|
+
.set(options?.expiresAt === undefined ? { updated_at: ts } : { updated_at: ts, expires_at: options.expiresAt })
|
|
268
|
+
.where("id", "=", id)
|
|
269
|
+
.where("scope_id", "=", scopeId)
|
|
270
|
+
.where("owner_kind", "=", ownerKind)
|
|
271
|
+
.where("owner_id", "=", ownerId)
|
|
272
|
+
.execute());
|
|
273
|
+
},
|
|
274
|
+
async delete({ id }, _optional = {}) {
|
|
275
|
+
// Messages, agent sessions and tool approvals go with it through `ON DELETE CASCADE`.
|
|
276
|
+
await run((db) => db
|
|
277
|
+
.deleteFrom("ai_chats")
|
|
278
|
+
.where("id", "=", id)
|
|
279
|
+
.where("scope_id", "=", scopeId)
|
|
280
|
+
.where("owner_kind", "=", ownerKind)
|
|
281
|
+
.where("owner_id", "=", ownerId)
|
|
282
|
+
.execute());
|
|
283
|
+
},
|
|
284
|
+
messages,
|
|
285
|
+
async appendMessage({ conversationId, message }, _optional = {}) {
|
|
286
|
+
return writeMessage(conversationId, message);
|
|
287
|
+
},
|
|
288
|
+
async pageConversations(_required, options = {}) {
|
|
289
|
+
const limit = pageLimit({ options: options });
|
|
290
|
+
const anchor = decodeCursor({ cursor: options?.cursor, kind: 'conversations', scope: scope, conversation: null });
|
|
291
|
+
const id = ordinalId(kernel.dialect, 'c.id');
|
|
292
|
+
const rows = await run(db => {
|
|
293
|
+
let query = ownedConversations(db);
|
|
294
|
+
if (anchor)
|
|
295
|
+
query = query.where(eb => eb.or([
|
|
296
|
+
eb('c.updated_at', '<', anchor[0]),
|
|
297
|
+
eb.and([eb('c.updated_at', '=', anchor[0]), eb(id, '>', anchor[1])]),
|
|
298
|
+
]));
|
|
299
|
+
return query.orderBy('c.updated_at', 'desc').orderBy(id, 'asc').limit(limit + 1).execute();
|
|
300
|
+
});
|
|
301
|
+
const items = rows.slice(0, limit).map(row => toConversation(row));
|
|
302
|
+
const last = items.at(-1);
|
|
303
|
+
return { items, ...(rows.length > limit && last ? {
|
|
304
|
+
nextCursor: encodeCursor({ kind: 'conversations', scope: scope, conversation: null, anchor: [last.updatedAt, last.id] }),
|
|
305
|
+
} : {}) };
|
|
306
|
+
},
|
|
307
|
+
async pageMessages(input, options = {}) {
|
|
308
|
+
const limit = pageLimit({ options: options });
|
|
309
|
+
const anchor = decodeCursor({ cursor: options?.cursor, kind: 'messages', scope: scope, conversation: input.conversationId });
|
|
310
|
+
const id = ordinalId(kernel.dialect, 'm.id');
|
|
311
|
+
// One join, at most limit+1 rows, including the ownership check; no parent pre-read.
|
|
312
|
+
const rows = await run(db => {
|
|
313
|
+
let query = ownedMessages(db, input.conversationId).select('m.position');
|
|
314
|
+
if (anchor)
|
|
315
|
+
query = query.where(eb => eb.or([
|
|
316
|
+
eb('m.position', '>', anchor[0]),
|
|
317
|
+
eb.and([eb('m.position', '=', anchor[0]), eb(id, '>', anchor[1])]),
|
|
318
|
+
]));
|
|
319
|
+
return query.orderBy('m.position', 'asc').orderBy(id, 'asc').limit(limit + 1).execute();
|
|
320
|
+
});
|
|
321
|
+
const page = rows.slice(0, limit), last = page.at(-1);
|
|
322
|
+
return { items: page.map(toMessage), ...(rows.length > limit && last ? {
|
|
323
|
+
nextCursor: encodeCursor({ kind: 'messages', scope: scope, conversation: input.conversationId, anchor: [Number(last.position), last.id] }),
|
|
324
|
+
} : {}) };
|
|
325
|
+
},
|
|
326
|
+
};
|
|
327
|
+
return {
|
|
328
|
+
list: () => atStoreBoundary({ operation: () => store.list() }),
|
|
329
|
+
get: (required, optional = {}) => atStoreBoundary({ operation: () => store.get(required, optional) }),
|
|
330
|
+
create: (required, optional = {}) => atStoreBoundary({ operation: () => store.create(required, optional) }),
|
|
331
|
+
rename: (required, optional = {}) => atStoreBoundary({ operation: () => store.rename(required, optional) }),
|
|
332
|
+
touch: (required, optional = {}) => atStoreBoundary({ operation: () => store.touch(required, optional) }),
|
|
333
|
+
delete: (required, optional = {}) => atStoreBoundary({ operation: () => store.delete(required, optional) }),
|
|
334
|
+
messages: (required, optional = {}) => atStoreBoundary({ operation: () => store.messages(required, optional) }),
|
|
335
|
+
appendMessage: (required, optional = {}) => atStoreBoundary({ operation: () => store.appendMessage(required, optional) }),
|
|
336
|
+
pageConversations: (required, optional = {}) => atStoreBoundary({ operation: () => store.pageConversations(required, optional) }),
|
|
337
|
+
pageMessages: (required, optional = {}) => atStoreBoundary({ operation: () => store.pageMessages(required, optional) }),
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
//# sourceMappingURL=store.js.map
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** Existing ai_* schema, owned and migrated by the host. No schema creation on construction. */
|
|
2
|
+
import type { Generated } from 'kysely';
|
|
3
|
+
export type AiChatsTable = {
|
|
4
|
+
id: string;
|
|
5
|
+
scope_id: string;
|
|
6
|
+
owner_kind: string;
|
|
7
|
+
owner_id: string;
|
|
8
|
+
title: string | null;
|
|
9
|
+
title_source: Generated<string>;
|
|
10
|
+
created_at: number;
|
|
11
|
+
updated_at: number;
|
|
12
|
+
expires_at: number | null;
|
|
13
|
+
};
|
|
14
|
+
export type AiChatMessagesTable = {
|
|
15
|
+
id: string;
|
|
16
|
+
conversation_id: string;
|
|
17
|
+
role: string;
|
|
18
|
+
content: string;
|
|
19
|
+
agent_id: string | null;
|
|
20
|
+
agent_name: string | null;
|
|
21
|
+
events_json: string | null;
|
|
22
|
+
attachments_json: string | null;
|
|
23
|
+
run_id: string | null;
|
|
24
|
+
run_status: string | null;
|
|
25
|
+
position: number;
|
|
26
|
+
created_at: number;
|
|
27
|
+
started_at: number | null;
|
|
28
|
+
ended_at: number | null;
|
|
29
|
+
};
|
|
30
|
+
export type ChatDatabase = {
|
|
31
|
+
ai_chats: AiChatsTable;
|
|
32
|
+
ai_chat_messages: AiChatMessagesTable;
|
|
33
|
+
};
|
|
34
|
+
//# sourceMappingURL=tables.d.ts.map
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** Sqlite transcript adapter. The host owns the opened, migrated kernel and its lifetime. */
|
|
2
|
+
import type { Clock } from '@jini-ai/core/primitives';
|
|
3
|
+
import type { StorageKernel } from '@jini-ai/db/kernel';
|
|
4
|
+
import type { ChatDatabase } from '../sql/tables.js';
|
|
5
|
+
import type { ChatOwnerScope, ChatStore, ChatHistoryMaintenance } from '../ports.js';
|
|
6
|
+
export type { ChatDatabase, AiChatsTable, AiChatMessagesTable } from '../sql/tables.js';
|
|
7
|
+
/** Borrow an existing kernel, with no configuration, migration or connection lifecycle effects.
|
|
8
|
+
* @example createSqliteChatStore({ kernel, scope }, { clock: { nowMs: () => 42 } })
|
|
9
|
+
* @throws ChatStoreError invalid-input for a mismatched dialect/transport or owner scope.
|
|
10
|
+
*/
|
|
11
|
+
export declare function createSqliteChatStore<DB extends ChatDatabase>(required: {
|
|
12
|
+
kernel: StorageKernel<DB>;
|
|
13
|
+
scope: ChatOwnerScope;
|
|
14
|
+
}, optional?: {
|
|
15
|
+
clock?: Clock;
|
|
16
|
+
}): ChatStore;
|
|
17
|
+
/** Privileged bounded expiry maintenance; never hand this collaborator to request handlers. */
|
|
18
|
+
export declare function createSqliteChatMaintenance<DB extends ChatDatabase>(required: {
|
|
19
|
+
kernel: StorageKernel<DB>;
|
|
20
|
+
}): ChatHistoryMaintenance;
|
|
21
|
+
import { type SqliteConnectionSource } from '@jini-ai/db/kernel/sqlite';
|
|
22
|
+
export { CHAT_HISTORY_DDL, ensureChatHistoryTables } from './schema.js';
|
|
23
|
+
/** Compatibility wrapper over db's memoized kernel; joins the exact raw-handle kernel context.
|
|
24
|
+
* @example createChatHistoryStore({ db, scope }, { clock: { nowMs: () => 42 } })
|
|
25
|
+
*/
|
|
26
|
+
export declare function createChatHistoryStore({ db, scope }: {
|
|
27
|
+
db: SqliteConnectionSource;
|
|
28
|
+
scope: ChatOwnerScope;
|
|
29
|
+
}, optional?: {
|
|
30
|
+
clock?: Clock;
|
|
31
|
+
}): ChatStore;
|
|
32
|
+
/** Compatibility retention wrapper; the caller still owns the handle and its configuration. */
|
|
33
|
+
export declare function createChatHistoryMaintenance({ db }: {
|
|
34
|
+
db: SqliteConnectionSource;
|
|
35
|
+
}): ChatHistoryMaintenance;
|
|
36
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { createSqlChatStore } from '../sql/store.js';
|
|
2
|
+
import { createSqlChatMaintenance } from '../sql/maintenance.js';
|
|
3
|
+
import { validateKernel } from '../sql/boundary.js';
|
|
4
|
+
/** Borrow an existing kernel, with no configuration, migration or connection lifecycle effects.
|
|
5
|
+
* @example createSqliteChatStore({ kernel, scope }, { clock: { nowMs: () => 42 } })
|
|
6
|
+
* @throws ChatStoreError invalid-input for a mismatched dialect/transport or owner scope.
|
|
7
|
+
*/
|
|
8
|
+
export function createSqliteChatStore(required, optional = {}) {
|
|
9
|
+
validateKernel({ kernel: required.kernel, transports: ['better-sqlite3'], dialect: 'sqlite' });
|
|
10
|
+
return createSqlChatStore(required, optional);
|
|
11
|
+
}
|
|
12
|
+
/** Privileged bounded expiry maintenance; never hand this collaborator to request handlers. */
|
|
13
|
+
export function createSqliteChatMaintenance(required) {
|
|
14
|
+
validateKernel({ kernel: required.kernel, transports: ['better-sqlite3'], dialect: 'sqlite' });
|
|
15
|
+
return createSqlChatMaintenance(required);
|
|
16
|
+
}
|
|
17
|
+
import { sqliteKernel } from '@jini-ai/db/kernel/sqlite';
|
|
18
|
+
export { CHAT_HISTORY_DDL, ensureChatHistoryTables } from './schema.js';
|
|
19
|
+
/** Compatibility wrapper over db's memoized kernel; joins the exact raw-handle kernel context.
|
|
20
|
+
* @example createChatHistoryStore({ db, scope }, { clock: { nowMs: () => 42 } })
|
|
21
|
+
*/
|
|
22
|
+
export function createChatHistoryStore({ db, scope }, optional = {}) {
|
|
23
|
+
return createSqliteChatStore({ kernel: sqliteKernel(db), scope }, optional);
|
|
24
|
+
}
|
|
25
|
+
/** Compatibility retention wrapper; the caller still owns the handle and its configuration. */
|
|
26
|
+
export function createChatHistoryMaintenance({ db }) {
|
|
27
|
+
return createSqliteChatMaintenance({ kernel: sqliteKernel(db) });
|
|
28
|
+
}
|
|
29
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module store/sqlite/schema
|
|
3
|
+
*
|
|
4
|
+
* Canonical unchanged DDL for ai_chats and ai_chat_messages. Hosts own their database,
|
|
5
|
+
* migration ledger and connection configuration. A host with migrations retains its frozen
|
|
6
|
+
* migration statements; a fresh fixture or host without a migrator may call the explicit helper.
|
|
7
|
+
* Store construction never invokes it, chooses a file, or sets connection-wide pragmas.
|
|
8
|
+
*/
|
|
9
|
+
import type { SqliteClient } from '@jini-ai/db/sqlite';
|
|
10
|
+
/**
|
|
11
|
+
* The canonical chat-history schema.
|
|
12
|
+
*
|
|
13
|
+
* Notes on the parts that are easy to get wrong:
|
|
14
|
+
*
|
|
15
|
+
* - **`scope_id`** is the host's partition key (workspace/tenant/project). It is `NOT NULL` from
|
|
16
|
+
* the first migration on purpose: adding it later is trivial, but *backfilling* it is not —
|
|
17
|
+
* pre-existing rows carry no evidence of which partition they belonged to, so a late addition
|
|
18
|
+
* forces a choice between deleting history and guessing.
|
|
19
|
+
* - **`owner_kind` + the CHECK pair** make "a guest row with a `user_id`" unrepresentable rather
|
|
20
|
+
* than merely discouraged.
|
|
21
|
+
* - **`owner_id`** holds a user id for `user`, and a *hash* of the session key for `guest`. The
|
|
22
|
+
* raw token never reaches this table.
|
|
23
|
+
* - **`title_source`** exists so an agent-generated title cannot clobber a manual rename. That
|
|
24
|
+
* rule is enforced in the store, but it needs a column to be enforceable at all.
|
|
25
|
+
* - **`position`** orders messages, not `created_at`: two inserts inside the same millisecond
|
|
26
|
+
* are ordinary under concurrent writers, and a tie there corrupts replay order permanently.
|
|
27
|
+
* `UNIQUE (conversation_id, position)` turns a position-assignment bug into a loud constraint
|
|
28
|
+
* violation instead of a silently scrambled transcript.
|
|
29
|
+
* - **`expires_at NULL`** means "never expires". The retention sweep filters on
|
|
30
|
+
* `expires_at IS NOT NULL`, so authenticated history is out of its reach by construction, not
|
|
31
|
+
* by a predicate that could drift.
|
|
32
|
+
*/
|
|
33
|
+
export declare const CHAT_HISTORY_DDL = "\nCREATE TABLE IF NOT EXISTS ai_chats (\n id TEXT PRIMARY KEY,\n scope_id TEXT NOT NULL,\n owner_kind TEXT NOT NULL CHECK (owner_kind IN ('user','guest')),\n owner_id TEXT NOT NULL,\n title TEXT,\n title_source TEXT NOT NULL DEFAULT 'fallback'\n CHECK (title_source IN ('fallback','generated','manual')),\n created_at INTEGER NOT NULL,\n updated_at INTEGER NOT NULL,\n expires_at INTEGER\n);\n\nCREATE INDEX IF NOT EXISTS idx_ai_chats_owner\n ON ai_chats(scope_id, owner_kind, owner_id, updated_at DESC);\n\nCREATE INDEX IF NOT EXISTS idx_ai_chats_expiry\n ON ai_chats(expires_at) WHERE expires_at IS NOT NULL;\n\nCREATE TABLE IF NOT EXISTS ai_chat_messages (\n id TEXT PRIMARY KEY,\n conversation_id TEXT NOT NULL REFERENCES ai_chats(id) ON DELETE CASCADE,\n role TEXT NOT NULL CHECK (role IN ('user','assistant')),\n content TEXT NOT NULL,\n agent_id TEXT,\n agent_name TEXT,\n events_json TEXT,\n attachments_json TEXT,\n run_id TEXT,\n run_status TEXT,\n position INTEGER NOT NULL,\n created_at INTEGER NOT NULL,\n started_at INTEGER,\n ended_at INTEGER,\n UNIQUE (conversation_id, position)\n);\n\nCREATE INDEX IF NOT EXISTS idx_ai_chat_messages_order\n ON ai_chat_messages(conversation_id, position);\n";
|
|
34
|
+
/**
|
|
35
|
+
* Creates the chat-history tables in the given database if they are absent.
|
|
36
|
+
*
|
|
37
|
+
* Idempotent, matching `ensureToolCatalogTables`'s convention. For a host whose database is
|
|
38
|
+
* managed by its own migration system, prefer copying {@link CHAT_HISTORY_DDL} into a numbered
|
|
39
|
+
* migration instead — see this module's header for why two migrators over one file is a hazard.
|
|
40
|
+
*
|
|
41
|
+
* The caller is responsible for `PRAGMA foreign_keys = ON`; without it the `ON DELETE CASCADE`
|
|
42
|
+
* above is silently inert and deleting a conversation orphans its messages. This function does
|
|
43
|
+
* not set it, because pragmas are connection-wide and a library has no business reconfiguring a
|
|
44
|
+
* handle it was merely lent.
|
|
45
|
+
*/
|
|
46
|
+
export declare function ensureChatHistoryTables({ db }: {
|
|
47
|
+
db: Pick<SqliteClient, 'exec'>;
|
|
48
|
+
}): void;
|
|
49
|
+
//# sourceMappingURL=schema.d.ts.map
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The canonical chat-history schema.
|
|
3
|
+
*
|
|
4
|
+
* Notes on the parts that are easy to get wrong:
|
|
5
|
+
*
|
|
6
|
+
* - **`scope_id`** is the host's partition key (workspace/tenant/project). It is `NOT NULL` from
|
|
7
|
+
* the first migration on purpose: adding it later is trivial, but *backfilling* it is not —
|
|
8
|
+
* pre-existing rows carry no evidence of which partition they belonged to, so a late addition
|
|
9
|
+
* forces a choice between deleting history and guessing.
|
|
10
|
+
* - **`owner_kind` + the CHECK pair** make "a guest row with a `user_id`" unrepresentable rather
|
|
11
|
+
* than merely discouraged.
|
|
12
|
+
* - **`owner_id`** holds a user id for `user`, and a *hash* of the session key for `guest`. The
|
|
13
|
+
* raw token never reaches this table.
|
|
14
|
+
* - **`title_source`** exists so an agent-generated title cannot clobber a manual rename. That
|
|
15
|
+
* rule is enforced in the store, but it needs a column to be enforceable at all.
|
|
16
|
+
* - **`position`** orders messages, not `created_at`: two inserts inside the same millisecond
|
|
17
|
+
* are ordinary under concurrent writers, and a tie there corrupts replay order permanently.
|
|
18
|
+
* `UNIQUE (conversation_id, position)` turns a position-assignment bug into a loud constraint
|
|
19
|
+
* violation instead of a silently scrambled transcript.
|
|
20
|
+
* - **`expires_at NULL`** means "never expires". The retention sweep filters on
|
|
21
|
+
* `expires_at IS NOT NULL`, so authenticated history is out of its reach by construction, not
|
|
22
|
+
* by a predicate that could drift.
|
|
23
|
+
*/
|
|
24
|
+
export const CHAT_HISTORY_DDL = `
|
|
25
|
+
CREATE TABLE IF NOT EXISTS ai_chats (
|
|
26
|
+
id TEXT PRIMARY KEY,
|
|
27
|
+
scope_id TEXT NOT NULL,
|
|
28
|
+
owner_kind TEXT NOT NULL CHECK (owner_kind IN ('user','guest')),
|
|
29
|
+
owner_id TEXT NOT NULL,
|
|
30
|
+
title TEXT,
|
|
31
|
+
title_source TEXT NOT NULL DEFAULT 'fallback'
|
|
32
|
+
CHECK (title_source IN ('fallback','generated','manual')),
|
|
33
|
+
created_at INTEGER NOT NULL,
|
|
34
|
+
updated_at INTEGER NOT NULL,
|
|
35
|
+
expires_at INTEGER
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
CREATE INDEX IF NOT EXISTS idx_ai_chats_owner
|
|
39
|
+
ON ai_chats(scope_id, owner_kind, owner_id, updated_at DESC);
|
|
40
|
+
|
|
41
|
+
CREATE INDEX IF NOT EXISTS idx_ai_chats_expiry
|
|
42
|
+
ON ai_chats(expires_at) WHERE expires_at IS NOT NULL;
|
|
43
|
+
|
|
44
|
+
CREATE TABLE IF NOT EXISTS ai_chat_messages (
|
|
45
|
+
id TEXT PRIMARY KEY,
|
|
46
|
+
conversation_id TEXT NOT NULL REFERENCES ai_chats(id) ON DELETE CASCADE,
|
|
47
|
+
role TEXT NOT NULL CHECK (role IN ('user','assistant')),
|
|
48
|
+
content TEXT NOT NULL,
|
|
49
|
+
agent_id TEXT,
|
|
50
|
+
agent_name TEXT,
|
|
51
|
+
events_json TEXT,
|
|
52
|
+
attachments_json TEXT,
|
|
53
|
+
run_id TEXT,
|
|
54
|
+
run_status TEXT,
|
|
55
|
+
position INTEGER NOT NULL,
|
|
56
|
+
created_at INTEGER NOT NULL,
|
|
57
|
+
started_at INTEGER,
|
|
58
|
+
ended_at INTEGER,
|
|
59
|
+
UNIQUE (conversation_id, position)
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
CREATE INDEX IF NOT EXISTS idx_ai_chat_messages_order
|
|
63
|
+
ON ai_chat_messages(conversation_id, position);
|
|
64
|
+
`;
|
|
65
|
+
/**
|
|
66
|
+
* Creates the chat-history tables in the given database if they are absent.
|
|
67
|
+
*
|
|
68
|
+
* Idempotent, matching `ensureToolCatalogTables`'s convention. For a host whose database is
|
|
69
|
+
* managed by its own migration system, prefer copying {@link CHAT_HISTORY_DDL} into a numbered
|
|
70
|
+
* migration instead — see this module's header for why two migrators over one file is a hazard.
|
|
71
|
+
*
|
|
72
|
+
* The caller is responsible for `PRAGMA foreign_keys = ON`; without it the `ON DELETE CASCADE`
|
|
73
|
+
* above is silently inert and deleting a conversation orphans its messages. This function does
|
|
74
|
+
* not set it, because pragmas are connection-wide and a library has no business reconfiguring a
|
|
75
|
+
* handle it was merely lent.
|
|
76
|
+
*/
|
|
77
|
+
export function ensureChatHistoryTables({ db }) {
|
|
78
|
+
db.exec(CHAT_HISTORY_DDL);
|
|
79
|
+
}
|
|
80
|
+
//# sourceMappingURL=schema.js.map
|