@khoralabs/chat 0.1.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/README.md +7 -0
- package/dist/domain.d.ts +10 -0
- package/dist/domain.d.ts.map +1 -0
- package/dist/errors.d.ts +20 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/events.d.ts +59 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/hash.d.ts +47 -0
- package/dist/hash.d.ts.map +1 -0
- package/dist/http/client.d.ts +21 -0
- package/dist/http/client.d.ts.map +1 -0
- package/dist/http/client.js +367 -0
- package/dist/http/config.d.ts +5 -0
- package/dist/http/config.d.ts.map +1 -0
- package/dist/http/index.d.ts +6 -0
- package/dist/http/index.d.ts.map +1 -0
- package/dist/http/index.js +1084 -0
- package/dist/http/routes.d.ts +15 -0
- package/dist/http/routes.d.ts.map +1 -0
- package/dist/http/routes.js +645 -0
- package/dist/http/server.d.ts +19 -0
- package/dist/http/server.d.ts.map +1 -0
- package/dist/http/server.js +973 -0
- package/dist/http/service.d.ts +34 -0
- package/dist/http/service.d.ts.map +1 -0
- package/dist/http/service.js +501 -0
- package/dist/ids.d.ts +8 -0
- package/dist/ids.d.ts.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +453 -0
- package/dist/lineage.d.ts +5 -0
- package/dist/lineage.d.ts.map +1 -0
- package/dist/persistence/core/index.d.ts +2 -0
- package/dist/persistence/core/index.d.ts.map +1 -0
- package/dist/persistence/core/persistence/base-persistence.d.ts +12 -0
- package/dist/persistence/core/persistence/base-persistence.d.ts.map +1 -0
- package/dist/persistence/core/persistence/helpers.d.ts +30 -0
- package/dist/persistence/core/persistence/helpers.d.ts.map +1 -0
- package/dist/persistence/core/persistence/index.d.ts +6 -0
- package/dist/persistence/core/persistence/index.d.ts.map +1 -0
- package/dist/persistence/core/persistence/index.js +1276 -0
- package/dist/persistence/core/persistence/memory-persistence.d.ts +55 -0
- package/dist/persistence/core/persistence/memory-persistence.d.ts.map +1 -0
- package/dist/persistence/core/persistence/signed-persistence.d.ts +18 -0
- package/dist/persistence/core/persistence/signed-persistence.d.ts.map +1 -0
- package/dist/persistence/core/persistence/types.d.ts +210 -0
- package/dist/persistence/core/persistence/types.d.ts.map +1 -0
- package/dist/persistence/sqlite/index.d.ts +3 -0
- package/dist/persistence/sqlite/index.d.ts.map +1 -0
- package/dist/persistence/sqlite/index.js +1544 -0
- package/dist/persistence/sqlite/persistence/persistence.d.ts +49 -0
- package/dist/persistence/sqlite/persistence/persistence.d.ts.map +1 -0
- package/dist/persistence/sqlite/persistence/schema.d.ts +4 -0
- package/dist/persistence/sqlite/persistence/schema.d.ts.map +1 -0
- package/dist/persistence/sqlite/persistence/streaming.d.ts +27 -0
- package/dist/persistence/sqlite/persistence/streaming.d.ts.map +1 -0
- package/dist/persistence/testing/index.d.ts +2 -0
- package/dist/persistence/testing/index.d.ts.map +1 -0
- package/dist/persistence/testing/index.js +689 -0
- package/dist/persistence/testing/persistence/contract.d.ts +3 -0
- package/dist/persistence/testing/persistence/contract.d.ts.map +1 -0
- package/dist/persistence/turso-serverless/index.d.ts +10 -0
- package/dist/persistence/turso-serverless/index.d.ts.map +1 -0
- package/dist/persistence/turso-serverless/index.js +1812 -0
- package/dist/persistence/turso-serverless/persistence/local-sqlite.d.ts +5 -0
- package/dist/persistence/turso-serverless/persistence/local-sqlite.d.ts.map +1 -0
- package/dist/persistence/turso-serverless/persistence/persistence.d.ts +49 -0
- package/dist/persistence/turso-serverless/persistence/persistence.d.ts.map +1 -0
- package/dist/persistence/turso-serverless/persistence/schema.d.ts +4 -0
- package/dist/persistence/turso-serverless/persistence/schema.d.ts.map +1 -0
- package/dist/persistence/turso-serverless/persistence/sql.d.ts +20 -0
- package/dist/persistence/turso-serverless/persistence/sql.d.ts.map +1 -0
- package/dist/persistence/turso-serverless/persistence/sql.js +53 -0
- package/dist/persistence/turso-serverless/persistence/streaming.d.ts +27 -0
- package/dist/persistence/turso-serverless/persistence/streaming.d.ts.map +1 -0
- package/dist/service.d.ts +49 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/stream.d.ts +32 -0
- package/dist/stream.d.ts.map +1 -0
- package/dist/types.d.ts +201 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/validation.d.ts +4 -0
- package/dist/validation.d.ts.map +1 -0
- package/package.json +104 -0
|
@@ -0,0 +1,1544 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
3
|
+
|
|
4
|
+
// src/persistence/sqlite/persistence/persistence.ts
|
|
5
|
+
import {
|
|
6
|
+
BaseChatPersistence,
|
|
7
|
+
buildAclEventContentHash,
|
|
8
|
+
prepareAppendPost as prepareAppendPost2,
|
|
9
|
+
prepareEditPost
|
|
10
|
+
} from "@khoralabs/chat/persistence";
|
|
11
|
+
|
|
12
|
+
// src/errors.ts
|
|
13
|
+
class ChatError extends Error {
|
|
14
|
+
code;
|
|
15
|
+
constructor(code, message) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = "ChatError";
|
|
18
|
+
this.code = code;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
class ChatNotFoundError extends ChatError {
|
|
23
|
+
resource;
|
|
24
|
+
resourceId;
|
|
25
|
+
constructor(resource, resourceId) {
|
|
26
|
+
super("not_found", `${resource} not found: ${resourceId}`);
|
|
27
|
+
this.name = "ChatNotFoundError";
|
|
28
|
+
this.resource = resource;
|
|
29
|
+
this.resourceId = resourceId;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
class ChatConflictError extends ChatError {
|
|
34
|
+
reason;
|
|
35
|
+
constructor(reason, message) {
|
|
36
|
+
super("conflict", message);
|
|
37
|
+
this.name = "ChatConflictError";
|
|
38
|
+
this.reason = reason;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
class ChatValidationError extends ChatError {
|
|
43
|
+
constructor(message) {
|
|
44
|
+
super("validation", message);
|
|
45
|
+
this.name = "ChatValidationError";
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
class ChatPermissionError extends ChatError {
|
|
50
|
+
constructor(message) {
|
|
51
|
+
super("permission_denied", message);
|
|
52
|
+
this.name = "ChatPermissionError";
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// src/hash.ts
|
|
56
|
+
function sha256Hex(input) {
|
|
57
|
+
const hasher = new Bun.CryptoHasher("sha256");
|
|
58
|
+
hasher.update(input);
|
|
59
|
+
return hasher.digest("hex");
|
|
60
|
+
}
|
|
61
|
+
function stableStringify(value) {
|
|
62
|
+
if (value === undefined) {
|
|
63
|
+
return "null";
|
|
64
|
+
}
|
|
65
|
+
if (value === null || typeof value !== "object") {
|
|
66
|
+
return JSON.stringify(value);
|
|
67
|
+
}
|
|
68
|
+
if (Array.isArray(value)) {
|
|
69
|
+
return `[${value.map((item) => stableStringify(item)).join(",")}]`;
|
|
70
|
+
}
|
|
71
|
+
const record = value;
|
|
72
|
+
const keys = Object.keys(record).sort();
|
|
73
|
+
return `{${keys.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`).join(",")}}`;
|
|
74
|
+
}
|
|
75
|
+
function canonicalPostVersionPayload(input) {
|
|
76
|
+
return stableStringify({
|
|
77
|
+
postId: input.postId,
|
|
78
|
+
versionId: input.versionId,
|
|
79
|
+
threadId: input.threadId,
|
|
80
|
+
author: input.author,
|
|
81
|
+
role: input.role,
|
|
82
|
+
parts: input.parts,
|
|
83
|
+
metadata: input.metadata ?? null,
|
|
84
|
+
mentions: input.mentions ?? null,
|
|
85
|
+
model: input.model ?? null,
|
|
86
|
+
usage: input.usage ?? null,
|
|
87
|
+
parentVersionId: input.parentVersionId ?? null,
|
|
88
|
+
previousPostVersionId: input.previousPostVersionId ?? null
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
function computeContentHash(payload) {
|
|
92
|
+
return sha256Hex(canonicalPostVersionPayload(payload));
|
|
93
|
+
}
|
|
94
|
+
function computeLineageHash(input) {
|
|
95
|
+
return sha256Hex(stableStringify({
|
|
96
|
+
previousLineageHash: input.previousLineageHash,
|
|
97
|
+
contentHash: input.contentHash,
|
|
98
|
+
postId: input.postId,
|
|
99
|
+
versionId: input.versionId
|
|
100
|
+
}));
|
|
101
|
+
}
|
|
102
|
+
function canonicalAclEventPayload(input) {
|
|
103
|
+
return stableStringify({
|
|
104
|
+
type: input.type,
|
|
105
|
+
targetType: input.targetType,
|
|
106
|
+
targetId: input.targetId,
|
|
107
|
+
scope: input.scope ?? null,
|
|
108
|
+
role: input.role ?? null,
|
|
109
|
+
actor: input.actor,
|
|
110
|
+
previousAclEventId: input.previousAclEventId ?? null
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
function computeAclEventContentHash(payload) {
|
|
114
|
+
return sha256Hex(canonicalAclEventPayload(payload));
|
|
115
|
+
}
|
|
116
|
+
function signedPayloadBytes(payload) {
|
|
117
|
+
return new TextEncoder().encode(payload);
|
|
118
|
+
}
|
|
119
|
+
function canonicalSignedPostVersionPayload(input) {
|
|
120
|
+
return stableStringify({
|
|
121
|
+
contentHash: input.contentHash,
|
|
122
|
+
lineageHash: input.lineageHash,
|
|
123
|
+
payload: JSON.parse(canonicalPostVersionPayload(input))
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
function canonicalSignedAclEventPayload(input) {
|
|
127
|
+
return stableStringify({
|
|
128
|
+
contentHash: input.contentHash,
|
|
129
|
+
payload: JSON.parse(canonicalAclEventPayload(input.payload))
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
async function maybeSignPayload(payload, signer, sign) {
|
|
133
|
+
return sign(signedPayloadBytes(payload), signer);
|
|
134
|
+
}
|
|
135
|
+
// src/ids.ts
|
|
136
|
+
function createId() {
|
|
137
|
+
return crypto.randomUUID();
|
|
138
|
+
}
|
|
139
|
+
function scopeKey(scope) {
|
|
140
|
+
return `${scope.type}:${scope.id}`;
|
|
141
|
+
}
|
|
142
|
+
function scopeRefFromKey(key) {
|
|
143
|
+
const separatorIndex = key.indexOf(":");
|
|
144
|
+
if (separatorIndex === -1) {
|
|
145
|
+
throw new Error(`Invalid scope key: ${key}`);
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
type: key.slice(0, separatorIndex),
|
|
149
|
+
id: key.slice(separatorIndex + 1)
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
// src/lineage.ts
|
|
153
|
+
function walkLineageFromHead(headVersionId, versionsById) {
|
|
154
|
+
const lineage = [];
|
|
155
|
+
let currentId = headVersionId;
|
|
156
|
+
const seenPostIds = new Set;
|
|
157
|
+
while (currentId) {
|
|
158
|
+
const version = versionsById.get(currentId);
|
|
159
|
+
if (!version)
|
|
160
|
+
break;
|
|
161
|
+
if (!seenPostIds.has(version.postId)) {
|
|
162
|
+
lineage.push(version);
|
|
163
|
+
seenPostIds.add(version.postId);
|
|
164
|
+
}
|
|
165
|
+
currentId = version.previousPostVersionId;
|
|
166
|
+
}
|
|
167
|
+
return lineage.reverse();
|
|
168
|
+
}
|
|
169
|
+
function lineageBetween(headVersionId, ancestorVersionId, versionsById) {
|
|
170
|
+
const lineage = walkLineageFromHead(headVersionId, versionsById);
|
|
171
|
+
const startIndex = lineage.findIndex((version) => version.id === ancestorVersionId);
|
|
172
|
+
if (startIndex === -1)
|
|
173
|
+
return null;
|
|
174
|
+
return lineage.slice(startIndex);
|
|
175
|
+
}
|
|
176
|
+
function postFromVersion(version, index, deletedAtMs) {
|
|
177
|
+
return {
|
|
178
|
+
...version,
|
|
179
|
+
id: version.postId,
|
|
180
|
+
status: "complete",
|
|
181
|
+
versionId: version.id,
|
|
182
|
+
previousVersionId: version.parentVersionId ?? null,
|
|
183
|
+
index,
|
|
184
|
+
updatedAtMs: version.parentVersionId ? version.createdAtMs : null,
|
|
185
|
+
deletedAtMs: deletedAtMs ?? null
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
// src/stream.ts
|
|
189
|
+
function rebuildStreamCacheFromEvents(events) {
|
|
190
|
+
const sorted = [...events].sort((a, b) => a.revision - b.revision);
|
|
191
|
+
let message = null;
|
|
192
|
+
let mentions;
|
|
193
|
+
let model;
|
|
194
|
+
let usage;
|
|
195
|
+
let revision = 0;
|
|
196
|
+
for (const event of sorted) {
|
|
197
|
+
if (event.eventType === "stream.started") {
|
|
198
|
+
if (!event.message) {
|
|
199
|
+
throw new Error(`stream.started event ${event.id} missing message`);
|
|
200
|
+
}
|
|
201
|
+
message = event.message;
|
|
202
|
+
mentions = event.mentions;
|
|
203
|
+
model = event.model;
|
|
204
|
+
usage = event.usage;
|
|
205
|
+
revision = event.revision;
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
if (event.eventType === "stream.delta") {
|
|
209
|
+
if (!event.message) {
|
|
210
|
+
throw new Error(`stream.delta event ${event.id} missing message`);
|
|
211
|
+
}
|
|
212
|
+
message = event.message;
|
|
213
|
+
if (event.mentions !== undefined)
|
|
214
|
+
mentions = event.mentions;
|
|
215
|
+
if (event.model !== undefined)
|
|
216
|
+
model = event.model;
|
|
217
|
+
if (event.usage !== undefined)
|
|
218
|
+
usage = event.usage;
|
|
219
|
+
revision = event.revision;
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
if (event.eventType === "stream.completed" || event.eventType === "stream.aborted") {
|
|
223
|
+
revision = event.revision;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (!message) {
|
|
227
|
+
throw new Error("stream events did not produce a message");
|
|
228
|
+
}
|
|
229
|
+
return { message, mentions, model, usage, revision };
|
|
230
|
+
}
|
|
231
|
+
function streamingPostFromCache(input) {
|
|
232
|
+
return {
|
|
233
|
+
...input.message,
|
|
234
|
+
id: input.postId,
|
|
235
|
+
status: "streaming",
|
|
236
|
+
threadId: input.threadId,
|
|
237
|
+
author: input.author,
|
|
238
|
+
mentions: input.mentions,
|
|
239
|
+
model: input.model,
|
|
240
|
+
usage: input.usage,
|
|
241
|
+
index: input.index,
|
|
242
|
+
streamRevision: input.streamRevision,
|
|
243
|
+
createdAtMs: input.createdAtMs,
|
|
244
|
+
updatedAtMs: input.updatedAtMs ?? null,
|
|
245
|
+
deletedAtMs: input.deletedAtMs ?? null
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
function mergeThreadPostsForList(lineagePosts, activePosts) {
|
|
249
|
+
const byIndex = new Map;
|
|
250
|
+
for (const item of lineagePosts) {
|
|
251
|
+
byIndex.set(item.index, item.post);
|
|
252
|
+
}
|
|
253
|
+
for (const post of activePosts) {
|
|
254
|
+
if (post.status === "streaming" || post.status === "aborted") {
|
|
255
|
+
byIndex.set(post.index, post);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return [...byIndex.entries()].sort(([a], [b]) => a - b).map(([, post]) => post);
|
|
259
|
+
}
|
|
260
|
+
// src/validation.ts
|
|
261
|
+
function assertThreadRoot(root) {
|
|
262
|
+
if (root.type === "channel") {
|
|
263
|
+
if (!root.channelId.trim()) {
|
|
264
|
+
throw new ChatValidationError("channel root requires channelId");
|
|
265
|
+
}
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (!root.postId.trim()) {
|
|
269
|
+
throw new ChatValidationError("post root requires postId");
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
function assertNonEmptyString(value, label) {
|
|
273
|
+
if (!value.trim()) {
|
|
274
|
+
throw new ChatValidationError(`${label} is required`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
// src/persistence/sqlite/persistence/streaming.ts
|
|
278
|
+
import { prepareAppendPost } from "@khoralabs/chat/persistence";
|
|
279
|
+
var DEFAULT_HEAD_NAME = "default";
|
|
280
|
+
function parseJson(value) {
|
|
281
|
+
if (!value)
|
|
282
|
+
return;
|
|
283
|
+
return JSON.parse(value);
|
|
284
|
+
}
|
|
285
|
+
function scopeFromRow(type, id) {
|
|
286
|
+
return { type, id };
|
|
287
|
+
}
|
|
288
|
+
function requireStreamAuthor(row) {
|
|
289
|
+
if (!row.stream_author_scope_type || !row.stream_author_scope_id) {
|
|
290
|
+
throw new ChatValidationError(`post ${row.id} is missing stream author`);
|
|
291
|
+
}
|
|
292
|
+
return scopeFromRow(row.stream_author_scope_type, row.stream_author_scope_id);
|
|
293
|
+
}
|
|
294
|
+
function postFromRow(row, getVersion) {
|
|
295
|
+
if (row.status === "streaming") {
|
|
296
|
+
const message = parseJson(row.stream_message);
|
|
297
|
+
if (!message || !row.stream_author_scope_type || !row.stream_author_scope_id)
|
|
298
|
+
return null;
|
|
299
|
+
return streamingPostFromCache({
|
|
300
|
+
postId: row.id,
|
|
301
|
+
threadId: row.thread_id,
|
|
302
|
+
author: scopeFromRow(row.stream_author_scope_type, row.stream_author_scope_id),
|
|
303
|
+
message,
|
|
304
|
+
mentions: parseJson(row.stream_mentions),
|
|
305
|
+
model: parseJson(row.stream_model),
|
|
306
|
+
usage: parseJson(row.stream_usage),
|
|
307
|
+
index: row.post_index,
|
|
308
|
+
streamRevision: row.stream_revision,
|
|
309
|
+
createdAtMs: row.created_at_ms,
|
|
310
|
+
updatedAtMs: row.updated_at_ms,
|
|
311
|
+
deletedAtMs: row.deleted_at_ms
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
if (row.status === "aborted") {
|
|
315
|
+
const message = parseJson(row.stream_message);
|
|
316
|
+
if (!message || !row.stream_author_scope_type || !row.stream_author_scope_id)
|
|
317
|
+
return null;
|
|
318
|
+
return {
|
|
319
|
+
...message,
|
|
320
|
+
id: row.id,
|
|
321
|
+
status: "aborted",
|
|
322
|
+
threadId: row.thread_id,
|
|
323
|
+
author: scopeFromRow(row.stream_author_scope_type, row.stream_author_scope_id),
|
|
324
|
+
mentions: parseJson(row.stream_mentions),
|
|
325
|
+
model: parseJson(row.stream_model),
|
|
326
|
+
usage: parseJson(row.stream_usage),
|
|
327
|
+
index: row.post_index,
|
|
328
|
+
streamRevision: row.stream_revision,
|
|
329
|
+
createdAtMs: row.created_at_ms,
|
|
330
|
+
updatedAtMs: row.updated_at_ms,
|
|
331
|
+
deletedAtMs: row.deleted_at_ms
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
const versionId = row.completed_version_id;
|
|
335
|
+
if (!versionId)
|
|
336
|
+
return null;
|
|
337
|
+
const version = getVersion(versionId);
|
|
338
|
+
if (!version)
|
|
339
|
+
return null;
|
|
340
|
+
return postFromVersion(version, row.post_index, row.deleted_at_ms);
|
|
341
|
+
}
|
|
342
|
+
function getPostRow(db, postId) {
|
|
343
|
+
return db.prepare(`SELECT id, thread_id, post_index, status, stream_message, stream_mentions, stream_model, stream_usage,
|
|
344
|
+
stream_author_scope_type, stream_author_scope_id, stream_revision,
|
|
345
|
+
completed_version_id, created_at_ms, updated_at_ms, deleted_at_ms
|
|
346
|
+
FROM chat_posts WHERE id = ?`).get(postId);
|
|
347
|
+
}
|
|
348
|
+
function insertStreamEvent(db, event) {
|
|
349
|
+
db.prepare(`INSERT INTO chat_post_stream_events
|
|
350
|
+
(id, post_id, thread_id, event_type, revision, message, delta, mentions, model, usage, idempotency_key, created_at_ms)
|
|
351
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(event.id, event.postId, event.threadId, event.eventType, event.revision, event.message ? JSON.stringify(event.message) : null, event.delta ? JSON.stringify(event.delta) : null, event.mentions ? JSON.stringify(event.mentions) : null, event.model ? JSON.stringify(event.model) : null, event.usage ? JSON.stringify(event.usage) : null, event.idempotencyKey ?? null, event.createdAtMs);
|
|
352
|
+
}
|
|
353
|
+
function getStreamIdempotency(db, key) {
|
|
354
|
+
return db.prepare(`SELECT post_id, revision FROM chat_post_stream_events
|
|
355
|
+
WHERE idempotency_key = ?`).get(key);
|
|
356
|
+
}
|
|
357
|
+
function sqliteListPostStreamEvents(db, postId) {
|
|
358
|
+
const rows = db.prepare(`SELECT id, post_id, thread_id, event_type, revision, message, delta, mentions, model, usage, idempotency_key, created_at_ms
|
|
359
|
+
FROM chat_post_stream_events WHERE post_id = ? ORDER BY revision ASC`).all(postId);
|
|
360
|
+
return rows.map((row) => ({
|
|
361
|
+
id: row.id,
|
|
362
|
+
postId: row.post_id,
|
|
363
|
+
threadId: row.thread_id,
|
|
364
|
+
eventType: row.event_type,
|
|
365
|
+
revision: row.revision,
|
|
366
|
+
message: parseJson(row.message),
|
|
367
|
+
delta: parseJson(row.delta),
|
|
368
|
+
mentions: parseJson(row.mentions),
|
|
369
|
+
model: parseJson(row.model),
|
|
370
|
+
usage: parseJson(row.usage),
|
|
371
|
+
idempotencyKey: row.idempotency_key ?? undefined,
|
|
372
|
+
createdAtMs: row.created_at_ms
|
|
373
|
+
}));
|
|
374
|
+
}
|
|
375
|
+
function sqliteStartStreamedPost(db, input, getVersion) {
|
|
376
|
+
if (input.idempotencyKey) {
|
|
377
|
+
const existing = getStreamIdempotency(db, input.idempotencyKey);
|
|
378
|
+
if (existing) {
|
|
379
|
+
const row = getPostRow(db, existing.post_id);
|
|
380
|
+
if (!row)
|
|
381
|
+
throw new ChatNotFoundError("post", existing.post_id);
|
|
382
|
+
const post = postFromRow(row, getVersion);
|
|
383
|
+
if (post?.status !== "streaming") {
|
|
384
|
+
throw new ChatConflictError("idempotency_mismatch", "idempotency key reused");
|
|
385
|
+
}
|
|
386
|
+
return { post, revision: existing.revision };
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return db.transaction(() => {
|
|
390
|
+
const thread = db.prepare("SELECT id FROM chat_threads WHERE id = ?").get(input.threadId);
|
|
391
|
+
if (!thread)
|
|
392
|
+
throw new ChatNotFoundError("thread", input.threadId);
|
|
393
|
+
const maxIndexRow = db.prepare("SELECT COALESCE(MAX(post_index), 0) AS max_index FROM chat_posts WHERE thread_id = ?").get(input.threadId);
|
|
394
|
+
const nextIndex = maxIndexRow.max_index + 1;
|
|
395
|
+
const postId = input.message.id || createId();
|
|
396
|
+
const createdAtMs = Date.now();
|
|
397
|
+
const message = { ...input.message, id: postId };
|
|
398
|
+
const revision = 1;
|
|
399
|
+
db.prepare(`INSERT INTO chat_posts
|
|
400
|
+
(id, thread_id, post_index, status, stream_message, stream_mentions, stream_model, stream_usage,
|
|
401
|
+
stream_author_scope_type, stream_author_scope_id, stream_revision,
|
|
402
|
+
completed_version_id, created_at_ms, updated_at_ms, deleted_at_ms)
|
|
403
|
+
VALUES (?, ?, ?, 'streaming', ?, ?, ?, ?, ?, ?, ?, NULL, ?, NULL, NULL)`).run(postId, input.threadId, nextIndex, JSON.stringify(message), input.mentions ? JSON.stringify(input.mentions) : null, input.model ? JSON.stringify(input.model) : null, input.usage ? JSON.stringify(input.usage) : null, input.author.type, input.author.id, revision, createdAtMs);
|
|
404
|
+
insertStreamEvent(db, {
|
|
405
|
+
id: createId(),
|
|
406
|
+
postId,
|
|
407
|
+
threadId: input.threadId,
|
|
408
|
+
eventType: "stream.started",
|
|
409
|
+
revision,
|
|
410
|
+
message,
|
|
411
|
+
mentions: input.mentions,
|
|
412
|
+
model: input.model,
|
|
413
|
+
usage: input.usage,
|
|
414
|
+
idempotencyKey: input.idempotencyKey,
|
|
415
|
+
createdAtMs
|
|
416
|
+
});
|
|
417
|
+
const post = streamingPostFromCache({
|
|
418
|
+
postId,
|
|
419
|
+
threadId: input.threadId,
|
|
420
|
+
author: input.author,
|
|
421
|
+
message,
|
|
422
|
+
mentions: input.mentions,
|
|
423
|
+
model: input.model,
|
|
424
|
+
usage: input.usage,
|
|
425
|
+
index: nextIndex,
|
|
426
|
+
streamRevision: revision,
|
|
427
|
+
createdAtMs
|
|
428
|
+
});
|
|
429
|
+
return { post, revision };
|
|
430
|
+
})();
|
|
431
|
+
}
|
|
432
|
+
function sqliteApplyPostDelta(db, input, getVersion) {
|
|
433
|
+
if (input.idempotencyKey) {
|
|
434
|
+
const existing = getStreamIdempotency(db, input.idempotencyKey);
|
|
435
|
+
if (existing && existing.post_id === input.postId) {
|
|
436
|
+
const row = getPostRow(db, input.postId);
|
|
437
|
+
if (!row)
|
|
438
|
+
throw new ChatNotFoundError("post", input.postId);
|
|
439
|
+
const post = postFromRow(row, getVersion);
|
|
440
|
+
if (post?.status !== "streaming") {
|
|
441
|
+
throw new ChatConflictError("idempotency_mismatch", "idempotency key reused");
|
|
442
|
+
}
|
|
443
|
+
return { post, revision: existing.revision };
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
return db.transaction(() => {
|
|
447
|
+
const row = getPostRow(db, input.postId);
|
|
448
|
+
if (!row)
|
|
449
|
+
throw new ChatNotFoundError("post", input.postId);
|
|
450
|
+
if (row.status !== "streaming") {
|
|
451
|
+
throw new ChatValidationError(`post ${input.postId} is not streaming`);
|
|
452
|
+
}
|
|
453
|
+
if (input.expectedRevision !== undefined && row.stream_revision !== input.expectedRevision) {
|
|
454
|
+
throw new ChatConflictError("stream_revision_conflict", `expected revision ${input.expectedRevision}, current ${row.stream_revision}`);
|
|
455
|
+
}
|
|
456
|
+
const revision = row.stream_revision + 1;
|
|
457
|
+
const updatedAtMs = Date.now();
|
|
458
|
+
const message = { ...input.message, id: input.postId };
|
|
459
|
+
const mentions = input.mentions ?? parseJson(row.stream_mentions);
|
|
460
|
+
const model = input.model ?? parseJson(row.stream_model);
|
|
461
|
+
const usage = input.usage ?? parseJson(row.stream_usage);
|
|
462
|
+
db.prepare(`UPDATE chat_posts
|
|
463
|
+
SET stream_message = ?, stream_mentions = ?, stream_model = ?, stream_usage = ?, stream_revision = ?, updated_at_ms = ?
|
|
464
|
+
WHERE id = ?`).run(JSON.stringify(message), mentions ? JSON.stringify(mentions) : null, model ? JSON.stringify(model) : null, usage ? JSON.stringify(usage) : null, revision, updatedAtMs, input.postId);
|
|
465
|
+
insertStreamEvent(db, {
|
|
466
|
+
id: createId(),
|
|
467
|
+
postId: input.postId,
|
|
468
|
+
threadId: row.thread_id,
|
|
469
|
+
eventType: "stream.delta",
|
|
470
|
+
revision,
|
|
471
|
+
message,
|
|
472
|
+
delta: input.delta,
|
|
473
|
+
mentions,
|
|
474
|
+
model,
|
|
475
|
+
usage,
|
|
476
|
+
idempotencyKey: input.idempotencyKey,
|
|
477
|
+
createdAtMs: updatedAtMs
|
|
478
|
+
});
|
|
479
|
+
const post = streamingPostFromCache({
|
|
480
|
+
postId: input.postId,
|
|
481
|
+
threadId: row.thread_id,
|
|
482
|
+
author: requireStreamAuthor(row),
|
|
483
|
+
message,
|
|
484
|
+
mentions,
|
|
485
|
+
model,
|
|
486
|
+
usage,
|
|
487
|
+
index: row.post_index,
|
|
488
|
+
streamRevision: revision,
|
|
489
|
+
createdAtMs: row.created_at_ms,
|
|
490
|
+
updatedAtMs,
|
|
491
|
+
deletedAtMs: row.deleted_at_ms
|
|
492
|
+
});
|
|
493
|
+
return { post, revision };
|
|
494
|
+
})();
|
|
495
|
+
}
|
|
496
|
+
function sqliteCompleteStreamedPost(db, input) {
|
|
497
|
+
return db.transaction(() => {
|
|
498
|
+
const row = getPostRow(db, input.postId);
|
|
499
|
+
if (!row)
|
|
500
|
+
throw new ChatNotFoundError("post", input.postId);
|
|
501
|
+
if (row.status !== "streaming") {
|
|
502
|
+
throw new ChatValidationError(`post ${input.postId} is not streaming`);
|
|
503
|
+
}
|
|
504
|
+
if (input.expectedRevision !== undefined && row.stream_revision !== input.expectedRevision) {
|
|
505
|
+
throw new ChatConflictError("stream_revision_conflict", `expected revision ${input.expectedRevision}, current ${row.stream_revision}`);
|
|
506
|
+
}
|
|
507
|
+
const message = parseJson(row.stream_message);
|
|
508
|
+
if (!message)
|
|
509
|
+
throw new ChatValidationError(`post ${input.postId} has no streamed message`);
|
|
510
|
+
const model = parseJson(row.stream_model);
|
|
511
|
+
const usage = parseJson(row.stream_usage);
|
|
512
|
+
const thread = db.prepare("SELECT id, default_head_id FROM chat_threads WHERE id = ?").get(row.thread_id);
|
|
513
|
+
if (!thread)
|
|
514
|
+
throw new ChatNotFoundError("thread", row.thread_id);
|
|
515
|
+
const currentHead = thread.default_head_id ? db.prepare("SELECT * FROM chat_thread_heads WHERE id = ?").get(thread.default_head_id) : null;
|
|
516
|
+
if (input.expectedHeadPostVersionId !== undefined && (currentHead?.head_post_version_id ?? null) !== (input.expectedHeadPostVersionId ?? null)) {
|
|
517
|
+
if (!currentHead)
|
|
518
|
+
throw new ChatConflictError("head_conflict", "expected head but thread is empty");
|
|
519
|
+
return {
|
|
520
|
+
ok: false,
|
|
521
|
+
reason: "head_conflict",
|
|
522
|
+
currentHead: {
|
|
523
|
+
id: currentHead.id,
|
|
524
|
+
threadId: currentHead.thread_id,
|
|
525
|
+
name: currentHead.name,
|
|
526
|
+
headPostVersionId: currentHead.head_post_version_id,
|
|
527
|
+
createdAtMs: currentHead.created_at_ms
|
|
528
|
+
}
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
const previousVersion = currentHead ? db.prepare("SELECT * FROM chat_post_versions WHERE id = ?").get(currentHead.head_post_version_id) : null;
|
|
532
|
+
const prepared = prepareAppendPost({
|
|
533
|
+
threadId: row.thread_id,
|
|
534
|
+
author: requireStreamAuthor(row),
|
|
535
|
+
message,
|
|
536
|
+
mentions: parseJson(row.stream_mentions),
|
|
537
|
+
model,
|
|
538
|
+
usage,
|
|
539
|
+
previousPostVersionId: previousVersion?.id ?? null,
|
|
540
|
+
previousLineageHash: previousVersion?.lineage_hash ?? null
|
|
541
|
+
});
|
|
542
|
+
db.prepare(`INSERT INTO chat_post_versions
|
|
543
|
+
(id, post_id, thread_id, parent_version_id, previous_post_version_id,
|
|
544
|
+
author_scope_type, author_scope_id, message, mentions, model, usage, content_hash, lineage_hash,
|
|
545
|
+
signature, idempotency_key, created_at_ms)
|
|
546
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(prepared.versionId, prepared.postId, prepared.threadId, null, prepared.previousPostVersionId, prepared.author.type, prepared.author.id, JSON.stringify(prepared.message), prepared.mentions ? JSON.stringify(prepared.mentions) : null, prepared.model ? JSON.stringify(prepared.model) : null, prepared.usage ? JSON.stringify(prepared.usage) : null, prepared.contentHash, prepared.lineageHash, null, input.idempotencyKey ?? null, prepared.createdAtMs);
|
|
547
|
+
const head = currentHead ? {
|
|
548
|
+
id: currentHead.id,
|
|
549
|
+
threadId: row.thread_id,
|
|
550
|
+
name: currentHead.name,
|
|
551
|
+
headPostVersionId: prepared.versionId,
|
|
552
|
+
createdAtMs: currentHead.created_at_ms
|
|
553
|
+
} : {
|
|
554
|
+
id: createId(),
|
|
555
|
+
threadId: row.thread_id,
|
|
556
|
+
name: DEFAULT_HEAD_NAME,
|
|
557
|
+
headPostVersionId: prepared.versionId,
|
|
558
|
+
createdAtMs: prepared.createdAtMs
|
|
559
|
+
};
|
|
560
|
+
if (currentHead) {
|
|
561
|
+
db.prepare("UPDATE chat_thread_heads SET head_post_version_id = ? WHERE id = ?").run(prepared.versionId, head.id);
|
|
562
|
+
} else {
|
|
563
|
+
db.prepare(`INSERT INTO chat_thread_heads (id, thread_id, name, head_post_version_id, created_at_ms)
|
|
564
|
+
VALUES (?, ?, ?, ?, ?)`).run(head.id, head.threadId, head.name, head.headPostVersionId, head.createdAtMs);
|
|
565
|
+
db.prepare("UPDATE chat_threads SET default_head_id = ? WHERE id = ?").run(head.id, row.thread_id);
|
|
566
|
+
}
|
|
567
|
+
const completedRevision = row.stream_revision + 1;
|
|
568
|
+
const completedAtMs = Date.now();
|
|
569
|
+
insertStreamEvent(db, {
|
|
570
|
+
id: createId(),
|
|
571
|
+
postId: input.postId,
|
|
572
|
+
threadId: row.thread_id,
|
|
573
|
+
eventType: "stream.completed",
|
|
574
|
+
revision: completedRevision,
|
|
575
|
+
message,
|
|
576
|
+
mentions: parseJson(row.stream_mentions),
|
|
577
|
+
model,
|
|
578
|
+
usage,
|
|
579
|
+
createdAtMs: completedAtMs
|
|
580
|
+
});
|
|
581
|
+
db.prepare(`UPDATE chat_posts
|
|
582
|
+
SET status = 'complete', stream_message = NULL, stream_mentions = NULL,
|
|
583
|
+
stream_model = NULL, stream_usage = NULL,
|
|
584
|
+
stream_author_scope_type = NULL, stream_author_scope_id = NULL,
|
|
585
|
+
stream_revision = ?, completed_version_id = ?, updated_at_ms = ?
|
|
586
|
+
WHERE id = ?`).run(completedRevision, prepared.versionId, completedAtMs, input.postId);
|
|
587
|
+
const version = {
|
|
588
|
+
...prepared.message,
|
|
589
|
+
id: prepared.versionId,
|
|
590
|
+
postId: prepared.postId,
|
|
591
|
+
threadId: prepared.threadId,
|
|
592
|
+
parentVersionId: null,
|
|
593
|
+
previousPostVersionId: prepared.previousPostVersionId,
|
|
594
|
+
author: prepared.author,
|
|
595
|
+
contentHash: prepared.contentHash,
|
|
596
|
+
lineageHash: prepared.lineageHash,
|
|
597
|
+
mentions: prepared.mentions,
|
|
598
|
+
model: prepared.model,
|
|
599
|
+
usage: prepared.usage,
|
|
600
|
+
createdAtMs: prepared.createdAtMs
|
|
601
|
+
};
|
|
602
|
+
return {
|
|
603
|
+
ok: true,
|
|
604
|
+
post: postFromVersion(version, row.post_index, row.deleted_at_ms),
|
|
605
|
+
head
|
|
606
|
+
};
|
|
607
|
+
})();
|
|
608
|
+
}
|
|
609
|
+
function sqliteAbortStreamedPost(db, input, getVersion) {
|
|
610
|
+
return db.transaction(() => {
|
|
611
|
+
const row = getPostRow(db, input.postId);
|
|
612
|
+
if (!row)
|
|
613
|
+
throw new ChatNotFoundError("post", input.postId);
|
|
614
|
+
if (row.status !== "streaming") {
|
|
615
|
+
throw new ChatValidationError(`post ${input.postId} is not streaming`);
|
|
616
|
+
}
|
|
617
|
+
const deletedAtMs = input.deletedAtMs ?? Date.now();
|
|
618
|
+
const revision = row.stream_revision + 1;
|
|
619
|
+
insertStreamEvent(db, {
|
|
620
|
+
id: createId(),
|
|
621
|
+
postId: input.postId,
|
|
622
|
+
threadId: row.thread_id,
|
|
623
|
+
eventType: "stream.aborted",
|
|
624
|
+
revision,
|
|
625
|
+
message: parseJson(row.stream_message),
|
|
626
|
+
mentions: parseJson(row.stream_mentions),
|
|
627
|
+
model: parseJson(row.stream_model),
|
|
628
|
+
usage: parseJson(row.stream_usage),
|
|
629
|
+
createdAtMs: deletedAtMs
|
|
630
|
+
});
|
|
631
|
+
db.prepare(`UPDATE chat_posts SET status = 'aborted', stream_revision = ?, deleted_at_ms = ?, updated_at_ms = ? WHERE id = ?`).run(revision, deletedAtMs, deletedAtMs, input.postId);
|
|
632
|
+
const updated = getPostRow(db, input.postId);
|
|
633
|
+
if (!updated)
|
|
634
|
+
throw new ChatNotFoundError("post", input.postId);
|
|
635
|
+
const post = postFromRow(updated, getVersion);
|
|
636
|
+
if (post?.status !== "aborted")
|
|
637
|
+
throw new ChatNotFoundError("post", input.postId);
|
|
638
|
+
return { post };
|
|
639
|
+
})();
|
|
640
|
+
}
|
|
641
|
+
function sqliteRebuildStreamedPostCache(db, postId, _getVersion) {
|
|
642
|
+
const row = getPostRow(db, postId);
|
|
643
|
+
if (!row)
|
|
644
|
+
throw new ChatNotFoundError("post", postId);
|
|
645
|
+
if (row.status !== "streaming") {
|
|
646
|
+
throw new ChatValidationError(`post ${postId} is not streaming`);
|
|
647
|
+
}
|
|
648
|
+
const events2 = sqliteListPostStreamEvents(db, postId);
|
|
649
|
+
const rebuilt = rebuildStreamCacheFromEvents(events2);
|
|
650
|
+
const updatedAtMs = Date.now();
|
|
651
|
+
db.prepare(`UPDATE chat_posts SET stream_message = ?, stream_mentions = ?, stream_model = ?, stream_usage = ?, stream_revision = ?, updated_at_ms = ? WHERE id = ?`).run(JSON.stringify(rebuilt.message), rebuilt.mentions ? JSON.stringify(rebuilt.mentions) : null, rebuilt.model ? JSON.stringify(rebuilt.model) : null, rebuilt.usage ? JSON.stringify(rebuilt.usage) : null, rebuilt.revision, updatedAtMs, postId);
|
|
652
|
+
return streamingPostFromCache({
|
|
653
|
+
postId,
|
|
654
|
+
threadId: row.thread_id,
|
|
655
|
+
author: requireStreamAuthor(row),
|
|
656
|
+
message: rebuilt.message,
|
|
657
|
+
mentions: rebuilt.mentions,
|
|
658
|
+
model: rebuilt.model,
|
|
659
|
+
usage: rebuilt.usage,
|
|
660
|
+
index: row.post_index,
|
|
661
|
+
streamRevision: rebuilt.revision,
|
|
662
|
+
createdAtMs: row.created_at_ms,
|
|
663
|
+
updatedAtMs,
|
|
664
|
+
deletedAtMs: row.deleted_at_ms
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// src/persistence/sqlite/persistence/persistence.ts
|
|
669
|
+
var DEFAULT_HEAD_NAME2 = "default";
|
|
670
|
+
function parseJson2(value) {
|
|
671
|
+
if (!value)
|
|
672
|
+
return;
|
|
673
|
+
return JSON.parse(value);
|
|
674
|
+
}
|
|
675
|
+
function parseSignature(value) {
|
|
676
|
+
return parseJson2(value);
|
|
677
|
+
}
|
|
678
|
+
function requireAclSubjectScope(scope, eventId) {
|
|
679
|
+
if (!scope) {
|
|
680
|
+
throw new ChatValidationError(`ACL event ${eventId} is missing subject scope`);
|
|
681
|
+
}
|
|
682
|
+
return scope;
|
|
683
|
+
}
|
|
684
|
+
function requireAclRole(role, eventId) {
|
|
685
|
+
if (!role) {
|
|
686
|
+
throw new ChatValidationError(`ACL event ${eventId} is missing role`);
|
|
687
|
+
}
|
|
688
|
+
return role;
|
|
689
|
+
}
|
|
690
|
+
function aclEventFromRow(row) {
|
|
691
|
+
const actor = scopeFromRow2({
|
|
692
|
+
scope_type: row.actor_scope_type,
|
|
693
|
+
scope_id: row.actor_scope_id
|
|
694
|
+
});
|
|
695
|
+
const scope = row.subject_scope_type && row.subject_scope_id ? scopeFromRow2({
|
|
696
|
+
scope_type: row.subject_scope_type,
|
|
697
|
+
scope_id: row.subject_scope_id
|
|
698
|
+
}) : undefined;
|
|
699
|
+
const base = {
|
|
700
|
+
id: row.id,
|
|
701
|
+
previousAclEventId: row.previous_acl_event_id,
|
|
702
|
+
contentHash: row.content_hash,
|
|
703
|
+
signature: parseSignature(row.signature),
|
|
704
|
+
createdAtMs: row.created_at_ms,
|
|
705
|
+
actor
|
|
706
|
+
};
|
|
707
|
+
if (row.event_type === "channel.member.added") {
|
|
708
|
+
return {
|
|
709
|
+
...base,
|
|
710
|
+
type: "channel.member.added",
|
|
711
|
+
channelId: row.target_id,
|
|
712
|
+
scope: requireAclSubjectScope(scope, row.id),
|
|
713
|
+
role: requireAclRole(row.role, row.id)
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
if (row.event_type === "channel.member.removed") {
|
|
717
|
+
return {
|
|
718
|
+
...base,
|
|
719
|
+
type: "channel.member.removed",
|
|
720
|
+
channelId: row.target_id,
|
|
721
|
+
scope: requireAclSubjectScope(scope, row.id)
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
if (row.event_type === "thread.participant.added") {
|
|
725
|
+
return {
|
|
726
|
+
...base,
|
|
727
|
+
type: "thread.participant.added",
|
|
728
|
+
threadId: row.target_id,
|
|
729
|
+
scope: requireAclSubjectScope(scope, row.id),
|
|
730
|
+
role: requireAclRole(row.role, row.id)
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
return {
|
|
734
|
+
...base,
|
|
735
|
+
type: "thread.participant.removed",
|
|
736
|
+
threadId: row.target_id,
|
|
737
|
+
scope: requireAclSubjectScope(scope, row.id)
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
function scopeFromRow2(row) {
|
|
741
|
+
return { type: row.scope_type, id: row.scope_id };
|
|
742
|
+
}
|
|
743
|
+
function encodeThreadRoot(root) {
|
|
744
|
+
if (root.type === "channel") {
|
|
745
|
+
return {
|
|
746
|
+
root_type: "channel",
|
|
747
|
+
root_id: root.channelId,
|
|
748
|
+
root_version_id: null
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
return {
|
|
752
|
+
root_type: "post",
|
|
753
|
+
root_id: root.postId,
|
|
754
|
+
root_version_id: root.versionId ?? null
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
function decodeThreadRoot(row) {
|
|
758
|
+
if (row.root_type === "channel") {
|
|
759
|
+
return { type: "channel", channelId: row.root_id };
|
|
760
|
+
}
|
|
761
|
+
return {
|
|
762
|
+
type: "post",
|
|
763
|
+
postId: row.root_id,
|
|
764
|
+
versionId: row.root_version_id ?? undefined
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
function versionFromRow(row) {
|
|
768
|
+
const message = JSON.parse(row.message);
|
|
769
|
+
return {
|
|
770
|
+
...message,
|
|
771
|
+
id: row.id,
|
|
772
|
+
postId: row.post_id,
|
|
773
|
+
threadId: row.thread_id,
|
|
774
|
+
parentVersionId: row.parent_version_id,
|
|
775
|
+
previousPostVersionId: row.previous_post_version_id,
|
|
776
|
+
author: scopeFromRow2({
|
|
777
|
+
scope_type: row.author_scope_type,
|
|
778
|
+
scope_id: row.author_scope_id
|
|
779
|
+
}),
|
|
780
|
+
contentHash: row.content_hash,
|
|
781
|
+
lineageHash: row.lineage_hash,
|
|
782
|
+
signature: parseSignature(row.signature),
|
|
783
|
+
mentions: parseJson2(row.mentions),
|
|
784
|
+
model: parseJson2(row.model),
|
|
785
|
+
usage: parseJson2(row.usage),
|
|
786
|
+
createdAtMs: row.created_at_ms
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
class SqliteChatPersistence extends BaseChatPersistence {
|
|
791
|
+
db;
|
|
792
|
+
constructor(db) {
|
|
793
|
+
super();
|
|
794
|
+
this.db = db;
|
|
795
|
+
}
|
|
796
|
+
getPostVersionSync(id) {
|
|
797
|
+
const row = this.db.prepare("SELECT * FROM chat_post_versions WHERE id = ?").get(id);
|
|
798
|
+
return row ? versionFromRow(row) : null;
|
|
799
|
+
}
|
|
800
|
+
async createChannel(input) {
|
|
801
|
+
const channel = {
|
|
802
|
+
id: input.id ?? createId(),
|
|
803
|
+
metadata: input.metadata,
|
|
804
|
+
createdAtMs: Date.now()
|
|
805
|
+
};
|
|
806
|
+
this.db.prepare("INSERT INTO chat_channels (id, metadata, created_at_ms) VALUES (?, ?, ?)").run(channel.id, channel.metadata ? JSON.stringify(channel.metadata) : null, channel.createdAtMs);
|
|
807
|
+
return channel;
|
|
808
|
+
}
|
|
809
|
+
async createThread(input) {
|
|
810
|
+
this.validateCreateThreadInput(input);
|
|
811
|
+
if (input.root.type === "channel") {
|
|
812
|
+
await this.requireChannel(input.root.channelId);
|
|
813
|
+
} else {
|
|
814
|
+
await this.requirePost(input.root.postId);
|
|
815
|
+
}
|
|
816
|
+
const thread = {
|
|
817
|
+
id: input.id ?? createId(),
|
|
818
|
+
root: input.root,
|
|
819
|
+
defaultHeadId: null,
|
|
820
|
+
metadata: input.metadata,
|
|
821
|
+
createdAtMs: Date.now()
|
|
822
|
+
};
|
|
823
|
+
const encoded = encodeThreadRoot(thread.root);
|
|
824
|
+
this.db.prepare(`INSERT INTO chat_threads
|
|
825
|
+
(id, root_type, root_id, root_version_id, default_head_id, metadata, created_at_ms, archived_at_ms)
|
|
826
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(thread.id, encoded.root_type, encoded.root_id, encoded.root_version_id, null, thread.metadata ? JSON.stringify(thread.metadata) : null, thread.createdAtMs, null);
|
|
827
|
+
return thread;
|
|
828
|
+
}
|
|
829
|
+
async getChannel(id) {
|
|
830
|
+
const row = this.db.prepare("SELECT id, metadata, created_at_ms FROM chat_channels WHERE id = ?").get(id);
|
|
831
|
+
if (!row)
|
|
832
|
+
return null;
|
|
833
|
+
return {
|
|
834
|
+
id: row.id,
|
|
835
|
+
metadata: parseJson2(row.metadata),
|
|
836
|
+
createdAtMs: row.created_at_ms
|
|
837
|
+
};
|
|
838
|
+
}
|
|
839
|
+
async getThread(id) {
|
|
840
|
+
const row = this.db.prepare(`SELECT id, root_type, root_id, root_version_id, default_head_id, metadata, created_at_ms, archived_at_ms
|
|
841
|
+
FROM chat_threads WHERE id = ?`).get(id);
|
|
842
|
+
if (!row)
|
|
843
|
+
return null;
|
|
844
|
+
return {
|
|
845
|
+
id: row.id,
|
|
846
|
+
root: decodeThreadRoot(row),
|
|
847
|
+
defaultHeadId: row.default_head_id,
|
|
848
|
+
metadata: parseJson2(row.metadata),
|
|
849
|
+
createdAtMs: row.created_at_ms,
|
|
850
|
+
archivedAtMs: row.archived_at_ms ?? undefined
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
async getPost(id) {
|
|
854
|
+
const postRow = this.db.prepare(`SELECT id, thread_id, post_index, status, stream_message, stream_mentions, stream_model, stream_usage,
|
|
855
|
+
stream_author_scope_type, stream_author_scope_id, stream_revision,
|
|
856
|
+
completed_version_id, created_at_ms, updated_at_ms, deleted_at_ms
|
|
857
|
+
FROM chat_posts WHERE id = ?`).get(id);
|
|
858
|
+
if (!postRow)
|
|
859
|
+
return null;
|
|
860
|
+
return postFromRow(postRow, (versionId) => this.getPostVersionSync(versionId));
|
|
861
|
+
}
|
|
862
|
+
async getPostVersion(id) {
|
|
863
|
+
const row = this.db.prepare("SELECT * FROM chat_post_versions WHERE id = ?").get(id);
|
|
864
|
+
return row ? versionFromRow(row) : null;
|
|
865
|
+
}
|
|
866
|
+
async getThreadHead(threadId, headId) {
|
|
867
|
+
const thread = await this.requireThread(threadId);
|
|
868
|
+
const resolvedHeadId = headId ?? thread.defaultHeadId;
|
|
869
|
+
if (!resolvedHeadId)
|
|
870
|
+
return null;
|
|
871
|
+
const row = this.db.prepare("SELECT * FROM chat_thread_heads WHERE id = ?").get(resolvedHeadId);
|
|
872
|
+
if (!row || row.thread_id !== threadId)
|
|
873
|
+
return null;
|
|
874
|
+
return {
|
|
875
|
+
id: row.id,
|
|
876
|
+
threadId: row.thread_id,
|
|
877
|
+
name: row.name,
|
|
878
|
+
headPostVersionId: row.head_post_version_id,
|
|
879
|
+
createdAtMs: row.created_at_ms
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
async getThreadTip(threadId) {
|
|
883
|
+
const head = await this.getThreadHead(threadId);
|
|
884
|
+
if (!head)
|
|
885
|
+
return null;
|
|
886
|
+
const version = await this.getPostVersion(head.headPostVersionId);
|
|
887
|
+
if (!version)
|
|
888
|
+
return null;
|
|
889
|
+
return { id: version.id, lineageHash: version.lineageHash };
|
|
890
|
+
}
|
|
891
|
+
async listThreads(input) {
|
|
892
|
+
const limit = input.limit ?? 50;
|
|
893
|
+
const start = input.cursor ? Number.parseInt(input.cursor, 10) : 0;
|
|
894
|
+
const rows = this.listThreadRows(input);
|
|
895
|
+
const page = rows.slice(start, start + limit);
|
|
896
|
+
return {
|
|
897
|
+
items: page.map((row) => ({
|
|
898
|
+
id: row.id,
|
|
899
|
+
root: decodeThreadRoot(row),
|
|
900
|
+
defaultHeadId: row.default_head_id,
|
|
901
|
+
metadata: parseJson2(row.metadata),
|
|
902
|
+
createdAtMs: row.created_at_ms,
|
|
903
|
+
archivedAtMs: row.archived_at_ms ?? undefined
|
|
904
|
+
})),
|
|
905
|
+
nextCursor: start + limit < rows.length ? String(start + limit) : null
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
listThreadRows(input) {
|
|
909
|
+
const participant = input.participant;
|
|
910
|
+
if (participant && input.channelId) {
|
|
911
|
+
return this.db.prepare(`SELECT t.*
|
|
912
|
+
FROM chat_threads t
|
|
913
|
+
INNER JOIN chat_thread_participants p ON p.thread_id = t.id
|
|
914
|
+
WHERE p.scope_type = ? AND p.scope_id = ?
|
|
915
|
+
AND t.root_type = 'channel' AND t.root_id = ?
|
|
916
|
+
ORDER BY t.created_at_ms ASC`).all(participant.type, participant.id, input.channelId);
|
|
917
|
+
}
|
|
918
|
+
if (participant && input.postId) {
|
|
919
|
+
return this.db.prepare(`SELECT t.*
|
|
920
|
+
FROM chat_threads t
|
|
921
|
+
INNER JOIN chat_thread_participants p ON p.thread_id = t.id
|
|
922
|
+
WHERE p.scope_type = ? AND p.scope_id = ?
|
|
923
|
+
AND t.root_type = 'post' AND t.root_id = ?
|
|
924
|
+
ORDER BY t.created_at_ms ASC`).all(participant.type, participant.id, input.postId);
|
|
925
|
+
}
|
|
926
|
+
if (participant) {
|
|
927
|
+
return this.db.prepare(`SELECT t.*
|
|
928
|
+
FROM chat_threads t
|
|
929
|
+
INNER JOIN chat_thread_participants p ON p.thread_id = t.id
|
|
930
|
+
WHERE p.scope_type = ? AND p.scope_id = ?
|
|
931
|
+
ORDER BY t.created_at_ms ASC`).all(participant.type, participant.id);
|
|
932
|
+
}
|
|
933
|
+
if (input.channelId) {
|
|
934
|
+
return this.db.prepare(`SELECT * FROM chat_threads
|
|
935
|
+
WHERE root_type = 'channel' AND root_id = ?
|
|
936
|
+
ORDER BY created_at_ms ASC`).all(input.channelId);
|
|
937
|
+
}
|
|
938
|
+
if (input.postId) {
|
|
939
|
+
return this.db.prepare(`SELECT * FROM chat_threads
|
|
940
|
+
WHERE root_type = 'post' AND root_id = ?
|
|
941
|
+
ORDER BY created_at_ms ASC`).all(input.postId);
|
|
942
|
+
}
|
|
943
|
+
return this.db.prepare("SELECT * FROM chat_threads ORDER BY created_at_ms ASC").all();
|
|
944
|
+
}
|
|
945
|
+
async listPosts(input) {
|
|
946
|
+
const thread = await this.requireThread(input.threadId);
|
|
947
|
+
const head = input.headPostVersionId ? {
|
|
948
|
+
id: "inline",
|
|
949
|
+
threadId: input.threadId,
|
|
950
|
+
name: "inline",
|
|
951
|
+
headPostVersionId: input.headPostVersionId,
|
|
952
|
+
createdAtMs: 0
|
|
953
|
+
} : await this.getThreadHead(input.threadId, input.headId ?? thread.defaultHeadId ?? undefined);
|
|
954
|
+
const versionRows = this.db.prepare("SELECT * FROM chat_post_versions WHERE thread_id = ?").all(input.threadId);
|
|
955
|
+
const versionsById = new Map(versionRows.map((row) => [row.id, versionFromRow(row)]));
|
|
956
|
+
const lineagePosts = head ? walkLineageFromHead(head.headPostVersionId, versionsById).flatMap((version) => {
|
|
957
|
+
const postRow = this.db.prepare("SELECT post_index, deleted_at_ms FROM chat_posts WHERE id = ?").get(version.postId);
|
|
958
|
+
if (!postRow)
|
|
959
|
+
return [];
|
|
960
|
+
return [
|
|
961
|
+
{
|
|
962
|
+
index: postRow.post_index,
|
|
963
|
+
post: postFromVersion(version, postRow.post_index, postRow.deleted_at_ms)
|
|
964
|
+
}
|
|
965
|
+
];
|
|
966
|
+
}) : [];
|
|
967
|
+
const activeRows = this.db.prepare(`SELECT id, thread_id, post_index, status, stream_message, stream_mentions, stream_model, stream_usage,
|
|
968
|
+
stream_author_scope_type, stream_author_scope_id, stream_revision,
|
|
969
|
+
completed_version_id, created_at_ms, updated_at_ms, deleted_at_ms
|
|
970
|
+
FROM chat_posts
|
|
971
|
+
WHERE thread_id = ? AND status != 'complete'
|
|
972
|
+
ORDER BY post_index ASC`).all(input.threadId);
|
|
973
|
+
const activePosts = activeRows.map((row) => postFromRow(row, (versionId) => this.getPostVersionSync(versionId))).filter((post) => post !== null && (post.status === "streaming" || post.status === "aborted"));
|
|
974
|
+
const merged = mergeThreadPostsForList(lineagePosts, activePosts);
|
|
975
|
+
const limit = input.limit ?? 50;
|
|
976
|
+
const start = input.cursor ? Number.parseInt(input.cursor, 10) : 0;
|
|
977
|
+
const items = merged.slice(start, start + limit);
|
|
978
|
+
return {
|
|
979
|
+
items,
|
|
980
|
+
nextCursor: start + limit < merged.length ? String(start + limit) : null
|
|
981
|
+
};
|
|
982
|
+
}
|
|
983
|
+
async appendPost(input) {
|
|
984
|
+
if (input.idempotencyKey) {
|
|
985
|
+
const existing = this.db.prepare(`SELECT pv.*, cp.post_index, cp.deleted_at_ms
|
|
986
|
+
FROM chat_post_versions pv
|
|
987
|
+
JOIN chat_posts cp ON cp.id = pv.post_id
|
|
988
|
+
WHERE pv.idempotency_key = ?`).get(input.idempotencyKey);
|
|
989
|
+
if (existing) {
|
|
990
|
+
const version = versionFromRow(existing);
|
|
991
|
+
const head = await this.getThreadHead(input.threadId);
|
|
992
|
+
if (!head) {
|
|
993
|
+
throw new ChatNotFoundError("thread_head", input.threadId);
|
|
994
|
+
}
|
|
995
|
+
return {
|
|
996
|
+
ok: true,
|
|
997
|
+
post: postFromVersion(version, existing.post_index, existing.deleted_at_ms),
|
|
998
|
+
head
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
return this.db.transaction(() => {
|
|
1003
|
+
const thread = this.db.prepare("SELECT * FROM chat_threads WHERE id = ?").get(input.threadId);
|
|
1004
|
+
if (!thread)
|
|
1005
|
+
throw new ChatNotFoundError("thread", input.threadId);
|
|
1006
|
+
const currentHead = thread.default_head_id ? this.db.prepare("SELECT * FROM chat_thread_heads WHERE id = ?").get(thread.default_head_id) : null;
|
|
1007
|
+
if (input.expectedHeadPostVersionId !== undefined && (currentHead?.head_post_version_id ?? null) !== (input.expectedHeadPostVersionId ?? null)) {
|
|
1008
|
+
if (!currentHead) {
|
|
1009
|
+
throw new ChatConflictError("head_conflict", "expected head but thread is empty");
|
|
1010
|
+
}
|
|
1011
|
+
return {
|
|
1012
|
+
ok: false,
|
|
1013
|
+
reason: "head_conflict",
|
|
1014
|
+
currentHead: {
|
|
1015
|
+
id: currentHead.id,
|
|
1016
|
+
threadId: currentHead.thread_id,
|
|
1017
|
+
name: currentHead.name,
|
|
1018
|
+
headPostVersionId: currentHead.head_post_version_id,
|
|
1019
|
+
createdAtMs: currentHead.created_at_ms
|
|
1020
|
+
}
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
const previousVersion = currentHead ? versionFromRow(this.db.prepare("SELECT * FROM chat_post_versions WHERE id = ?").get(currentHead.head_post_version_id)) : null;
|
|
1024
|
+
const prepared = prepareAppendPost2({
|
|
1025
|
+
...input,
|
|
1026
|
+
previousPostVersionId: previousVersion?.id ?? null,
|
|
1027
|
+
previousLineageHash: previousVersion?.lineageHash ?? null
|
|
1028
|
+
});
|
|
1029
|
+
const maxIndexRow = this.db.prepare("SELECT COALESCE(MAX(post_index), 0) AS max_index FROM chat_posts WHERE thread_id = ?").get(input.threadId);
|
|
1030
|
+
const nextIndex = maxIndexRow.max_index + 1;
|
|
1031
|
+
this.db.prepare(`INSERT INTO chat_posts
|
|
1032
|
+
(id, thread_id, post_index, status, stream_revision, completed_version_id,
|
|
1033
|
+
created_at_ms, updated_at_ms, deleted_at_ms)
|
|
1034
|
+
VALUES (?, ?, ?, 'complete', 0, ?, ?, NULL, NULL)`).run(prepared.postId, prepared.threadId, nextIndex, prepared.versionId, prepared.createdAtMs);
|
|
1035
|
+
this.db.prepare(`INSERT INTO chat_post_versions
|
|
1036
|
+
(id, post_id, thread_id, parent_version_id, previous_post_version_id,
|
|
1037
|
+
author_scope_type, author_scope_id, message, mentions, model, usage, content_hash, lineage_hash,
|
|
1038
|
+
signature, idempotency_key, created_at_ms)
|
|
1039
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(prepared.versionId, prepared.postId, prepared.threadId, null, prepared.previousPostVersionId, prepared.author.type, prepared.author.id, JSON.stringify(prepared.message), prepared.mentions ? JSON.stringify(prepared.mentions) : null, prepared.model ? JSON.stringify(prepared.model) : null, prepared.usage ? JSON.stringify(prepared.usage) : null, prepared.contentHash, prepared.lineageHash, input.signature ? JSON.stringify(input.signature) : null, input.idempotencyKey ?? null, prepared.createdAtMs);
|
|
1040
|
+
const head = currentHead ? {
|
|
1041
|
+
id: currentHead.id,
|
|
1042
|
+
threadId: input.threadId,
|
|
1043
|
+
name: currentHead.name,
|
|
1044
|
+
headPostVersionId: prepared.versionId,
|
|
1045
|
+
createdAtMs: currentHead.created_at_ms
|
|
1046
|
+
} : {
|
|
1047
|
+
id: createId(),
|
|
1048
|
+
threadId: input.threadId,
|
|
1049
|
+
name: DEFAULT_HEAD_NAME2,
|
|
1050
|
+
headPostVersionId: prepared.versionId,
|
|
1051
|
+
createdAtMs: prepared.createdAtMs
|
|
1052
|
+
};
|
|
1053
|
+
if (currentHead) {
|
|
1054
|
+
this.db.prepare("UPDATE chat_thread_heads SET head_post_version_id = ? WHERE id = ?").run(prepared.versionId, head.id);
|
|
1055
|
+
} else {
|
|
1056
|
+
this.db.prepare(`INSERT INTO chat_thread_heads
|
|
1057
|
+
(id, thread_id, name, head_post_version_id, created_at_ms)
|
|
1058
|
+
VALUES (?, ?, ?, ?, ?)`).run(head.id, head.threadId, head.name, head.headPostVersionId, head.createdAtMs);
|
|
1059
|
+
this.db.prepare("UPDATE chat_threads SET default_head_id = ? WHERE id = ?").run(head.id, input.threadId);
|
|
1060
|
+
}
|
|
1061
|
+
const version = {
|
|
1062
|
+
...prepared.message,
|
|
1063
|
+
id: prepared.versionId,
|
|
1064
|
+
postId: prepared.postId,
|
|
1065
|
+
threadId: prepared.threadId,
|
|
1066
|
+
parentVersionId: null,
|
|
1067
|
+
previousPostVersionId: prepared.previousPostVersionId,
|
|
1068
|
+
author: prepared.author,
|
|
1069
|
+
contentHash: prepared.contentHash,
|
|
1070
|
+
lineageHash: prepared.lineageHash,
|
|
1071
|
+
mentions: prepared.mentions,
|
|
1072
|
+
createdAtMs: prepared.createdAtMs
|
|
1073
|
+
};
|
|
1074
|
+
return {
|
|
1075
|
+
ok: true,
|
|
1076
|
+
post: postFromVersion(version, nextIndex),
|
|
1077
|
+
head
|
|
1078
|
+
};
|
|
1079
|
+
})();
|
|
1080
|
+
}
|
|
1081
|
+
async editPost(input) {
|
|
1082
|
+
if (input.idempotencyKey) {
|
|
1083
|
+
const existing = this.db.prepare(`SELECT pv.*, cp.post_index, cp.deleted_at_ms
|
|
1084
|
+
FROM chat_post_versions pv
|
|
1085
|
+
JOIN chat_posts cp ON cp.id = pv.post_id
|
|
1086
|
+
WHERE pv.idempotency_key = ?`).get(input.idempotencyKey);
|
|
1087
|
+
if (existing) {
|
|
1088
|
+
const version = versionFromRow(existing);
|
|
1089
|
+
const head = await this.getThreadHead(version.threadId);
|
|
1090
|
+
if (!head) {
|
|
1091
|
+
throw new ChatNotFoundError("thread_head", version.threadId);
|
|
1092
|
+
}
|
|
1093
|
+
return {
|
|
1094
|
+
ok: true,
|
|
1095
|
+
post: postFromVersion(version, existing.post_index, existing.deleted_at_ms),
|
|
1096
|
+
head
|
|
1097
|
+
};
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
return this.db.transaction(() => {
|
|
1101
|
+
const parentRow = this.db.prepare("SELECT * FROM chat_post_versions WHERE id = ?").get(input.parentVersionId);
|
|
1102
|
+
if (!parentRow) {
|
|
1103
|
+
throw new ChatNotFoundError("post_version", input.parentVersionId);
|
|
1104
|
+
}
|
|
1105
|
+
const parentVersion = versionFromRow(parentRow);
|
|
1106
|
+
const thread = this.db.prepare("SELECT * FROM chat_threads WHERE id = ?").get(parentVersion.threadId);
|
|
1107
|
+
if (!thread)
|
|
1108
|
+
throw new ChatNotFoundError("thread", parentVersion.threadId);
|
|
1109
|
+
const currentHead = thread.default_head_id ? this.db.prepare("SELECT * FROM chat_thread_heads WHERE id = ?").get(thread.default_head_id) : null;
|
|
1110
|
+
if (input.expectedHeadPostVersionId !== undefined && (currentHead?.head_post_version_id ?? null) !== (input.expectedHeadPostVersionId ?? null)) {
|
|
1111
|
+
if (!currentHead) {
|
|
1112
|
+
throw new ChatConflictError("head_conflict", "expected head but thread is empty");
|
|
1113
|
+
}
|
|
1114
|
+
return {
|
|
1115
|
+
ok: false,
|
|
1116
|
+
reason: "head_conflict",
|
|
1117
|
+
currentHead: {
|
|
1118
|
+
id: currentHead.id,
|
|
1119
|
+
threadId: currentHead.thread_id,
|
|
1120
|
+
name: currentHead.name,
|
|
1121
|
+
headPostVersionId: currentHead.head_post_version_id,
|
|
1122
|
+
createdAtMs: currentHead.created_at_ms
|
|
1123
|
+
}
|
|
1124
|
+
};
|
|
1125
|
+
}
|
|
1126
|
+
const previousInChain = parentVersion.previousPostVersionId ? versionFromRow(this.db.prepare("SELECT * FROM chat_post_versions WHERE id = ?").get(parentVersion.previousPostVersionId)) : null;
|
|
1127
|
+
const prepared = prepareEditPost({
|
|
1128
|
+
...input,
|
|
1129
|
+
threadId: parentVersion.threadId,
|
|
1130
|
+
previousPostVersionId: parentVersion.previousPostVersionId ?? null,
|
|
1131
|
+
previousLineageHash: previousInChain?.lineageHash ?? null
|
|
1132
|
+
});
|
|
1133
|
+
const postRow = this.db.prepare("SELECT post_index, deleted_at_ms FROM chat_posts WHERE id = ?").get(input.postId);
|
|
1134
|
+
if (!postRow)
|
|
1135
|
+
throw new ChatNotFoundError("post", input.postId);
|
|
1136
|
+
this.db.prepare(`INSERT INTO chat_post_versions
|
|
1137
|
+
(id, post_id, thread_id, parent_version_id, previous_post_version_id,
|
|
1138
|
+
author_scope_type, author_scope_id, message, mentions, model, usage, content_hash, lineage_hash,
|
|
1139
|
+
signature, idempotency_key, created_at_ms)
|
|
1140
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(prepared.versionId, prepared.postId, prepared.threadId, prepared.parentVersionId, prepared.previousPostVersionId, prepared.author.type, prepared.author.id, JSON.stringify(prepared.message), prepared.mentions ? JSON.stringify(prepared.mentions) : null, prepared.model ? JSON.stringify(prepared.model) : null, prepared.usage ? JSON.stringify(prepared.usage) : null, prepared.contentHash, prepared.lineageHash, null, input.idempotencyKey ?? null, prepared.createdAtMs);
|
|
1141
|
+
let head;
|
|
1142
|
+
if (currentHead?.head_post_version_id === input.parentVersionId) {
|
|
1143
|
+
this.db.prepare("UPDATE chat_thread_heads SET head_post_version_id = ? WHERE id = ?").run(prepared.versionId, currentHead.id);
|
|
1144
|
+
head = {
|
|
1145
|
+
id: currentHead.id,
|
|
1146
|
+
threadId: currentHead.thread_id,
|
|
1147
|
+
name: currentHead.name,
|
|
1148
|
+
headPostVersionId: prepared.versionId,
|
|
1149
|
+
createdAtMs: currentHead.created_at_ms
|
|
1150
|
+
};
|
|
1151
|
+
} else if (currentHead) {
|
|
1152
|
+
head = {
|
|
1153
|
+
id: currentHead.id,
|
|
1154
|
+
threadId: currentHead.thread_id,
|
|
1155
|
+
name: currentHead.name,
|
|
1156
|
+
headPostVersionId: currentHead.head_post_version_id,
|
|
1157
|
+
createdAtMs: currentHead.created_at_ms
|
|
1158
|
+
};
|
|
1159
|
+
} else {
|
|
1160
|
+
head = {
|
|
1161
|
+
id: createId(),
|
|
1162
|
+
threadId: parentVersion.threadId,
|
|
1163
|
+
name: DEFAULT_HEAD_NAME2,
|
|
1164
|
+
headPostVersionId: prepared.versionId,
|
|
1165
|
+
createdAtMs: prepared.createdAtMs
|
|
1166
|
+
};
|
|
1167
|
+
this.db.prepare(`INSERT INTO chat_thread_heads
|
|
1168
|
+
(id, thread_id, name, head_post_version_id, created_at_ms)
|
|
1169
|
+
VALUES (?, ?, ?, ?, ?)`).run(head.id, head.threadId, head.name, head.headPostVersionId, head.createdAtMs);
|
|
1170
|
+
this.db.prepare("UPDATE chat_threads SET default_head_id = ? WHERE id = ?").run(head.id, parentVersion.threadId);
|
|
1171
|
+
}
|
|
1172
|
+
const version = {
|
|
1173
|
+
...prepared.message,
|
|
1174
|
+
id: prepared.versionId,
|
|
1175
|
+
postId: prepared.postId,
|
|
1176
|
+
threadId: prepared.threadId,
|
|
1177
|
+
parentVersionId: prepared.parentVersionId,
|
|
1178
|
+
previousPostVersionId: prepared.previousPostVersionId,
|
|
1179
|
+
author: prepared.author,
|
|
1180
|
+
contentHash: prepared.contentHash,
|
|
1181
|
+
lineageHash: prepared.lineageHash,
|
|
1182
|
+
mentions: prepared.mentions,
|
|
1183
|
+
createdAtMs: prepared.createdAtMs
|
|
1184
|
+
};
|
|
1185
|
+
return {
|
|
1186
|
+
ok: true,
|
|
1187
|
+
post: postFromVersion(version, postRow.post_index, postRow.deleted_at_ms),
|
|
1188
|
+
head
|
|
1189
|
+
};
|
|
1190
|
+
})();
|
|
1191
|
+
}
|
|
1192
|
+
async deletePost(input) {
|
|
1193
|
+
const post = await this.requirePost(input.postId);
|
|
1194
|
+
const deletedAtMs = input.deletedAtMs ?? Date.now();
|
|
1195
|
+
this.db.prepare("UPDATE chat_posts SET deleted_at_ms = ? WHERE id = ?").run(deletedAtMs, input.postId);
|
|
1196
|
+
return { ...post, deletedAtMs };
|
|
1197
|
+
}
|
|
1198
|
+
async addChannelMember(input) {
|
|
1199
|
+
await this.requireChannel(input.channelId);
|
|
1200
|
+
const createdAtMs = Date.now();
|
|
1201
|
+
this.db.prepare(`INSERT OR REPLACE INTO chat_channel_members
|
|
1202
|
+
(channel_id, scope_type, scope_id, role, created_at_ms)
|
|
1203
|
+
VALUES (?, ?, ?, ?, ?)`).run(input.channelId, input.scope.type, input.scope.id, input.role, createdAtMs);
|
|
1204
|
+
const previousAclEventId = this.latestAclEventId();
|
|
1205
|
+
const event = {
|
|
1206
|
+
id: createId(),
|
|
1207
|
+
type: "channel.member.added",
|
|
1208
|
+
channelId: input.channelId,
|
|
1209
|
+
scope: input.scope,
|
|
1210
|
+
role: input.role,
|
|
1211
|
+
actor: input.actor,
|
|
1212
|
+
previousAclEventId,
|
|
1213
|
+
contentHash: buildAclEventContentHash({
|
|
1214
|
+
type: "channel.member.added",
|
|
1215
|
+
targetType: "channel",
|
|
1216
|
+
targetId: input.channelId,
|
|
1217
|
+
scope: input.scope,
|
|
1218
|
+
role: input.role,
|
|
1219
|
+
actor: input.actor,
|
|
1220
|
+
previousAclEventId
|
|
1221
|
+
}),
|
|
1222
|
+
signature: input.signature,
|
|
1223
|
+
createdAtMs
|
|
1224
|
+
};
|
|
1225
|
+
this.insertAclEvent(event, input.scope, input.role);
|
|
1226
|
+
return event;
|
|
1227
|
+
}
|
|
1228
|
+
async removeChannelMember(input) {
|
|
1229
|
+
await this.requireChannel(input.channelId);
|
|
1230
|
+
this.db.prepare("DELETE FROM chat_channel_members WHERE channel_id = ? AND scope_type = ? AND scope_id = ?").run(input.channelId, input.scope.type, input.scope.id);
|
|
1231
|
+
const previousAclEventId = this.latestAclEventId();
|
|
1232
|
+
const createdAtMs = Date.now();
|
|
1233
|
+
const event = {
|
|
1234
|
+
id: createId(),
|
|
1235
|
+
type: "channel.member.removed",
|
|
1236
|
+
channelId: input.channelId,
|
|
1237
|
+
scope: input.scope,
|
|
1238
|
+
actor: input.actor,
|
|
1239
|
+
previousAclEventId,
|
|
1240
|
+
contentHash: buildAclEventContentHash({
|
|
1241
|
+
type: "channel.member.removed",
|
|
1242
|
+
targetType: "channel",
|
|
1243
|
+
targetId: input.channelId,
|
|
1244
|
+
scope: input.scope,
|
|
1245
|
+
actor: input.actor,
|
|
1246
|
+
previousAclEventId
|
|
1247
|
+
}),
|
|
1248
|
+
signature: input.signature,
|
|
1249
|
+
createdAtMs
|
|
1250
|
+
};
|
|
1251
|
+
this.insertAclEvent(event, input.scope);
|
|
1252
|
+
return event;
|
|
1253
|
+
}
|
|
1254
|
+
async addThreadParticipant(input) {
|
|
1255
|
+
await this.requireThread(input.threadId);
|
|
1256
|
+
const createdAtMs = Date.now();
|
|
1257
|
+
this.db.prepare(`INSERT OR REPLACE INTO chat_thread_participants
|
|
1258
|
+
(thread_id, scope_type, scope_id, role, created_at_ms)
|
|
1259
|
+
VALUES (?, ?, ?, ?, ?)`).run(input.threadId, input.scope.type, input.scope.id, input.role, createdAtMs);
|
|
1260
|
+
const previousAclEventId = this.latestAclEventId();
|
|
1261
|
+
const event = {
|
|
1262
|
+
id: createId(),
|
|
1263
|
+
type: "thread.participant.added",
|
|
1264
|
+
threadId: input.threadId,
|
|
1265
|
+
scope: input.scope,
|
|
1266
|
+
role: input.role,
|
|
1267
|
+
actor: input.actor,
|
|
1268
|
+
previousAclEventId,
|
|
1269
|
+
contentHash: buildAclEventContentHash({
|
|
1270
|
+
type: "thread.participant.added",
|
|
1271
|
+
targetType: "thread",
|
|
1272
|
+
targetId: input.threadId,
|
|
1273
|
+
scope: input.scope,
|
|
1274
|
+
role: input.role,
|
|
1275
|
+
actor: input.actor,
|
|
1276
|
+
previousAclEventId
|
|
1277
|
+
}),
|
|
1278
|
+
signature: input.signature,
|
|
1279
|
+
createdAtMs
|
|
1280
|
+
};
|
|
1281
|
+
this.insertAclEvent(event, input.scope, input.role, input.threadId);
|
|
1282
|
+
return event;
|
|
1283
|
+
}
|
|
1284
|
+
async removeThreadParticipant(input) {
|
|
1285
|
+
await this.requireThread(input.threadId);
|
|
1286
|
+
this.db.prepare("DELETE FROM chat_thread_participants WHERE thread_id = ? AND scope_type = ? AND scope_id = ?").run(input.threadId, input.scope.type, input.scope.id);
|
|
1287
|
+
const previousAclEventId = this.latestAclEventId();
|
|
1288
|
+
const createdAtMs = Date.now();
|
|
1289
|
+
const event = {
|
|
1290
|
+
id: createId(),
|
|
1291
|
+
type: "thread.participant.removed",
|
|
1292
|
+
threadId: input.threadId,
|
|
1293
|
+
scope: input.scope,
|
|
1294
|
+
actor: input.actor,
|
|
1295
|
+
previousAclEventId,
|
|
1296
|
+
contentHash: buildAclEventContentHash({
|
|
1297
|
+
type: "thread.participant.removed",
|
|
1298
|
+
targetType: "thread",
|
|
1299
|
+
targetId: input.threadId,
|
|
1300
|
+
scope: input.scope,
|
|
1301
|
+
actor: input.actor,
|
|
1302
|
+
previousAclEventId
|
|
1303
|
+
}),
|
|
1304
|
+
signature: input.signature,
|
|
1305
|
+
createdAtMs
|
|
1306
|
+
};
|
|
1307
|
+
this.insertAclEvent(event, input.scope, undefined, input.threadId);
|
|
1308
|
+
return event;
|
|
1309
|
+
}
|
|
1310
|
+
async createThreadHead(input) {
|
|
1311
|
+
await this.requireThread(input.threadId);
|
|
1312
|
+
const version = await this.getPostVersion(input.headPostVersionId);
|
|
1313
|
+
if (!version) {
|
|
1314
|
+
throw new ChatNotFoundError("post_version", input.headPostVersionId);
|
|
1315
|
+
}
|
|
1316
|
+
const head = {
|
|
1317
|
+
id: createId(),
|
|
1318
|
+
threadId: input.threadId,
|
|
1319
|
+
name: input.name,
|
|
1320
|
+
headPostVersionId: input.headPostVersionId,
|
|
1321
|
+
createdAtMs: Date.now()
|
|
1322
|
+
};
|
|
1323
|
+
this.db.prepare(`INSERT INTO chat_thread_heads
|
|
1324
|
+
(id, thread_id, name, head_post_version_id, created_at_ms)
|
|
1325
|
+
VALUES (?, ?, ?, ?, ?)`).run(head.id, head.threadId, head.name, head.headPostVersionId, head.createdAtMs);
|
|
1326
|
+
return head;
|
|
1327
|
+
}
|
|
1328
|
+
async listChannelMembers(channelId) {
|
|
1329
|
+
const rows = this.db.prepare("SELECT scope_type, scope_id FROM chat_channel_members WHERE channel_id = ?").all(channelId);
|
|
1330
|
+
return rows.map(scopeFromRow2);
|
|
1331
|
+
}
|
|
1332
|
+
async listThreadParticipants(threadId) {
|
|
1333
|
+
const rows = this.db.prepare("SELECT scope_type, scope_id FROM chat_thread_participants WHERE thread_id = ?").all(threadId);
|
|
1334
|
+
return rows.map(scopeFromRow2);
|
|
1335
|
+
}
|
|
1336
|
+
async listAclEvents(input) {
|
|
1337
|
+
let query = "SELECT * FROM chat_acl_events";
|
|
1338
|
+
const params = [];
|
|
1339
|
+
if (input.channelId) {
|
|
1340
|
+
query += " WHERE target_type = 'channel' AND target_id = ?";
|
|
1341
|
+
params.push(input.channelId);
|
|
1342
|
+
} else if (input.threadId) {
|
|
1343
|
+
query += " WHERE target_type = 'thread' AND target_id = ?";
|
|
1344
|
+
params.push(input.threadId);
|
|
1345
|
+
}
|
|
1346
|
+
query += " ORDER BY created_at_ms ASC";
|
|
1347
|
+
const rows = this.db.prepare(query).all(...params);
|
|
1348
|
+
const events2 = rows.map(aclEventFromRow);
|
|
1349
|
+
return events2.slice(0, input.limit ?? events2.length);
|
|
1350
|
+
}
|
|
1351
|
+
latestAclEventId() {
|
|
1352
|
+
const row = this.db.prepare("SELECT id FROM chat_acl_events ORDER BY created_at_ms DESC LIMIT 1").get();
|
|
1353
|
+
return row?.id ?? null;
|
|
1354
|
+
}
|
|
1355
|
+
insertAclEvent(event, subject, role, threadTargetId) {
|
|
1356
|
+
const targetType = event.type.startsWith("channel.") ? "channel" : "thread";
|
|
1357
|
+
const targetId = targetType === "channel" ? event.channelId : threadTargetId ?? event.threadId;
|
|
1358
|
+
this.db.prepare(`INSERT INTO chat_acl_events
|
|
1359
|
+
(id, target_type, target_id, event_type, actor_scope_type, actor_scope_id,
|
|
1360
|
+
subject_scope_type, subject_scope_id, role, previous_acl_event_id, content_hash, signature, created_at_ms)
|
|
1361
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(event.id, targetType, targetId, event.type, event.actor.type, event.actor.id, subject?.type ?? null, subject?.id ?? null, role ?? ("role" in event ? event.role : null), event.previousAclEventId ?? null, event.contentHash, event.signature ? JSON.stringify(event.signature) : null, event.createdAtMs);
|
|
1362
|
+
}
|
|
1363
|
+
async listPostStreamEvents(postId) {
|
|
1364
|
+
return sqliteListPostStreamEvents(this.db, postId);
|
|
1365
|
+
}
|
|
1366
|
+
async startStreamedPost(input) {
|
|
1367
|
+
return sqliteStartStreamedPost(this.db, input, (versionId) => this.getPostVersionSync(versionId));
|
|
1368
|
+
}
|
|
1369
|
+
async applyPostDelta(input) {
|
|
1370
|
+
return sqliteApplyPostDelta(this.db, input, (versionId) => this.getPostVersionSync(versionId));
|
|
1371
|
+
}
|
|
1372
|
+
async completeStreamedPost(input) {
|
|
1373
|
+
return sqliteCompleteStreamedPost(this.db, input);
|
|
1374
|
+
}
|
|
1375
|
+
async abortStreamedPost(input) {
|
|
1376
|
+
return sqliteAbortStreamedPost(this.db, input, (versionId) => this.getPostVersionSync(versionId));
|
|
1377
|
+
}
|
|
1378
|
+
async rebuildStreamedPostCache(postId) {
|
|
1379
|
+
return sqliteRebuildStreamedPostCache(this.db, postId, (versionId) => this.getPostVersionSync(versionId));
|
|
1380
|
+
}
|
|
1381
|
+
async setPostVersionSignature(versionId, signature) {
|
|
1382
|
+
const existing = this.db.prepare("SELECT id FROM chat_post_versions WHERE id = ?").get(versionId);
|
|
1383
|
+
if (!existing) {
|
|
1384
|
+
throw new ChatNotFoundError("post_version", versionId);
|
|
1385
|
+
}
|
|
1386
|
+
this.db.prepare("UPDATE chat_post_versions SET signature = ? WHERE id = ?").run(JSON.stringify(signature), versionId);
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
function createSqliteChatPersistence(db) {
|
|
1390
|
+
return new SqliteChatPersistence(db);
|
|
1391
|
+
}
|
|
1392
|
+
// src/persistence/sqlite/persistence/schema.ts
|
|
1393
|
+
import { Database } from "bun:sqlite";
|
|
1394
|
+
var SCHEMA = `
|
|
1395
|
+
CREATE TABLE IF NOT EXISTS chat_channels (
|
|
1396
|
+
id TEXT PRIMARY KEY,
|
|
1397
|
+
metadata TEXT,
|
|
1398
|
+
created_at_ms INTEGER NOT NULL
|
|
1399
|
+
);
|
|
1400
|
+
|
|
1401
|
+
CREATE TABLE IF NOT EXISTS chat_threads (
|
|
1402
|
+
id TEXT PRIMARY KEY,
|
|
1403
|
+
root_type TEXT NOT NULL,
|
|
1404
|
+
root_id TEXT NOT NULL,
|
|
1405
|
+
root_version_id TEXT,
|
|
1406
|
+
default_head_id TEXT,
|
|
1407
|
+
metadata TEXT,
|
|
1408
|
+
created_at_ms INTEGER NOT NULL,
|
|
1409
|
+
archived_at_ms INTEGER
|
|
1410
|
+
);
|
|
1411
|
+
|
|
1412
|
+
CREATE TABLE IF NOT EXISTS chat_posts (
|
|
1413
|
+
id TEXT PRIMARY KEY,
|
|
1414
|
+
thread_id TEXT NOT NULL,
|
|
1415
|
+
post_index INTEGER NOT NULL,
|
|
1416
|
+
status TEXT NOT NULL DEFAULT 'complete',
|
|
1417
|
+
stream_message TEXT,
|
|
1418
|
+
stream_mentions TEXT,
|
|
1419
|
+
stream_model TEXT,
|
|
1420
|
+
stream_usage TEXT,
|
|
1421
|
+
stream_author_scope_type TEXT,
|
|
1422
|
+
stream_author_scope_id TEXT,
|
|
1423
|
+
stream_revision INTEGER NOT NULL DEFAULT 0,
|
|
1424
|
+
completed_version_id TEXT,
|
|
1425
|
+
created_at_ms INTEGER NOT NULL,
|
|
1426
|
+
updated_at_ms INTEGER,
|
|
1427
|
+
deleted_at_ms INTEGER,
|
|
1428
|
+
UNIQUE(thread_id, post_index)
|
|
1429
|
+
);
|
|
1430
|
+
|
|
1431
|
+
CREATE TABLE IF NOT EXISTS chat_post_stream_events (
|
|
1432
|
+
id TEXT PRIMARY KEY,
|
|
1433
|
+
post_id TEXT NOT NULL,
|
|
1434
|
+
thread_id TEXT NOT NULL,
|
|
1435
|
+
event_type TEXT NOT NULL,
|
|
1436
|
+
revision INTEGER NOT NULL,
|
|
1437
|
+
message TEXT,
|
|
1438
|
+
delta TEXT,
|
|
1439
|
+
mentions TEXT,
|
|
1440
|
+
model TEXT,
|
|
1441
|
+
usage TEXT,
|
|
1442
|
+
idempotency_key TEXT UNIQUE,
|
|
1443
|
+
created_at_ms INTEGER NOT NULL
|
|
1444
|
+
);
|
|
1445
|
+
|
|
1446
|
+
CREATE TABLE IF NOT EXISTS chat_post_versions (
|
|
1447
|
+
id TEXT PRIMARY KEY,
|
|
1448
|
+
post_id TEXT NOT NULL,
|
|
1449
|
+
thread_id TEXT NOT NULL,
|
|
1450
|
+
parent_version_id TEXT,
|
|
1451
|
+
previous_post_version_id TEXT,
|
|
1452
|
+
author_scope_type TEXT NOT NULL,
|
|
1453
|
+
author_scope_id TEXT NOT NULL,
|
|
1454
|
+
message TEXT NOT NULL,
|
|
1455
|
+
mentions TEXT,
|
|
1456
|
+
model TEXT,
|
|
1457
|
+
usage TEXT,
|
|
1458
|
+
content_hash TEXT NOT NULL,
|
|
1459
|
+
lineage_hash TEXT NOT NULL,
|
|
1460
|
+
signature TEXT,
|
|
1461
|
+
idempotency_key TEXT UNIQUE,
|
|
1462
|
+
created_at_ms INTEGER NOT NULL
|
|
1463
|
+
);
|
|
1464
|
+
|
|
1465
|
+
CREATE TABLE IF NOT EXISTS chat_thread_heads (
|
|
1466
|
+
id TEXT PRIMARY KEY,
|
|
1467
|
+
thread_id TEXT NOT NULL,
|
|
1468
|
+
name TEXT NOT NULL,
|
|
1469
|
+
head_post_version_id TEXT NOT NULL,
|
|
1470
|
+
created_at_ms INTEGER NOT NULL
|
|
1471
|
+
);
|
|
1472
|
+
|
|
1473
|
+
CREATE TABLE IF NOT EXISTS chat_acl_events (
|
|
1474
|
+
id TEXT PRIMARY KEY,
|
|
1475
|
+
target_type TEXT NOT NULL,
|
|
1476
|
+
target_id TEXT NOT NULL,
|
|
1477
|
+
event_type TEXT NOT NULL,
|
|
1478
|
+
actor_scope_type TEXT NOT NULL,
|
|
1479
|
+
actor_scope_id TEXT NOT NULL,
|
|
1480
|
+
subject_scope_type TEXT,
|
|
1481
|
+
subject_scope_id TEXT,
|
|
1482
|
+
role TEXT,
|
|
1483
|
+
previous_acl_event_id TEXT,
|
|
1484
|
+
content_hash TEXT NOT NULL,
|
|
1485
|
+
signature TEXT,
|
|
1486
|
+
created_at_ms INTEGER NOT NULL
|
|
1487
|
+
);
|
|
1488
|
+
|
|
1489
|
+
CREATE TABLE IF NOT EXISTS chat_channel_members (
|
|
1490
|
+
channel_id TEXT NOT NULL,
|
|
1491
|
+
scope_type TEXT NOT NULL,
|
|
1492
|
+
scope_id TEXT NOT NULL,
|
|
1493
|
+
role TEXT NOT NULL,
|
|
1494
|
+
created_at_ms INTEGER NOT NULL,
|
|
1495
|
+
PRIMARY KEY (channel_id, scope_type, scope_id)
|
|
1496
|
+
);
|
|
1497
|
+
|
|
1498
|
+
CREATE TABLE IF NOT EXISTS chat_thread_participants (
|
|
1499
|
+
thread_id TEXT NOT NULL,
|
|
1500
|
+
scope_type TEXT NOT NULL,
|
|
1501
|
+
scope_id TEXT NOT NULL,
|
|
1502
|
+
role TEXT NOT NULL,
|
|
1503
|
+
created_at_ms INTEGER NOT NULL,
|
|
1504
|
+
PRIMARY KEY (thread_id, scope_type, scope_id)
|
|
1505
|
+
);
|
|
1506
|
+
|
|
1507
|
+
CREATE INDEX IF NOT EXISTS idx_chat_threads_root ON chat_threads(root_type, root_id);
|
|
1508
|
+
CREATE INDEX IF NOT EXISTS idx_chat_post_versions_thread ON chat_post_versions(thread_id);
|
|
1509
|
+
CREATE INDEX IF NOT EXISTS idx_chat_post_versions_post ON chat_post_versions(post_id);
|
|
1510
|
+
CREATE INDEX IF NOT EXISTS idx_chat_posts_thread ON chat_posts(thread_id, post_index);
|
|
1511
|
+
CREATE INDEX IF NOT EXISTS idx_chat_post_stream_events_post ON chat_post_stream_events(post_id, revision);
|
|
1512
|
+
`;
|
|
1513
|
+
function ensureChatSqliteSchema(db, busyTimeoutMs = 5000) {
|
|
1514
|
+
db.run("PRAGMA journal_mode = WAL;");
|
|
1515
|
+
db.run(`PRAGMA busy_timeout = ${busyTimeoutMs};`);
|
|
1516
|
+
db.run("PRAGMA foreign_keys = ON;");
|
|
1517
|
+
db.exec(SCHEMA);
|
|
1518
|
+
for (const statement of [
|
|
1519
|
+
"ALTER TABLE chat_posts ADD COLUMN stream_model TEXT",
|
|
1520
|
+
"ALTER TABLE chat_posts ADD COLUMN stream_usage TEXT",
|
|
1521
|
+
"ALTER TABLE chat_post_stream_events ADD COLUMN model TEXT",
|
|
1522
|
+
"ALTER TABLE chat_post_stream_events ADD COLUMN usage TEXT",
|
|
1523
|
+
"ALTER TABLE chat_post_versions ADD COLUMN model TEXT",
|
|
1524
|
+
"ALTER TABLE chat_post_versions ADD COLUMN usage TEXT"
|
|
1525
|
+
]) {
|
|
1526
|
+
try {
|
|
1527
|
+
db.run(statement);
|
|
1528
|
+
} catch (error) {
|
|
1529
|
+
if (!(error instanceof Error) || !error.message.includes("duplicate column name"))
|
|
1530
|
+
throw error;
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
function createChatDatabase(path = ":memory:") {
|
|
1535
|
+
const db = new Database(path);
|
|
1536
|
+
ensureChatSqliteSchema(db);
|
|
1537
|
+
return db;
|
|
1538
|
+
}
|
|
1539
|
+
export {
|
|
1540
|
+
ensureChatSqliteSchema,
|
|
1541
|
+
createSqliteChatPersistence,
|
|
1542
|
+
createChatDatabase,
|
|
1543
|
+
SqliteChatPersistence
|
|
1544
|
+
};
|