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