@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,1276 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
3
|
+
|
|
4
|
+
// src/errors.ts
|
|
5
|
+
class ChatError extends Error {
|
|
6
|
+
code;
|
|
7
|
+
constructor(code, message) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = "ChatError";
|
|
10
|
+
this.code = code;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
class ChatNotFoundError extends ChatError {
|
|
15
|
+
resource;
|
|
16
|
+
resourceId;
|
|
17
|
+
constructor(resource, resourceId) {
|
|
18
|
+
super("not_found", `${resource} not found: ${resourceId}`);
|
|
19
|
+
this.name = "ChatNotFoundError";
|
|
20
|
+
this.resource = resource;
|
|
21
|
+
this.resourceId = resourceId;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
class ChatConflictError extends ChatError {
|
|
26
|
+
reason;
|
|
27
|
+
constructor(reason, message) {
|
|
28
|
+
super("conflict", message);
|
|
29
|
+
this.name = "ChatConflictError";
|
|
30
|
+
this.reason = reason;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
class ChatValidationError extends ChatError {
|
|
35
|
+
constructor(message) {
|
|
36
|
+
super("validation", message);
|
|
37
|
+
this.name = "ChatValidationError";
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
class ChatPermissionError extends ChatError {
|
|
42
|
+
constructor(message) {
|
|
43
|
+
super("permission_denied", message);
|
|
44
|
+
this.name = "ChatPermissionError";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// src/hash.ts
|
|
48
|
+
function sha256Hex(input) {
|
|
49
|
+
const hasher = new Bun.CryptoHasher("sha256");
|
|
50
|
+
hasher.update(input);
|
|
51
|
+
return hasher.digest("hex");
|
|
52
|
+
}
|
|
53
|
+
function stableStringify(value) {
|
|
54
|
+
if (value === undefined) {
|
|
55
|
+
return "null";
|
|
56
|
+
}
|
|
57
|
+
if (value === null || typeof value !== "object") {
|
|
58
|
+
return JSON.stringify(value);
|
|
59
|
+
}
|
|
60
|
+
if (Array.isArray(value)) {
|
|
61
|
+
return `[${value.map((item) => stableStringify(item)).join(",")}]`;
|
|
62
|
+
}
|
|
63
|
+
const record = value;
|
|
64
|
+
const keys = Object.keys(record).sort();
|
|
65
|
+
return `{${keys.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`).join(",")}}`;
|
|
66
|
+
}
|
|
67
|
+
function canonicalPostVersionPayload(input) {
|
|
68
|
+
return stableStringify({
|
|
69
|
+
postId: input.postId,
|
|
70
|
+
versionId: input.versionId,
|
|
71
|
+
threadId: input.threadId,
|
|
72
|
+
author: input.author,
|
|
73
|
+
role: input.role,
|
|
74
|
+
parts: input.parts,
|
|
75
|
+
metadata: input.metadata ?? null,
|
|
76
|
+
mentions: input.mentions ?? null,
|
|
77
|
+
model: input.model ?? null,
|
|
78
|
+
usage: input.usage ?? null,
|
|
79
|
+
parentVersionId: input.parentVersionId ?? null,
|
|
80
|
+
previousPostVersionId: input.previousPostVersionId ?? null
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
function computeContentHash(payload) {
|
|
84
|
+
return sha256Hex(canonicalPostVersionPayload(payload));
|
|
85
|
+
}
|
|
86
|
+
function computeLineageHash(input) {
|
|
87
|
+
return sha256Hex(stableStringify({
|
|
88
|
+
previousLineageHash: input.previousLineageHash,
|
|
89
|
+
contentHash: input.contentHash,
|
|
90
|
+
postId: input.postId,
|
|
91
|
+
versionId: input.versionId
|
|
92
|
+
}));
|
|
93
|
+
}
|
|
94
|
+
function canonicalAclEventPayload(input) {
|
|
95
|
+
return stableStringify({
|
|
96
|
+
type: input.type,
|
|
97
|
+
targetType: input.targetType,
|
|
98
|
+
targetId: input.targetId,
|
|
99
|
+
scope: input.scope ?? null,
|
|
100
|
+
role: input.role ?? null,
|
|
101
|
+
actor: input.actor,
|
|
102
|
+
previousAclEventId: input.previousAclEventId ?? null
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
function computeAclEventContentHash(payload) {
|
|
106
|
+
return sha256Hex(canonicalAclEventPayload(payload));
|
|
107
|
+
}
|
|
108
|
+
function signedPayloadBytes(payload) {
|
|
109
|
+
return new TextEncoder().encode(payload);
|
|
110
|
+
}
|
|
111
|
+
function canonicalSignedPostVersionPayload(input) {
|
|
112
|
+
return stableStringify({
|
|
113
|
+
contentHash: input.contentHash,
|
|
114
|
+
lineageHash: input.lineageHash,
|
|
115
|
+
payload: JSON.parse(canonicalPostVersionPayload(input))
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
function canonicalSignedAclEventPayload(input) {
|
|
119
|
+
return stableStringify({
|
|
120
|
+
contentHash: input.contentHash,
|
|
121
|
+
payload: JSON.parse(canonicalAclEventPayload(input.payload))
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
async function maybeSignPayload(payload, signer, sign) {
|
|
125
|
+
return sign(signedPayloadBytes(payload), signer);
|
|
126
|
+
}
|
|
127
|
+
// src/ids.ts
|
|
128
|
+
function createId() {
|
|
129
|
+
return crypto.randomUUID();
|
|
130
|
+
}
|
|
131
|
+
function scopeKey(scope) {
|
|
132
|
+
return `${scope.type}:${scope.id}`;
|
|
133
|
+
}
|
|
134
|
+
function scopeRefFromKey(key) {
|
|
135
|
+
const separatorIndex = key.indexOf(":");
|
|
136
|
+
if (separatorIndex === -1) {
|
|
137
|
+
throw new Error(`Invalid scope key: ${key}`);
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
type: key.slice(0, separatorIndex),
|
|
141
|
+
id: key.slice(separatorIndex + 1)
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
// src/lineage.ts
|
|
145
|
+
function walkLineageFromHead(headVersionId, versionsById) {
|
|
146
|
+
const lineage = [];
|
|
147
|
+
let currentId = headVersionId;
|
|
148
|
+
const seenPostIds = new Set;
|
|
149
|
+
while (currentId) {
|
|
150
|
+
const version = versionsById.get(currentId);
|
|
151
|
+
if (!version)
|
|
152
|
+
break;
|
|
153
|
+
if (!seenPostIds.has(version.postId)) {
|
|
154
|
+
lineage.push(version);
|
|
155
|
+
seenPostIds.add(version.postId);
|
|
156
|
+
}
|
|
157
|
+
currentId = version.previousPostVersionId;
|
|
158
|
+
}
|
|
159
|
+
return lineage.reverse();
|
|
160
|
+
}
|
|
161
|
+
function lineageBetween(headVersionId, ancestorVersionId, versionsById) {
|
|
162
|
+
const lineage = walkLineageFromHead(headVersionId, versionsById);
|
|
163
|
+
const startIndex = lineage.findIndex((version) => version.id === ancestorVersionId);
|
|
164
|
+
if (startIndex === -1)
|
|
165
|
+
return null;
|
|
166
|
+
return lineage.slice(startIndex);
|
|
167
|
+
}
|
|
168
|
+
function postFromVersion(version, index, deletedAtMs) {
|
|
169
|
+
return {
|
|
170
|
+
...version,
|
|
171
|
+
id: version.postId,
|
|
172
|
+
status: "complete",
|
|
173
|
+
versionId: version.id,
|
|
174
|
+
previousVersionId: version.parentVersionId ?? null,
|
|
175
|
+
index,
|
|
176
|
+
updatedAtMs: version.parentVersionId ? version.createdAtMs : null,
|
|
177
|
+
deletedAtMs: deletedAtMs ?? null
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
// src/stream.ts
|
|
181
|
+
function rebuildStreamCacheFromEvents(events) {
|
|
182
|
+
const sorted = [...events].sort((a, b) => a.revision - b.revision);
|
|
183
|
+
let message = null;
|
|
184
|
+
let mentions;
|
|
185
|
+
let model;
|
|
186
|
+
let usage;
|
|
187
|
+
let revision = 0;
|
|
188
|
+
for (const event of sorted) {
|
|
189
|
+
if (event.eventType === "stream.started") {
|
|
190
|
+
if (!event.message) {
|
|
191
|
+
throw new Error(`stream.started event ${event.id} missing message`);
|
|
192
|
+
}
|
|
193
|
+
message = event.message;
|
|
194
|
+
mentions = event.mentions;
|
|
195
|
+
model = event.model;
|
|
196
|
+
usage = event.usage;
|
|
197
|
+
revision = event.revision;
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (event.eventType === "stream.delta") {
|
|
201
|
+
if (!event.message) {
|
|
202
|
+
throw new Error(`stream.delta event ${event.id} missing message`);
|
|
203
|
+
}
|
|
204
|
+
message = event.message;
|
|
205
|
+
if (event.mentions !== undefined)
|
|
206
|
+
mentions = event.mentions;
|
|
207
|
+
if (event.model !== undefined)
|
|
208
|
+
model = event.model;
|
|
209
|
+
if (event.usage !== undefined)
|
|
210
|
+
usage = event.usage;
|
|
211
|
+
revision = event.revision;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (event.eventType === "stream.completed" || event.eventType === "stream.aborted") {
|
|
215
|
+
revision = event.revision;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (!message) {
|
|
219
|
+
throw new Error("stream events did not produce a message");
|
|
220
|
+
}
|
|
221
|
+
return { message, mentions, model, usage, revision };
|
|
222
|
+
}
|
|
223
|
+
function streamingPostFromCache(input) {
|
|
224
|
+
return {
|
|
225
|
+
...input.message,
|
|
226
|
+
id: input.postId,
|
|
227
|
+
status: "streaming",
|
|
228
|
+
threadId: input.threadId,
|
|
229
|
+
author: input.author,
|
|
230
|
+
mentions: input.mentions,
|
|
231
|
+
model: input.model,
|
|
232
|
+
usage: input.usage,
|
|
233
|
+
index: input.index,
|
|
234
|
+
streamRevision: input.streamRevision,
|
|
235
|
+
createdAtMs: input.createdAtMs,
|
|
236
|
+
updatedAtMs: input.updatedAtMs ?? null,
|
|
237
|
+
deletedAtMs: input.deletedAtMs ?? null
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
function mergeThreadPostsForList(lineagePosts, activePosts) {
|
|
241
|
+
const byIndex = new Map;
|
|
242
|
+
for (const item of lineagePosts) {
|
|
243
|
+
byIndex.set(item.index, item.post);
|
|
244
|
+
}
|
|
245
|
+
for (const post of activePosts) {
|
|
246
|
+
if (post.status === "streaming" || post.status === "aborted") {
|
|
247
|
+
byIndex.set(post.index, post);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return [...byIndex.entries()].sort(([a], [b]) => a - b).map(([, post]) => post);
|
|
251
|
+
}
|
|
252
|
+
// src/validation.ts
|
|
253
|
+
function assertThreadRoot(root) {
|
|
254
|
+
if (root.type === "channel") {
|
|
255
|
+
if (!root.channelId.trim()) {
|
|
256
|
+
throw new ChatValidationError("channel root requires channelId");
|
|
257
|
+
}
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (!root.postId.trim()) {
|
|
261
|
+
throw new ChatValidationError("post root requires postId");
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
function assertNonEmptyString(value, label) {
|
|
265
|
+
if (!value.trim()) {
|
|
266
|
+
throw new ChatValidationError(`${label} is required`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
// src/persistence/core/persistence/base-persistence.ts
|
|
270
|
+
class BaseChatPersistence {
|
|
271
|
+
assertThreadRootExists(root) {
|
|
272
|
+
if (root.type === "channel" && !root.channelId.trim()) {
|
|
273
|
+
throw new ChatValidationError("channel root requires channelId");
|
|
274
|
+
}
|
|
275
|
+
if (root.type === "post" && !root.postId.trim()) {
|
|
276
|
+
throw new ChatValidationError("post root requires postId");
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
async requireChannel(id) {
|
|
280
|
+
const channel = await this.getChannel(id);
|
|
281
|
+
if (!channel)
|
|
282
|
+
throw new ChatNotFoundError("channel", id);
|
|
283
|
+
return channel;
|
|
284
|
+
}
|
|
285
|
+
async requireThread(id) {
|
|
286
|
+
const thread = await this.getThread(id);
|
|
287
|
+
if (!thread)
|
|
288
|
+
throw new ChatNotFoundError("thread", id);
|
|
289
|
+
return thread;
|
|
290
|
+
}
|
|
291
|
+
async requirePost(id) {
|
|
292
|
+
const post = await this.getPost(id);
|
|
293
|
+
if (!post)
|
|
294
|
+
throw new ChatNotFoundError("post", id);
|
|
295
|
+
return post;
|
|
296
|
+
}
|
|
297
|
+
validateCreateThreadInput(input) {
|
|
298
|
+
this.assertThreadRootExists(input.root);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
// src/persistence/core/persistence/helpers.ts
|
|
302
|
+
function prepareAppendPost(input, options = {}) {
|
|
303
|
+
const generate = options.generateId ?? createId;
|
|
304
|
+
const now = options.now ?? (() => Date.now());
|
|
305
|
+
const postId = input.message.id || generate();
|
|
306
|
+
const versionId = input.versionId ?? generate();
|
|
307
|
+
const createdAtMs = input.createdAtMs ?? now();
|
|
308
|
+
const message = { ...input.message, id: postId };
|
|
309
|
+
const contentHash = computeContentHash({
|
|
310
|
+
postId,
|
|
311
|
+
versionId,
|
|
312
|
+
threadId: input.threadId,
|
|
313
|
+
author: input.author,
|
|
314
|
+
role: message.role,
|
|
315
|
+
parts: message.parts,
|
|
316
|
+
metadata: message.metadata,
|
|
317
|
+
mentions: input.mentions,
|
|
318
|
+
model: input.model,
|
|
319
|
+
usage: input.usage,
|
|
320
|
+
parentVersionId: null,
|
|
321
|
+
previousPostVersionId: input.previousPostVersionId
|
|
322
|
+
});
|
|
323
|
+
const lineageHash = computeLineageHash({
|
|
324
|
+
previousLineageHash: input.previousLineageHash,
|
|
325
|
+
contentHash,
|
|
326
|
+
postId,
|
|
327
|
+
versionId
|
|
328
|
+
});
|
|
329
|
+
return {
|
|
330
|
+
postId,
|
|
331
|
+
versionId,
|
|
332
|
+
threadId: input.threadId,
|
|
333
|
+
author: input.author,
|
|
334
|
+
message,
|
|
335
|
+
mentions: input.mentions,
|
|
336
|
+
model: input.model,
|
|
337
|
+
usage: input.usage,
|
|
338
|
+
previousPostVersionId: input.previousPostVersionId,
|
|
339
|
+
previousLineageHash: input.previousLineageHash,
|
|
340
|
+
parentVersionId: null,
|
|
341
|
+
createdAtMs,
|
|
342
|
+
contentHash,
|
|
343
|
+
lineageHash
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
function prepareEditPost(input, options = {}) {
|
|
347
|
+
const generate = options.generateId ?? createId;
|
|
348
|
+
const now = options.now ?? (() => Date.now());
|
|
349
|
+
const postId = input.postId;
|
|
350
|
+
const versionId = generate();
|
|
351
|
+
const createdAtMs = now();
|
|
352
|
+
const message = { ...input.message, id: postId };
|
|
353
|
+
const contentHash = computeContentHash({
|
|
354
|
+
postId,
|
|
355
|
+
versionId,
|
|
356
|
+
threadId: input.threadId,
|
|
357
|
+
author: input.author,
|
|
358
|
+
role: message.role,
|
|
359
|
+
parts: message.parts,
|
|
360
|
+
metadata: message.metadata,
|
|
361
|
+
mentions: input.mentions,
|
|
362
|
+
model: input.model,
|
|
363
|
+
usage: input.usage,
|
|
364
|
+
parentVersionId: input.parentVersionId,
|
|
365
|
+
previousPostVersionId: input.previousPostVersionId
|
|
366
|
+
});
|
|
367
|
+
const lineageHash = computeLineageHash({
|
|
368
|
+
previousLineageHash: input.previousLineageHash,
|
|
369
|
+
contentHash,
|
|
370
|
+
postId,
|
|
371
|
+
versionId
|
|
372
|
+
});
|
|
373
|
+
return {
|
|
374
|
+
postId,
|
|
375
|
+
versionId,
|
|
376
|
+
threadId: input.threadId,
|
|
377
|
+
author: input.author,
|
|
378
|
+
message,
|
|
379
|
+
mentions: input.mentions,
|
|
380
|
+
model: input.model,
|
|
381
|
+
usage: input.usage,
|
|
382
|
+
previousPostVersionId: input.previousPostVersionId,
|
|
383
|
+
previousLineageHash: input.previousLineageHash,
|
|
384
|
+
parentVersionId: input.parentVersionId,
|
|
385
|
+
createdAtMs,
|
|
386
|
+
contentHash,
|
|
387
|
+
lineageHash
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
function buildAclEventContentHash(input) {
|
|
391
|
+
return computeAclEventContentHash(input);
|
|
392
|
+
}
|
|
393
|
+
// src/persistence/core/persistence/memory-persistence.ts
|
|
394
|
+
var DEFAULT_HEAD_NAME = "default";
|
|
395
|
+
|
|
396
|
+
class MemoryChatPersistence extends BaseChatPersistence {
|
|
397
|
+
channels = new Map;
|
|
398
|
+
threads = new Map;
|
|
399
|
+
posts = new Map;
|
|
400
|
+
versions = new Map;
|
|
401
|
+
streamEvents = [];
|
|
402
|
+
streamIdempotency = new Map;
|
|
403
|
+
heads = new Map;
|
|
404
|
+
channelMembers = new Map;
|
|
405
|
+
threadParticipants = new Map;
|
|
406
|
+
aclEvents = [];
|
|
407
|
+
idempotency = new Map;
|
|
408
|
+
threadIndexes = new Map;
|
|
409
|
+
async createChannel(input) {
|
|
410
|
+
const channel = {
|
|
411
|
+
id: input.id ?? createId(),
|
|
412
|
+
metadata: input.metadata,
|
|
413
|
+
createdAtMs: Date.now()
|
|
414
|
+
};
|
|
415
|
+
this.channels.set(channel.id, channel);
|
|
416
|
+
return channel;
|
|
417
|
+
}
|
|
418
|
+
async createThread(input) {
|
|
419
|
+
this.validateCreateThreadInput(input);
|
|
420
|
+
if (input.root.type === "channel") {
|
|
421
|
+
await this.requireChannel(input.root.channelId);
|
|
422
|
+
} else {
|
|
423
|
+
await this.requirePost(input.root.postId);
|
|
424
|
+
}
|
|
425
|
+
const thread = {
|
|
426
|
+
id: input.id ?? createId(),
|
|
427
|
+
root: input.root,
|
|
428
|
+
defaultHeadId: null,
|
|
429
|
+
metadata: input.metadata,
|
|
430
|
+
createdAtMs: Date.now()
|
|
431
|
+
};
|
|
432
|
+
this.threads.set(thread.id, thread);
|
|
433
|
+
this.threadIndexes.set(thread.id, 0);
|
|
434
|
+
return thread;
|
|
435
|
+
}
|
|
436
|
+
async getChannel(id) {
|
|
437
|
+
return this.channels.get(id) ?? null;
|
|
438
|
+
}
|
|
439
|
+
async getThread(id) {
|
|
440
|
+
return this.threads.get(id) ?? null;
|
|
441
|
+
}
|
|
442
|
+
postFromRecord(postId, record) {
|
|
443
|
+
if (record.status === "streaming") {
|
|
444
|
+
if (!record.message)
|
|
445
|
+
return null;
|
|
446
|
+
return streamingPostFromCache({
|
|
447
|
+
postId,
|
|
448
|
+
threadId: record.threadId,
|
|
449
|
+
author: record.author,
|
|
450
|
+
message: record.message,
|
|
451
|
+
mentions: record.mentions,
|
|
452
|
+
model: record.model,
|
|
453
|
+
usage: record.usage,
|
|
454
|
+
index: record.index,
|
|
455
|
+
streamRevision: record.streamRevision,
|
|
456
|
+
createdAtMs: record.createdAtMs,
|
|
457
|
+
updatedAtMs: record.updatedAtMs,
|
|
458
|
+
deletedAtMs: record.deletedAtMs
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
if (record.status === "aborted") {
|
|
462
|
+
if (!record.message)
|
|
463
|
+
return null;
|
|
464
|
+
return {
|
|
465
|
+
...record.message,
|
|
466
|
+
id: postId,
|
|
467
|
+
status: "aborted",
|
|
468
|
+
threadId: record.threadId,
|
|
469
|
+
author: record.author,
|
|
470
|
+
mentions: record.mentions,
|
|
471
|
+
model: record.model,
|
|
472
|
+
usage: record.usage,
|
|
473
|
+
index: record.index,
|
|
474
|
+
streamRevision: record.streamRevision,
|
|
475
|
+
createdAtMs: record.createdAtMs,
|
|
476
|
+
updatedAtMs: record.updatedAtMs,
|
|
477
|
+
deletedAtMs: record.deletedAtMs
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
const version = record.completedVersionId ? this.versions.get(record.completedVersionId) : [...this.versions.values()].filter((item) => item.postId === postId).sort((a, b) => b.createdAtMs - a.createdAtMs)[0];
|
|
481
|
+
if (!version)
|
|
482
|
+
return null;
|
|
483
|
+
return postFromVersion(version, record.index, record.deletedAtMs);
|
|
484
|
+
}
|
|
485
|
+
async getPost(id) {
|
|
486
|
+
const record = this.posts.get(id);
|
|
487
|
+
if (!record)
|
|
488
|
+
return null;
|
|
489
|
+
return this.postFromRecord(id, record);
|
|
490
|
+
}
|
|
491
|
+
async getPostVersion(id) {
|
|
492
|
+
return this.versions.get(id) ?? null;
|
|
493
|
+
}
|
|
494
|
+
async getThreadHead(threadId, headId) {
|
|
495
|
+
const thread = await this.requireThread(threadId);
|
|
496
|
+
const resolvedHeadId = headId ?? thread.defaultHeadId;
|
|
497
|
+
if (!resolvedHeadId)
|
|
498
|
+
return null;
|
|
499
|
+
const head = this.heads.get(resolvedHeadId);
|
|
500
|
+
return head?.threadId === threadId ? head : null;
|
|
501
|
+
}
|
|
502
|
+
async getThreadTip(threadId) {
|
|
503
|
+
const head = await this.getThreadHead(threadId);
|
|
504
|
+
if (!head)
|
|
505
|
+
return null;
|
|
506
|
+
const version = await this.getPostVersion(head.headPostVersionId);
|
|
507
|
+
if (!version)
|
|
508
|
+
return null;
|
|
509
|
+
return { id: version.id, lineageHash: version.lineageHash };
|
|
510
|
+
}
|
|
511
|
+
async listThreads(input) {
|
|
512
|
+
const limit = input.limit ?? 50;
|
|
513
|
+
let items = [...this.threads.values()];
|
|
514
|
+
if (input.channelId) {
|
|
515
|
+
items = items.filter((thread) => thread.root.type === "channel" && thread.root.channelId === input.channelId);
|
|
516
|
+
}
|
|
517
|
+
if (input.postId) {
|
|
518
|
+
items = items.filter((thread) => thread.root.type === "post" && thread.root.postId === input.postId);
|
|
519
|
+
}
|
|
520
|
+
if (input.participant) {
|
|
521
|
+
const key = scopeKey(input.participant);
|
|
522
|
+
items = items.filter((thread) => this.threadParticipants.get(thread.id)?.has(key));
|
|
523
|
+
}
|
|
524
|
+
items.sort((a, b) => a.createdAtMs - b.createdAtMs);
|
|
525
|
+
const start = input.cursor ? Number.parseInt(input.cursor, 10) : 0;
|
|
526
|
+
const page = items.slice(start, start + limit);
|
|
527
|
+
const nextCursor = start + limit < items.length ? String(start + limit) : null;
|
|
528
|
+
return { items: page, nextCursor };
|
|
529
|
+
}
|
|
530
|
+
async listPosts(input) {
|
|
531
|
+
const thread = await this.requireThread(input.threadId);
|
|
532
|
+
const head = input.headPostVersionId ? {
|
|
533
|
+
id: "inline",
|
|
534
|
+
threadId: input.threadId,
|
|
535
|
+
name: "inline",
|
|
536
|
+
headPostVersionId: input.headPostVersionId,
|
|
537
|
+
createdAtMs: 0
|
|
538
|
+
} : await this.getThreadHead(input.threadId, input.headId ?? thread.defaultHeadId ?? undefined);
|
|
539
|
+
const lineagePosts = head ? walkLineageFromHead(head.headPostVersionId, this.versions).flatMap((version) => {
|
|
540
|
+
const record = this.posts.get(version.postId);
|
|
541
|
+
if (!record)
|
|
542
|
+
return [];
|
|
543
|
+
const post = postFromVersion(version, record.index, record.deletedAtMs);
|
|
544
|
+
return [{ index: record.index, post }];
|
|
545
|
+
}) : [];
|
|
546
|
+
const activePosts = [...this.posts.entries()].filter(([, record]) => record.threadId === input.threadId && record.status !== "complete").map(([postId, record]) => this.postFromRecord(postId, record)).filter((post) => post !== null && (post.status === "streaming" || post.status === "aborted"));
|
|
547
|
+
const merged = mergeThreadPostsForList(lineagePosts, activePosts);
|
|
548
|
+
const limit = input.limit ?? 50;
|
|
549
|
+
const start = input.cursor ? Number.parseInt(input.cursor, 10) : 0;
|
|
550
|
+
const items = merged.slice(start, start + limit);
|
|
551
|
+
const nextCursor = start + limit < merged.length ? String(start + limit) : null;
|
|
552
|
+
return { items, nextCursor };
|
|
553
|
+
}
|
|
554
|
+
async appendPost(input) {
|
|
555
|
+
if (input.idempotencyKey) {
|
|
556
|
+
const existing = this.idempotency.get(input.idempotencyKey);
|
|
557
|
+
if (existing?.kind === "append") {
|
|
558
|
+
return { ok: true, post: existing.post, head: existing.head };
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
const thread = await this.requireThread(input.threadId);
|
|
562
|
+
const currentHead = thread.defaultHeadId ? this.heads.get(thread.defaultHeadId) : null;
|
|
563
|
+
if (input.expectedHeadPostVersionId !== undefined && (currentHead?.headPostVersionId ?? null) !== (input.expectedHeadPostVersionId ?? null)) {
|
|
564
|
+
if (!currentHead) {
|
|
565
|
+
throw new ChatConflictError("head_conflict", "expected head but thread is empty");
|
|
566
|
+
}
|
|
567
|
+
return { ok: false, reason: "head_conflict", currentHead };
|
|
568
|
+
}
|
|
569
|
+
const previousVersion = currentHead ? this.versions.get(currentHead.headPostVersionId) : null;
|
|
570
|
+
const prepared = prepareAppendPost({
|
|
571
|
+
...input,
|
|
572
|
+
previousPostVersionId: previousVersion?.id ?? null,
|
|
573
|
+
previousLineageHash: previousVersion?.lineageHash ?? null
|
|
574
|
+
});
|
|
575
|
+
const nextIndex = (this.threadIndexes.get(input.threadId) ?? 0) + 1;
|
|
576
|
+
this.threadIndexes.set(input.threadId, nextIndex);
|
|
577
|
+
const version = {
|
|
578
|
+
...prepared.message,
|
|
579
|
+
id: prepared.versionId,
|
|
580
|
+
postId: prepared.postId,
|
|
581
|
+
threadId: prepared.threadId,
|
|
582
|
+
parentVersionId: null,
|
|
583
|
+
previousPostVersionId: prepared.previousPostVersionId,
|
|
584
|
+
author: prepared.author,
|
|
585
|
+
contentHash: prepared.contentHash,
|
|
586
|
+
lineageHash: prepared.lineageHash,
|
|
587
|
+
mentions: prepared.mentions,
|
|
588
|
+
model: prepared.model,
|
|
589
|
+
usage: prepared.usage,
|
|
590
|
+
signature: input.signature,
|
|
591
|
+
createdAtMs: prepared.createdAtMs
|
|
592
|
+
};
|
|
593
|
+
this.posts.set(prepared.postId, {
|
|
594
|
+
threadId: input.threadId,
|
|
595
|
+
index: nextIndex,
|
|
596
|
+
status: "complete",
|
|
597
|
+
author: prepared.author,
|
|
598
|
+
streamRevision: 0,
|
|
599
|
+
completedVersionId: version.id,
|
|
600
|
+
createdAtMs: prepared.createdAtMs,
|
|
601
|
+
updatedAtMs: null,
|
|
602
|
+
deletedAtMs: null
|
|
603
|
+
});
|
|
604
|
+
this.versions.set(version.id, version);
|
|
605
|
+
const head = {
|
|
606
|
+
id: currentHead?.id ?? createId(),
|
|
607
|
+
threadId: input.threadId,
|
|
608
|
+
name: currentHead?.name ?? DEFAULT_HEAD_NAME,
|
|
609
|
+
headPostVersionId: version.id,
|
|
610
|
+
createdAtMs: currentHead?.createdAtMs ?? prepared.createdAtMs
|
|
611
|
+
};
|
|
612
|
+
this.heads.set(head.id, head);
|
|
613
|
+
this.threads.set(input.threadId, {
|
|
614
|
+
...thread,
|
|
615
|
+
defaultHeadId: head.id
|
|
616
|
+
});
|
|
617
|
+
const post = postFromVersion(version, nextIndex);
|
|
618
|
+
if (input.idempotencyKey) {
|
|
619
|
+
this.idempotency.set(input.idempotencyKey, {
|
|
620
|
+
kind: "append",
|
|
621
|
+
post,
|
|
622
|
+
head
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
return { ok: true, post, head };
|
|
626
|
+
}
|
|
627
|
+
async editPost(input) {
|
|
628
|
+
if (input.idempotencyKey) {
|
|
629
|
+
const existing = this.idempotency.get(input.idempotencyKey);
|
|
630
|
+
if (existing?.kind === "edit") {
|
|
631
|
+
return { ok: true, post: existing.post, head: existing.head };
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
const parentVersion = await this.getPostVersion(input.parentVersionId);
|
|
635
|
+
if (!parentVersion) {
|
|
636
|
+
throw new ChatNotFoundError("post_version", input.parentVersionId);
|
|
637
|
+
}
|
|
638
|
+
const thread = await this.requireThread(parentVersion.threadId);
|
|
639
|
+
const currentHead = thread.defaultHeadId ? this.heads.get(thread.defaultHeadId) : null;
|
|
640
|
+
if (input.expectedHeadPostVersionId !== undefined && (currentHead?.headPostVersionId ?? null) !== (input.expectedHeadPostVersionId ?? null)) {
|
|
641
|
+
if (!currentHead) {
|
|
642
|
+
throw new ChatConflictError("head_conflict", "expected head but thread is empty");
|
|
643
|
+
}
|
|
644
|
+
return { ok: false, reason: "head_conflict", currentHead };
|
|
645
|
+
}
|
|
646
|
+
const previousInChain = parentVersion.previousPostVersionId ? this.versions.get(parentVersion.previousPostVersionId) ?? null : null;
|
|
647
|
+
const prepared = prepareEditPost({
|
|
648
|
+
...input,
|
|
649
|
+
threadId: parentVersion.threadId,
|
|
650
|
+
previousPostVersionId: parentVersion.previousPostVersionId ?? null,
|
|
651
|
+
previousLineageHash: previousInChain?.lineageHash ?? null
|
|
652
|
+
});
|
|
653
|
+
const record = this.posts.get(input.postId);
|
|
654
|
+
if (!record)
|
|
655
|
+
throw new ChatNotFoundError("post", input.postId);
|
|
656
|
+
const version = {
|
|
657
|
+
...prepared.message,
|
|
658
|
+
id: prepared.versionId,
|
|
659
|
+
postId: prepared.postId,
|
|
660
|
+
threadId: prepared.threadId,
|
|
661
|
+
parentVersionId: prepared.parentVersionId,
|
|
662
|
+
previousPostVersionId: prepared.previousPostVersionId,
|
|
663
|
+
author: prepared.author,
|
|
664
|
+
contentHash: prepared.contentHash,
|
|
665
|
+
lineageHash: prepared.lineageHash,
|
|
666
|
+
mentions: prepared.mentions,
|
|
667
|
+
model: prepared.model,
|
|
668
|
+
usage: prepared.usage,
|
|
669
|
+
createdAtMs: prepared.createdAtMs
|
|
670
|
+
};
|
|
671
|
+
this.versions.set(version.id, version);
|
|
672
|
+
let head;
|
|
673
|
+
if (currentHead?.headPostVersionId === input.parentVersionId) {
|
|
674
|
+
head = {
|
|
675
|
+
...currentHead,
|
|
676
|
+
headPostVersionId: version.id
|
|
677
|
+
};
|
|
678
|
+
this.heads.set(head.id, head);
|
|
679
|
+
} else if (currentHead) {
|
|
680
|
+
head = currentHead;
|
|
681
|
+
} else {
|
|
682
|
+
head = {
|
|
683
|
+
id: createId(),
|
|
684
|
+
threadId: parentVersion.threadId,
|
|
685
|
+
name: DEFAULT_HEAD_NAME,
|
|
686
|
+
headPostVersionId: version.id,
|
|
687
|
+
createdAtMs: prepared.createdAtMs
|
|
688
|
+
};
|
|
689
|
+
this.heads.set(head.id, head);
|
|
690
|
+
this.threads.set(parentVersion.threadId, {
|
|
691
|
+
...thread,
|
|
692
|
+
defaultHeadId: head.id
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
const post = postFromVersion(version, record.index, record.deletedAtMs);
|
|
696
|
+
if (input.idempotencyKey) {
|
|
697
|
+
this.idempotency.set(input.idempotencyKey, {
|
|
698
|
+
kind: "edit",
|
|
699
|
+
post,
|
|
700
|
+
head
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
return { ok: true, post, head };
|
|
704
|
+
}
|
|
705
|
+
async deletePost(input) {
|
|
706
|
+
const post = await this.requirePost(input.postId);
|
|
707
|
+
const record = this.posts.get(input.postId);
|
|
708
|
+
if (!record)
|
|
709
|
+
throw new ChatNotFoundError("post", input.postId);
|
|
710
|
+
const deletedAtMs = input.deletedAtMs ?? Date.now();
|
|
711
|
+
this.posts.set(input.postId, { ...record, deletedAtMs });
|
|
712
|
+
return { ...post, deletedAtMs };
|
|
713
|
+
}
|
|
714
|
+
async addChannelMember(input) {
|
|
715
|
+
await this.requireChannel(input.channelId);
|
|
716
|
+
const members = this.channelMembers.get(input.channelId) ?? new Map;
|
|
717
|
+
members.set(scopeKey(input.scope), {
|
|
718
|
+
role: input.role,
|
|
719
|
+
createdAtMs: Date.now()
|
|
720
|
+
});
|
|
721
|
+
this.channelMembers.set(input.channelId, members);
|
|
722
|
+
const previousAclEventId = this.aclEvents.length > 0 ? this.aclEvents[this.aclEvents.length - 1]?.id : null;
|
|
723
|
+
const event = {
|
|
724
|
+
id: createId(),
|
|
725
|
+
type: "channel.member.added",
|
|
726
|
+
channelId: input.channelId,
|
|
727
|
+
scope: input.scope,
|
|
728
|
+
role: input.role,
|
|
729
|
+
actor: input.actor,
|
|
730
|
+
previousAclEventId,
|
|
731
|
+
contentHash: buildAclEventContentHash({
|
|
732
|
+
type: "channel.member.added",
|
|
733
|
+
targetType: "channel",
|
|
734
|
+
targetId: input.channelId,
|
|
735
|
+
scope: input.scope,
|
|
736
|
+
role: input.role,
|
|
737
|
+
actor: input.actor,
|
|
738
|
+
previousAclEventId
|
|
739
|
+
}),
|
|
740
|
+
signature: input.signature,
|
|
741
|
+
createdAtMs: Date.now()
|
|
742
|
+
};
|
|
743
|
+
this.aclEvents.push(event);
|
|
744
|
+
return event;
|
|
745
|
+
}
|
|
746
|
+
async removeChannelMember(input) {
|
|
747
|
+
await this.requireChannel(input.channelId);
|
|
748
|
+
const members = this.channelMembers.get(input.channelId);
|
|
749
|
+
members?.delete(scopeKey(input.scope));
|
|
750
|
+
const previousAclEventId = this.aclEvents.length > 0 ? this.aclEvents[this.aclEvents.length - 1]?.id : null;
|
|
751
|
+
const event = {
|
|
752
|
+
id: createId(),
|
|
753
|
+
type: "channel.member.removed",
|
|
754
|
+
channelId: input.channelId,
|
|
755
|
+
scope: input.scope,
|
|
756
|
+
actor: input.actor,
|
|
757
|
+
previousAclEventId,
|
|
758
|
+
contentHash: buildAclEventContentHash({
|
|
759
|
+
type: "channel.member.removed",
|
|
760
|
+
targetType: "channel",
|
|
761
|
+
targetId: input.channelId,
|
|
762
|
+
scope: input.scope,
|
|
763
|
+
actor: input.actor,
|
|
764
|
+
previousAclEventId
|
|
765
|
+
}),
|
|
766
|
+
signature: input.signature,
|
|
767
|
+
createdAtMs: Date.now()
|
|
768
|
+
};
|
|
769
|
+
this.aclEvents.push(event);
|
|
770
|
+
return event;
|
|
771
|
+
}
|
|
772
|
+
async addThreadParticipant(input) {
|
|
773
|
+
await this.requireThread(input.threadId);
|
|
774
|
+
const participants = this.threadParticipants.get(input.threadId) ?? new Map;
|
|
775
|
+
participants.set(scopeKey(input.scope), {
|
|
776
|
+
role: input.role,
|
|
777
|
+
createdAtMs: Date.now()
|
|
778
|
+
});
|
|
779
|
+
this.threadParticipants.set(input.threadId, participants);
|
|
780
|
+
const previousAclEventId = this.aclEvents.length > 0 ? this.aclEvents[this.aclEvents.length - 1]?.id : null;
|
|
781
|
+
const event = {
|
|
782
|
+
id: createId(),
|
|
783
|
+
type: "thread.participant.added",
|
|
784
|
+
threadId: input.threadId,
|
|
785
|
+
scope: input.scope,
|
|
786
|
+
role: input.role,
|
|
787
|
+
actor: input.actor,
|
|
788
|
+
previousAclEventId,
|
|
789
|
+
contentHash: buildAclEventContentHash({
|
|
790
|
+
type: "thread.participant.added",
|
|
791
|
+
targetType: "thread",
|
|
792
|
+
targetId: input.threadId,
|
|
793
|
+
scope: input.scope,
|
|
794
|
+
role: input.role,
|
|
795
|
+
actor: input.actor,
|
|
796
|
+
previousAclEventId
|
|
797
|
+
}),
|
|
798
|
+
signature: input.signature,
|
|
799
|
+
createdAtMs: Date.now()
|
|
800
|
+
};
|
|
801
|
+
this.aclEvents.push(event);
|
|
802
|
+
return event;
|
|
803
|
+
}
|
|
804
|
+
async removeThreadParticipant(input) {
|
|
805
|
+
await this.requireThread(input.threadId);
|
|
806
|
+
const participants = this.threadParticipants.get(input.threadId);
|
|
807
|
+
participants?.delete(scopeKey(input.scope));
|
|
808
|
+
const previousAclEventId = this.aclEvents.length > 0 ? this.aclEvents[this.aclEvents.length - 1]?.id : null;
|
|
809
|
+
const event = {
|
|
810
|
+
id: createId(),
|
|
811
|
+
type: "thread.participant.removed",
|
|
812
|
+
threadId: input.threadId,
|
|
813
|
+
scope: input.scope,
|
|
814
|
+
actor: input.actor,
|
|
815
|
+
previousAclEventId,
|
|
816
|
+
contentHash: buildAclEventContentHash({
|
|
817
|
+
type: "thread.participant.removed",
|
|
818
|
+
targetType: "thread",
|
|
819
|
+
targetId: input.threadId,
|
|
820
|
+
scope: input.scope,
|
|
821
|
+
actor: input.actor,
|
|
822
|
+
previousAclEventId
|
|
823
|
+
}),
|
|
824
|
+
signature: input.signature,
|
|
825
|
+
createdAtMs: Date.now()
|
|
826
|
+
};
|
|
827
|
+
this.aclEvents.push(event);
|
|
828
|
+
return event;
|
|
829
|
+
}
|
|
830
|
+
async createThreadHead(input) {
|
|
831
|
+
await this.requireThread(input.threadId);
|
|
832
|
+
const version = await this.getPostVersion(input.headPostVersionId);
|
|
833
|
+
if (!version) {
|
|
834
|
+
throw new ChatNotFoundError("post_version", input.headPostVersionId);
|
|
835
|
+
}
|
|
836
|
+
const head = {
|
|
837
|
+
id: createId(),
|
|
838
|
+
threadId: input.threadId,
|
|
839
|
+
name: input.name,
|
|
840
|
+
headPostVersionId: input.headPostVersionId,
|
|
841
|
+
createdAtMs: Date.now()
|
|
842
|
+
};
|
|
843
|
+
this.heads.set(head.id, head);
|
|
844
|
+
return head;
|
|
845
|
+
}
|
|
846
|
+
async listChannelMembers(channelId) {
|
|
847
|
+
const members = this.channelMembers.get(channelId);
|
|
848
|
+
if (!members)
|
|
849
|
+
return [];
|
|
850
|
+
return [...members.keys()].map(scopeRefFromKey);
|
|
851
|
+
}
|
|
852
|
+
async listThreadParticipants(threadId) {
|
|
853
|
+
const participants = this.threadParticipants.get(threadId);
|
|
854
|
+
if (!participants)
|
|
855
|
+
return [];
|
|
856
|
+
return [...participants.keys()].map(scopeRefFromKey);
|
|
857
|
+
}
|
|
858
|
+
async listAclEvents(input) {
|
|
859
|
+
let events2 = [...this.aclEvents];
|
|
860
|
+
if (input.channelId) {
|
|
861
|
+
events2 = events2.filter((event) => event.type.startsWith("channel.") && ("channelId" in event) && event.channelId === input.channelId);
|
|
862
|
+
}
|
|
863
|
+
if (input.threadId) {
|
|
864
|
+
events2 = events2.filter((event) => event.type.startsWith("thread.") && ("threadId" in event) && event.threadId === input.threadId);
|
|
865
|
+
}
|
|
866
|
+
return events2.slice(0, input.limit ?? events2.length);
|
|
867
|
+
}
|
|
868
|
+
async listPostStreamEvents(postId) {
|
|
869
|
+
return this.streamEvents.filter((event) => event.postId === postId).sort((a, b) => a.revision - b.revision);
|
|
870
|
+
}
|
|
871
|
+
async startStreamedPost(input) {
|
|
872
|
+
if (input.idempotencyKey) {
|
|
873
|
+
const existing = this.streamIdempotency.get(input.idempotencyKey);
|
|
874
|
+
if (existing) {
|
|
875
|
+
const post2 = await this.getPost(existing.postId);
|
|
876
|
+
if (post2?.status !== "streaming") {
|
|
877
|
+
throw new ChatConflictError("idempotency_mismatch", "idempotency key reused");
|
|
878
|
+
}
|
|
879
|
+
return { post: post2, revision: existing.revision };
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
await this.requireThread(input.threadId);
|
|
883
|
+
const postId = input.message.id || createId();
|
|
884
|
+
const nextIndex = (this.threadIndexes.get(input.threadId) ?? 0) + 1;
|
|
885
|
+
this.threadIndexes.set(input.threadId, nextIndex);
|
|
886
|
+
const createdAtMs = Date.now();
|
|
887
|
+
const message = { ...input.message, id: postId };
|
|
888
|
+
const revision = 1;
|
|
889
|
+
const event = {
|
|
890
|
+
id: createId(),
|
|
891
|
+
postId,
|
|
892
|
+
threadId: input.threadId,
|
|
893
|
+
eventType: "stream.started",
|
|
894
|
+
revision,
|
|
895
|
+
message,
|
|
896
|
+
mentions: input.mentions,
|
|
897
|
+
model: input.model,
|
|
898
|
+
usage: input.usage,
|
|
899
|
+
idempotencyKey: input.idempotencyKey,
|
|
900
|
+
createdAtMs
|
|
901
|
+
};
|
|
902
|
+
this.streamEvents.push(event);
|
|
903
|
+
this.posts.set(postId, {
|
|
904
|
+
threadId: input.threadId,
|
|
905
|
+
index: nextIndex,
|
|
906
|
+
status: "streaming",
|
|
907
|
+
author: input.author,
|
|
908
|
+
message,
|
|
909
|
+
mentions: input.mentions,
|
|
910
|
+
model: input.model,
|
|
911
|
+
usage: input.usage,
|
|
912
|
+
streamRevision: revision,
|
|
913
|
+
createdAtMs,
|
|
914
|
+
updatedAtMs: null,
|
|
915
|
+
deletedAtMs: null
|
|
916
|
+
});
|
|
917
|
+
if (input.idempotencyKey) {
|
|
918
|
+
this.streamIdempotency.set(input.idempotencyKey, {
|
|
919
|
+
postId,
|
|
920
|
+
revision,
|
|
921
|
+
eventId: event.id
|
|
922
|
+
});
|
|
923
|
+
}
|
|
924
|
+
const post = streamingPostFromCache({
|
|
925
|
+
postId,
|
|
926
|
+
threadId: input.threadId,
|
|
927
|
+
author: input.author,
|
|
928
|
+
message,
|
|
929
|
+
mentions: input.mentions,
|
|
930
|
+
model: input.model,
|
|
931
|
+
usage: input.usage,
|
|
932
|
+
index: nextIndex,
|
|
933
|
+
streamRevision: revision,
|
|
934
|
+
createdAtMs
|
|
935
|
+
});
|
|
936
|
+
return { post, revision };
|
|
937
|
+
}
|
|
938
|
+
async applyPostDelta(input) {
|
|
939
|
+
if (input.idempotencyKey) {
|
|
940
|
+
const existing = this.streamIdempotency.get(input.idempotencyKey);
|
|
941
|
+
if (existing && existing.postId === input.postId) {
|
|
942
|
+
const post2 = await this.getPost(input.postId);
|
|
943
|
+
if (post2?.status !== "streaming") {
|
|
944
|
+
throw new ChatConflictError("idempotency_mismatch", "idempotency key reused");
|
|
945
|
+
}
|
|
946
|
+
return { post: post2, revision: existing.revision };
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
const record = this.posts.get(input.postId);
|
|
950
|
+
if (!record)
|
|
951
|
+
throw new ChatNotFoundError("post", input.postId);
|
|
952
|
+
if (record.status !== "streaming") {
|
|
953
|
+
throw new ChatValidationError(`post ${input.postId} is not streaming`);
|
|
954
|
+
}
|
|
955
|
+
if (input.expectedRevision !== undefined && record.streamRevision !== input.expectedRevision) {
|
|
956
|
+
throw new ChatConflictError("stream_revision_conflict", `expected revision ${input.expectedRevision}, current ${record.streamRevision}`);
|
|
957
|
+
}
|
|
958
|
+
const revision = record.streamRevision + 1;
|
|
959
|
+
const updatedAtMs = Date.now();
|
|
960
|
+
const message = { ...input.message, id: input.postId };
|
|
961
|
+
const event = {
|
|
962
|
+
id: createId(),
|
|
963
|
+
postId: input.postId,
|
|
964
|
+
threadId: record.threadId,
|
|
965
|
+
eventType: "stream.delta",
|
|
966
|
+
revision,
|
|
967
|
+
message,
|
|
968
|
+
delta: input.delta,
|
|
969
|
+
mentions: input.mentions ?? record.mentions,
|
|
970
|
+
model: input.model ?? record.model,
|
|
971
|
+
usage: input.usage ?? record.usage,
|
|
972
|
+
idempotencyKey: input.idempotencyKey,
|
|
973
|
+
createdAtMs: updatedAtMs
|
|
974
|
+
};
|
|
975
|
+
this.streamEvents.push(event);
|
|
976
|
+
this.posts.set(input.postId, {
|
|
977
|
+
...record,
|
|
978
|
+
message,
|
|
979
|
+
mentions: input.mentions ?? record.mentions,
|
|
980
|
+
model: input.model ?? record.model,
|
|
981
|
+
usage: input.usage ?? record.usage,
|
|
982
|
+
streamRevision: revision,
|
|
983
|
+
updatedAtMs
|
|
984
|
+
});
|
|
985
|
+
if (input.idempotencyKey) {
|
|
986
|
+
this.streamIdempotency.set(input.idempotencyKey, {
|
|
987
|
+
postId: input.postId,
|
|
988
|
+
revision,
|
|
989
|
+
eventId: event.id
|
|
990
|
+
});
|
|
991
|
+
}
|
|
992
|
+
const post = streamingPostFromCache({
|
|
993
|
+
postId: input.postId,
|
|
994
|
+
threadId: record.threadId,
|
|
995
|
+
author: record.author,
|
|
996
|
+
message,
|
|
997
|
+
mentions: input.mentions ?? record.mentions,
|
|
998
|
+
model: input.model ?? record.model,
|
|
999
|
+
usage: input.usage ?? record.usage,
|
|
1000
|
+
index: record.index,
|
|
1001
|
+
streamRevision: revision,
|
|
1002
|
+
createdAtMs: record.createdAtMs,
|
|
1003
|
+
updatedAtMs,
|
|
1004
|
+
deletedAtMs: record.deletedAtMs
|
|
1005
|
+
});
|
|
1006
|
+
return { post, revision };
|
|
1007
|
+
}
|
|
1008
|
+
async completeStreamedPost(input) {
|
|
1009
|
+
const record = this.posts.get(input.postId);
|
|
1010
|
+
if (!record)
|
|
1011
|
+
throw new ChatNotFoundError("post", input.postId);
|
|
1012
|
+
if (record.status !== "streaming") {
|
|
1013
|
+
throw new ChatValidationError(`post ${input.postId} is not streaming`);
|
|
1014
|
+
}
|
|
1015
|
+
if (input.expectedRevision !== undefined && record.streamRevision !== input.expectedRevision) {
|
|
1016
|
+
throw new ChatConflictError("stream_revision_conflict", `expected revision ${input.expectedRevision}, current ${record.streamRevision}`);
|
|
1017
|
+
}
|
|
1018
|
+
if (!record.message) {
|
|
1019
|
+
throw new ChatValidationError(`post ${input.postId} has no streamed message`);
|
|
1020
|
+
}
|
|
1021
|
+
const thread = await this.requireThread(record.threadId);
|
|
1022
|
+
const currentHead = thread.defaultHeadId ? this.heads.get(thread.defaultHeadId) : null;
|
|
1023
|
+
if (input.expectedHeadPostVersionId !== undefined && (currentHead?.headPostVersionId ?? null) !== (input.expectedHeadPostVersionId ?? null)) {
|
|
1024
|
+
if (!currentHead) {
|
|
1025
|
+
throw new ChatConflictError("head_conflict", "expected head but thread is empty");
|
|
1026
|
+
}
|
|
1027
|
+
return { ok: false, reason: "head_conflict", currentHead };
|
|
1028
|
+
}
|
|
1029
|
+
const previousVersion = currentHead ? this.versions.get(currentHead.headPostVersionId) : null;
|
|
1030
|
+
const prepared = prepareAppendPost({
|
|
1031
|
+
threadId: record.threadId,
|
|
1032
|
+
author: record.author,
|
|
1033
|
+
message: record.message,
|
|
1034
|
+
mentions: record.mentions,
|
|
1035
|
+
model: record.model,
|
|
1036
|
+
usage: record.usage,
|
|
1037
|
+
previousPostVersionId: previousVersion?.id ?? null,
|
|
1038
|
+
previousLineageHash: previousVersion?.lineageHash ?? null
|
|
1039
|
+
});
|
|
1040
|
+
const version = {
|
|
1041
|
+
...prepared.message,
|
|
1042
|
+
id: prepared.versionId,
|
|
1043
|
+
postId: prepared.postId,
|
|
1044
|
+
threadId: prepared.threadId,
|
|
1045
|
+
parentVersionId: null,
|
|
1046
|
+
previousPostVersionId: prepared.previousPostVersionId,
|
|
1047
|
+
author: prepared.author,
|
|
1048
|
+
contentHash: prepared.contentHash,
|
|
1049
|
+
lineageHash: prepared.lineageHash,
|
|
1050
|
+
mentions: prepared.mentions,
|
|
1051
|
+
model: prepared.model,
|
|
1052
|
+
usage: prepared.usage,
|
|
1053
|
+
createdAtMs: prepared.createdAtMs
|
|
1054
|
+
};
|
|
1055
|
+
this.versions.set(version.id, version);
|
|
1056
|
+
const head = {
|
|
1057
|
+
id: currentHead?.id ?? createId(),
|
|
1058
|
+
threadId: record.threadId,
|
|
1059
|
+
name: currentHead?.name ?? DEFAULT_HEAD_NAME,
|
|
1060
|
+
headPostVersionId: version.id,
|
|
1061
|
+
createdAtMs: currentHead?.createdAtMs ?? prepared.createdAtMs
|
|
1062
|
+
};
|
|
1063
|
+
this.heads.set(head.id, head);
|
|
1064
|
+
this.threads.set(record.threadId, { ...thread, defaultHeadId: head.id });
|
|
1065
|
+
const completedRevision = record.streamRevision + 1;
|
|
1066
|
+
const completedAtMs = Date.now();
|
|
1067
|
+
this.streamEvents.push({
|
|
1068
|
+
id: createId(),
|
|
1069
|
+
postId: input.postId,
|
|
1070
|
+
threadId: record.threadId,
|
|
1071
|
+
eventType: "stream.completed",
|
|
1072
|
+
revision: completedRevision,
|
|
1073
|
+
message: record.message,
|
|
1074
|
+
mentions: record.mentions,
|
|
1075
|
+
model: record.model,
|
|
1076
|
+
usage: record.usage,
|
|
1077
|
+
createdAtMs: completedAtMs
|
|
1078
|
+
});
|
|
1079
|
+
this.posts.set(input.postId, {
|
|
1080
|
+
...record,
|
|
1081
|
+
status: "complete",
|
|
1082
|
+
streamRevision: completedRevision,
|
|
1083
|
+
completedVersionId: version.id,
|
|
1084
|
+
updatedAtMs: completedAtMs,
|
|
1085
|
+
message: undefined,
|
|
1086
|
+
mentions: undefined
|
|
1087
|
+
});
|
|
1088
|
+
const post = postFromVersion(version, record.index, record.deletedAtMs);
|
|
1089
|
+
return { ok: true, post, head };
|
|
1090
|
+
}
|
|
1091
|
+
async abortStreamedPost(input) {
|
|
1092
|
+
const record = this.posts.get(input.postId);
|
|
1093
|
+
if (!record)
|
|
1094
|
+
throw new ChatNotFoundError("post", input.postId);
|
|
1095
|
+
if (record.status !== "streaming") {
|
|
1096
|
+
throw new ChatValidationError(`post ${input.postId} is not streaming`);
|
|
1097
|
+
}
|
|
1098
|
+
const deletedAtMs = input.deletedAtMs ?? Date.now();
|
|
1099
|
+
const revision = record.streamRevision + 1;
|
|
1100
|
+
this.streamEvents.push({
|
|
1101
|
+
id: createId(),
|
|
1102
|
+
postId: input.postId,
|
|
1103
|
+
threadId: record.threadId,
|
|
1104
|
+
eventType: "stream.aborted",
|
|
1105
|
+
revision,
|
|
1106
|
+
message: record.message,
|
|
1107
|
+
mentions: record.mentions,
|
|
1108
|
+
model: record.model,
|
|
1109
|
+
usage: record.usage,
|
|
1110
|
+
createdAtMs: deletedAtMs
|
|
1111
|
+
});
|
|
1112
|
+
this.posts.set(input.postId, {
|
|
1113
|
+
...record,
|
|
1114
|
+
status: "aborted",
|
|
1115
|
+
streamRevision: revision,
|
|
1116
|
+
deletedAtMs,
|
|
1117
|
+
updatedAtMs: deletedAtMs
|
|
1118
|
+
});
|
|
1119
|
+
const post = await this.getPost(input.postId);
|
|
1120
|
+
if (post?.status !== "aborted") {
|
|
1121
|
+
throw new ChatNotFoundError("post", input.postId);
|
|
1122
|
+
}
|
|
1123
|
+
return { post };
|
|
1124
|
+
}
|
|
1125
|
+
async rebuildStreamedPostCache(postId) {
|
|
1126
|
+
const record = this.posts.get(postId);
|
|
1127
|
+
if (!record)
|
|
1128
|
+
throw new ChatNotFoundError("post", postId);
|
|
1129
|
+
if (record.status !== "streaming") {
|
|
1130
|
+
throw new ChatValidationError(`post ${postId} is not streaming`);
|
|
1131
|
+
}
|
|
1132
|
+
const events2 = await this.listPostStreamEvents(postId);
|
|
1133
|
+
const rebuilt = rebuildStreamCacheFromEvents(events2);
|
|
1134
|
+
this.posts.set(postId, {
|
|
1135
|
+
...record,
|
|
1136
|
+
message: rebuilt.message,
|
|
1137
|
+
mentions: rebuilt.mentions,
|
|
1138
|
+
streamRevision: rebuilt.revision,
|
|
1139
|
+
updatedAtMs: Date.now()
|
|
1140
|
+
});
|
|
1141
|
+
return streamingPostFromCache({
|
|
1142
|
+
postId,
|
|
1143
|
+
threadId: record.threadId,
|
|
1144
|
+
author: record.author,
|
|
1145
|
+
message: rebuilt.message,
|
|
1146
|
+
mentions: rebuilt.mentions,
|
|
1147
|
+
index: record.index,
|
|
1148
|
+
streamRevision: rebuilt.revision,
|
|
1149
|
+
createdAtMs: record.createdAtMs,
|
|
1150
|
+
updatedAtMs: Date.now(),
|
|
1151
|
+
deletedAtMs: record.deletedAtMs
|
|
1152
|
+
});
|
|
1153
|
+
}
|
|
1154
|
+
async setPostVersionSignature(versionId, signature) {
|
|
1155
|
+
const version = this.versions.get(versionId);
|
|
1156
|
+
if (!version)
|
|
1157
|
+
throw new ChatNotFoundError("post_version", versionId);
|
|
1158
|
+
this.versions.set(versionId, { ...version, signature });
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
function createMemoryChatPersistence() {
|
|
1162
|
+
return new MemoryChatPersistence;
|
|
1163
|
+
}
|
|
1164
|
+
// src/persistence/core/persistence/signed-persistence.ts
|
|
1165
|
+
function scopesEqual(a, b) {
|
|
1166
|
+
return a.type === b.type && a.id === b.id;
|
|
1167
|
+
}
|
|
1168
|
+
function preparedToSignable(prepared) {
|
|
1169
|
+
return {
|
|
1170
|
+
postId: prepared.postId,
|
|
1171
|
+
versionId: prepared.versionId,
|
|
1172
|
+
threadId: prepared.threadId,
|
|
1173
|
+
author: prepared.author,
|
|
1174
|
+
role: prepared.message.role,
|
|
1175
|
+
parts: prepared.message.parts,
|
|
1176
|
+
metadata: prepared.message.metadata,
|
|
1177
|
+
mentions: prepared.mentions,
|
|
1178
|
+
model: prepared.model,
|
|
1179
|
+
usage: prepared.usage,
|
|
1180
|
+
parentVersionId: prepared.parentVersionId ?? null,
|
|
1181
|
+
previousPostVersionId: prepared.previousPostVersionId,
|
|
1182
|
+
contentHash: prepared.contentHash,
|
|
1183
|
+
lineageHash: prepared.lineageHash
|
|
1184
|
+
};
|
|
1185
|
+
}
|
|
1186
|
+
async function prepareAppendForSigning(persistence, input) {
|
|
1187
|
+
const tip = await persistence.getThreadTip(input.threadId);
|
|
1188
|
+
return prepareAppendPost({
|
|
1189
|
+
...input,
|
|
1190
|
+
previousPostVersionId: tip?.id ?? null,
|
|
1191
|
+
previousLineageHash: tip?.lineageHash ?? null
|
|
1192
|
+
});
|
|
1193
|
+
}
|
|
1194
|
+
async function signPreparedAppendPost(signer, author, prepared) {
|
|
1195
|
+
const payload = canonicalSignedPostVersionPayload(preparedToSignable(prepared));
|
|
1196
|
+
return signer.sign(signedPayloadBytes(payload), author);
|
|
1197
|
+
}
|
|
1198
|
+
async function assertValidAppendSignature(prepared, author, envelope, verifier) {
|
|
1199
|
+
if (!scopesEqual(envelope.signer, author)) {
|
|
1200
|
+
throw new ChatValidationError("appendPost signature signer must match author");
|
|
1201
|
+
}
|
|
1202
|
+
const payload = canonicalSignedPostVersionPayload(preparedToSignable(prepared));
|
|
1203
|
+
const ok = await verifier.verify(signedPayloadBytes(payload), envelope);
|
|
1204
|
+
if (!ok) {
|
|
1205
|
+
throw new ChatValidationError("invalid post signature");
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
function signableFromCommittedPost(post) {
|
|
1209
|
+
return {
|
|
1210
|
+
postId: post.id,
|
|
1211
|
+
versionId: post.versionId,
|
|
1212
|
+
threadId: post.threadId,
|
|
1213
|
+
author: post.author,
|
|
1214
|
+
role: post.role,
|
|
1215
|
+
parts: post.parts,
|
|
1216
|
+
metadata: post.metadata,
|
|
1217
|
+
mentions: post.mentions,
|
|
1218
|
+
model: post.model,
|
|
1219
|
+
usage: post.usage,
|
|
1220
|
+
parentVersionId: post.previousVersionId ?? null,
|
|
1221
|
+
previousPostVersionId: post.previousPostVersionId ?? null,
|
|
1222
|
+
contentHash: post.contentHash,
|
|
1223
|
+
lineageHash: post.lineageHash
|
|
1224
|
+
};
|
|
1225
|
+
}
|
|
1226
|
+
function withSignedChatPersistence(persistence, options) {
|
|
1227
|
+
return new Proxy(persistence, {
|
|
1228
|
+
get(target, prop, receiver) {
|
|
1229
|
+
if (prop === "appendPost") {
|
|
1230
|
+
return async (input) => {
|
|
1231
|
+
if (!input.signature) {
|
|
1232
|
+
throw new ChatValidationError("appendPost requires a signed envelope");
|
|
1233
|
+
}
|
|
1234
|
+
const prepared = await prepareAppendForSigning(target, input);
|
|
1235
|
+
await assertValidAppendSignature(prepared, input.author, input.signature, options.verifier);
|
|
1236
|
+
return target.appendPost({
|
|
1237
|
+
...input,
|
|
1238
|
+
message: prepared.message,
|
|
1239
|
+
versionId: prepared.versionId,
|
|
1240
|
+
createdAtMs: prepared.createdAtMs,
|
|
1241
|
+
signature: input.signature
|
|
1242
|
+
});
|
|
1243
|
+
};
|
|
1244
|
+
}
|
|
1245
|
+
if (prop === "completeStreamedPost") {
|
|
1246
|
+
return async (input) => {
|
|
1247
|
+
const result = await target.completeStreamedPost(input);
|
|
1248
|
+
if (!result.ok)
|
|
1249
|
+
return result;
|
|
1250
|
+
const signable = signableFromCommittedPost(result.post);
|
|
1251
|
+
const payload = canonicalSignedPostVersionPayload(signable);
|
|
1252
|
+
const envelope = await options.signer.sign(signedPayloadBytes(payload), result.post.author);
|
|
1253
|
+
await target.setPostVersionSignature(result.post.versionId, envelope);
|
|
1254
|
+
return {
|
|
1255
|
+
ok: true,
|
|
1256
|
+
post: { ...result.post, signature: envelope },
|
|
1257
|
+
head: result.head
|
|
1258
|
+
};
|
|
1259
|
+
};
|
|
1260
|
+
}
|
|
1261
|
+
const value = Reflect.get(target, prop, receiver);
|
|
1262
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
1263
|
+
}
|
|
1264
|
+
});
|
|
1265
|
+
}
|
|
1266
|
+
export {
|
|
1267
|
+
withSignedChatPersistence,
|
|
1268
|
+
signPreparedAppendPost,
|
|
1269
|
+
prepareEditPost,
|
|
1270
|
+
prepareAppendPost,
|
|
1271
|
+
prepareAppendForSigning,
|
|
1272
|
+
createMemoryChatPersistence,
|
|
1273
|
+
buildAclEventContentHash,
|
|
1274
|
+
MemoryChatPersistence,
|
|
1275
|
+
BaseChatPersistence
|
|
1276
|
+
};
|