@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.
Files changed (85) hide show
  1. package/README.md +7 -0
  2. package/dist/domain.d.ts +10 -0
  3. package/dist/domain.d.ts.map +1 -0
  4. package/dist/errors.d.ts +20 -0
  5. package/dist/errors.d.ts.map +1 -0
  6. package/dist/events.d.ts +59 -0
  7. package/dist/events.d.ts.map +1 -0
  8. package/dist/hash.d.ts +47 -0
  9. package/dist/hash.d.ts.map +1 -0
  10. package/dist/http/client.d.ts +21 -0
  11. package/dist/http/client.d.ts.map +1 -0
  12. package/dist/http/client.js +367 -0
  13. package/dist/http/config.d.ts +5 -0
  14. package/dist/http/config.d.ts.map +1 -0
  15. package/dist/http/index.d.ts +6 -0
  16. package/dist/http/index.d.ts.map +1 -0
  17. package/dist/http/index.js +1084 -0
  18. package/dist/http/routes.d.ts +15 -0
  19. package/dist/http/routes.d.ts.map +1 -0
  20. package/dist/http/routes.js +645 -0
  21. package/dist/http/server.d.ts +19 -0
  22. package/dist/http/server.d.ts.map +1 -0
  23. package/dist/http/server.js +973 -0
  24. package/dist/http/service.d.ts +34 -0
  25. package/dist/http/service.d.ts.map +1 -0
  26. package/dist/http/service.js +501 -0
  27. package/dist/ids.d.ts +8 -0
  28. package/dist/ids.d.ts.map +1 -0
  29. package/dist/index.d.ts +3 -0
  30. package/dist/index.d.ts.map +1 -0
  31. package/dist/index.js +453 -0
  32. package/dist/lineage.d.ts +5 -0
  33. package/dist/lineage.d.ts.map +1 -0
  34. package/dist/persistence/core/index.d.ts +2 -0
  35. package/dist/persistence/core/index.d.ts.map +1 -0
  36. package/dist/persistence/core/persistence/base-persistence.d.ts +12 -0
  37. package/dist/persistence/core/persistence/base-persistence.d.ts.map +1 -0
  38. package/dist/persistence/core/persistence/helpers.d.ts +30 -0
  39. package/dist/persistence/core/persistence/helpers.d.ts.map +1 -0
  40. package/dist/persistence/core/persistence/index.d.ts +6 -0
  41. package/dist/persistence/core/persistence/index.d.ts.map +1 -0
  42. package/dist/persistence/core/persistence/index.js +1276 -0
  43. package/dist/persistence/core/persistence/memory-persistence.d.ts +55 -0
  44. package/dist/persistence/core/persistence/memory-persistence.d.ts.map +1 -0
  45. package/dist/persistence/core/persistence/signed-persistence.d.ts +18 -0
  46. package/dist/persistence/core/persistence/signed-persistence.d.ts.map +1 -0
  47. package/dist/persistence/core/persistence/types.d.ts +210 -0
  48. package/dist/persistence/core/persistence/types.d.ts.map +1 -0
  49. package/dist/persistence/sqlite/index.d.ts +3 -0
  50. package/dist/persistence/sqlite/index.d.ts.map +1 -0
  51. package/dist/persistence/sqlite/index.js +1544 -0
  52. package/dist/persistence/sqlite/persistence/persistence.d.ts +49 -0
  53. package/dist/persistence/sqlite/persistence/persistence.d.ts.map +1 -0
  54. package/dist/persistence/sqlite/persistence/schema.d.ts +4 -0
  55. package/dist/persistence/sqlite/persistence/schema.d.ts.map +1 -0
  56. package/dist/persistence/sqlite/persistence/streaming.d.ts +27 -0
  57. package/dist/persistence/sqlite/persistence/streaming.d.ts.map +1 -0
  58. package/dist/persistence/testing/index.d.ts +2 -0
  59. package/dist/persistence/testing/index.d.ts.map +1 -0
  60. package/dist/persistence/testing/index.js +689 -0
  61. package/dist/persistence/testing/persistence/contract.d.ts +3 -0
  62. package/dist/persistence/testing/persistence/contract.d.ts.map +1 -0
  63. package/dist/persistence/turso-serverless/index.d.ts +10 -0
  64. package/dist/persistence/turso-serverless/index.d.ts.map +1 -0
  65. package/dist/persistence/turso-serverless/index.js +1812 -0
  66. package/dist/persistence/turso-serverless/persistence/local-sqlite.d.ts +5 -0
  67. package/dist/persistence/turso-serverless/persistence/local-sqlite.d.ts.map +1 -0
  68. package/dist/persistence/turso-serverless/persistence/persistence.d.ts +49 -0
  69. package/dist/persistence/turso-serverless/persistence/persistence.d.ts.map +1 -0
  70. package/dist/persistence/turso-serverless/persistence/schema.d.ts +4 -0
  71. package/dist/persistence/turso-serverless/persistence/schema.d.ts.map +1 -0
  72. package/dist/persistence/turso-serverless/persistence/sql.d.ts +20 -0
  73. package/dist/persistence/turso-serverless/persistence/sql.d.ts.map +1 -0
  74. package/dist/persistence/turso-serverless/persistence/sql.js +53 -0
  75. package/dist/persistence/turso-serverless/persistence/streaming.d.ts +27 -0
  76. package/dist/persistence/turso-serverless/persistence/streaming.d.ts.map +1 -0
  77. package/dist/service.d.ts +49 -0
  78. package/dist/service.d.ts.map +1 -0
  79. package/dist/stream.d.ts +32 -0
  80. package/dist/stream.d.ts.map +1 -0
  81. package/dist/types.d.ts +201 -0
  82. package/dist/types.d.ts.map +1 -0
  83. package/dist/validation.d.ts +4 -0
  84. package/dist/validation.d.ts.map +1 -0
  85. 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
+ };