@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,689 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
3
|
+
|
|
4
|
+
// src/persistence/testing/persistence/contract.ts
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
|
|
7
|
+
// src/errors.ts
|
|
8
|
+
class ChatError extends Error {
|
|
9
|
+
code;
|
|
10
|
+
constructor(code, message) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = "ChatError";
|
|
13
|
+
this.code = code;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
class ChatNotFoundError extends ChatError {
|
|
18
|
+
resource;
|
|
19
|
+
resourceId;
|
|
20
|
+
constructor(resource, resourceId) {
|
|
21
|
+
super("not_found", `${resource} not found: ${resourceId}`);
|
|
22
|
+
this.name = "ChatNotFoundError";
|
|
23
|
+
this.resource = resource;
|
|
24
|
+
this.resourceId = resourceId;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
class ChatConflictError extends ChatError {
|
|
29
|
+
reason;
|
|
30
|
+
constructor(reason, message) {
|
|
31
|
+
super("conflict", message);
|
|
32
|
+
this.name = "ChatConflictError";
|
|
33
|
+
this.reason = reason;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
class ChatValidationError extends ChatError {
|
|
38
|
+
constructor(message) {
|
|
39
|
+
super("validation", message);
|
|
40
|
+
this.name = "ChatValidationError";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
class ChatPermissionError extends ChatError {
|
|
45
|
+
constructor(message) {
|
|
46
|
+
super("permission_denied", message);
|
|
47
|
+
this.name = "ChatPermissionError";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// src/hash.ts
|
|
51
|
+
function sha256Hex(input) {
|
|
52
|
+
const hasher = new Bun.CryptoHasher("sha256");
|
|
53
|
+
hasher.update(input);
|
|
54
|
+
return hasher.digest("hex");
|
|
55
|
+
}
|
|
56
|
+
function stableStringify(value) {
|
|
57
|
+
if (value === undefined) {
|
|
58
|
+
return "null";
|
|
59
|
+
}
|
|
60
|
+
if (value === null || typeof value !== "object") {
|
|
61
|
+
return JSON.stringify(value);
|
|
62
|
+
}
|
|
63
|
+
if (Array.isArray(value)) {
|
|
64
|
+
return `[${value.map((item) => stableStringify(item)).join(",")}]`;
|
|
65
|
+
}
|
|
66
|
+
const record = value;
|
|
67
|
+
const keys = Object.keys(record).sort();
|
|
68
|
+
return `{${keys.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`).join(",")}}`;
|
|
69
|
+
}
|
|
70
|
+
function canonicalPostVersionPayload(input) {
|
|
71
|
+
return stableStringify({
|
|
72
|
+
postId: input.postId,
|
|
73
|
+
versionId: input.versionId,
|
|
74
|
+
threadId: input.threadId,
|
|
75
|
+
author: input.author,
|
|
76
|
+
role: input.role,
|
|
77
|
+
parts: input.parts,
|
|
78
|
+
metadata: input.metadata ?? null,
|
|
79
|
+
mentions: input.mentions ?? null,
|
|
80
|
+
model: input.model ?? null,
|
|
81
|
+
usage: input.usage ?? null,
|
|
82
|
+
parentVersionId: input.parentVersionId ?? null,
|
|
83
|
+
previousPostVersionId: input.previousPostVersionId ?? null
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
function computeContentHash(payload) {
|
|
87
|
+
return sha256Hex(canonicalPostVersionPayload(payload));
|
|
88
|
+
}
|
|
89
|
+
function computeLineageHash(input) {
|
|
90
|
+
return sha256Hex(stableStringify({
|
|
91
|
+
previousLineageHash: input.previousLineageHash,
|
|
92
|
+
contentHash: input.contentHash,
|
|
93
|
+
postId: input.postId,
|
|
94
|
+
versionId: input.versionId
|
|
95
|
+
}));
|
|
96
|
+
}
|
|
97
|
+
function canonicalAclEventPayload(input) {
|
|
98
|
+
return stableStringify({
|
|
99
|
+
type: input.type,
|
|
100
|
+
targetType: input.targetType,
|
|
101
|
+
targetId: input.targetId,
|
|
102
|
+
scope: input.scope ?? null,
|
|
103
|
+
role: input.role ?? null,
|
|
104
|
+
actor: input.actor,
|
|
105
|
+
previousAclEventId: input.previousAclEventId ?? null
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
function computeAclEventContentHash(payload) {
|
|
109
|
+
return sha256Hex(canonicalAclEventPayload(payload));
|
|
110
|
+
}
|
|
111
|
+
function signedPayloadBytes(payload) {
|
|
112
|
+
return new TextEncoder().encode(payload);
|
|
113
|
+
}
|
|
114
|
+
function canonicalSignedPostVersionPayload(input) {
|
|
115
|
+
return stableStringify({
|
|
116
|
+
contentHash: input.contentHash,
|
|
117
|
+
lineageHash: input.lineageHash,
|
|
118
|
+
payload: JSON.parse(canonicalPostVersionPayload(input))
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
function canonicalSignedAclEventPayload(input) {
|
|
122
|
+
return stableStringify({
|
|
123
|
+
contentHash: input.contentHash,
|
|
124
|
+
payload: JSON.parse(canonicalAclEventPayload(input.payload))
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
async function maybeSignPayload(payload, signer, sign) {
|
|
128
|
+
return sign(signedPayloadBytes(payload), signer);
|
|
129
|
+
}
|
|
130
|
+
// src/ids.ts
|
|
131
|
+
function createId() {
|
|
132
|
+
return crypto.randomUUID();
|
|
133
|
+
}
|
|
134
|
+
function scopeKey(scope) {
|
|
135
|
+
return `${scope.type}:${scope.id}`;
|
|
136
|
+
}
|
|
137
|
+
function scopeRefFromKey(key) {
|
|
138
|
+
const separatorIndex = key.indexOf(":");
|
|
139
|
+
if (separatorIndex === -1) {
|
|
140
|
+
throw new Error(`Invalid scope key: ${key}`);
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
type: key.slice(0, separatorIndex),
|
|
144
|
+
id: key.slice(separatorIndex + 1)
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
// src/lineage.ts
|
|
148
|
+
function walkLineageFromHead(headVersionId, versionsById) {
|
|
149
|
+
const lineage = [];
|
|
150
|
+
let currentId = headVersionId;
|
|
151
|
+
const seenPostIds = new Set;
|
|
152
|
+
while (currentId) {
|
|
153
|
+
const version = versionsById.get(currentId);
|
|
154
|
+
if (!version)
|
|
155
|
+
break;
|
|
156
|
+
if (!seenPostIds.has(version.postId)) {
|
|
157
|
+
lineage.push(version);
|
|
158
|
+
seenPostIds.add(version.postId);
|
|
159
|
+
}
|
|
160
|
+
currentId = version.previousPostVersionId;
|
|
161
|
+
}
|
|
162
|
+
return lineage.reverse();
|
|
163
|
+
}
|
|
164
|
+
function lineageBetween(headVersionId, ancestorVersionId, versionsById) {
|
|
165
|
+
const lineage = walkLineageFromHead(headVersionId, versionsById);
|
|
166
|
+
const startIndex = lineage.findIndex((version) => version.id === ancestorVersionId);
|
|
167
|
+
if (startIndex === -1)
|
|
168
|
+
return null;
|
|
169
|
+
return lineage.slice(startIndex);
|
|
170
|
+
}
|
|
171
|
+
function postFromVersion(version, index, deletedAtMs) {
|
|
172
|
+
return {
|
|
173
|
+
...version,
|
|
174
|
+
id: version.postId,
|
|
175
|
+
status: "complete",
|
|
176
|
+
versionId: version.id,
|
|
177
|
+
previousVersionId: version.parentVersionId ?? null,
|
|
178
|
+
index,
|
|
179
|
+
updatedAtMs: version.parentVersionId ? version.createdAtMs : null,
|
|
180
|
+
deletedAtMs: deletedAtMs ?? null
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
// src/stream.ts
|
|
184
|
+
function rebuildStreamCacheFromEvents(events) {
|
|
185
|
+
const sorted = [...events].sort((a, b) => a.revision - b.revision);
|
|
186
|
+
let message = null;
|
|
187
|
+
let mentions;
|
|
188
|
+
let model;
|
|
189
|
+
let usage;
|
|
190
|
+
let revision = 0;
|
|
191
|
+
for (const event of sorted) {
|
|
192
|
+
if (event.eventType === "stream.started") {
|
|
193
|
+
if (!event.message) {
|
|
194
|
+
throw new Error(`stream.started event ${event.id} missing message`);
|
|
195
|
+
}
|
|
196
|
+
message = event.message;
|
|
197
|
+
mentions = event.mentions;
|
|
198
|
+
model = event.model;
|
|
199
|
+
usage = event.usage;
|
|
200
|
+
revision = event.revision;
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (event.eventType === "stream.delta") {
|
|
204
|
+
if (!event.message) {
|
|
205
|
+
throw new Error(`stream.delta event ${event.id} missing message`);
|
|
206
|
+
}
|
|
207
|
+
message = event.message;
|
|
208
|
+
if (event.mentions !== undefined)
|
|
209
|
+
mentions = event.mentions;
|
|
210
|
+
if (event.model !== undefined)
|
|
211
|
+
model = event.model;
|
|
212
|
+
if (event.usage !== undefined)
|
|
213
|
+
usage = event.usage;
|
|
214
|
+
revision = event.revision;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (event.eventType === "stream.completed" || event.eventType === "stream.aborted") {
|
|
218
|
+
revision = event.revision;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (!message) {
|
|
222
|
+
throw new Error("stream events did not produce a message");
|
|
223
|
+
}
|
|
224
|
+
return { message, mentions, model, usage, revision };
|
|
225
|
+
}
|
|
226
|
+
function streamingPostFromCache(input) {
|
|
227
|
+
return {
|
|
228
|
+
...input.message,
|
|
229
|
+
id: input.postId,
|
|
230
|
+
status: "streaming",
|
|
231
|
+
threadId: input.threadId,
|
|
232
|
+
author: input.author,
|
|
233
|
+
mentions: input.mentions,
|
|
234
|
+
model: input.model,
|
|
235
|
+
usage: input.usage,
|
|
236
|
+
index: input.index,
|
|
237
|
+
streamRevision: input.streamRevision,
|
|
238
|
+
createdAtMs: input.createdAtMs,
|
|
239
|
+
updatedAtMs: input.updatedAtMs ?? null,
|
|
240
|
+
deletedAtMs: input.deletedAtMs ?? null
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
function mergeThreadPostsForList(lineagePosts, activePosts) {
|
|
244
|
+
const byIndex = new Map;
|
|
245
|
+
for (const item of lineagePosts) {
|
|
246
|
+
byIndex.set(item.index, item.post);
|
|
247
|
+
}
|
|
248
|
+
for (const post of activePosts) {
|
|
249
|
+
if (post.status === "streaming" || post.status === "aborted") {
|
|
250
|
+
byIndex.set(post.index, post);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return [...byIndex.entries()].sort(([a], [b]) => a - b).map(([, post]) => post);
|
|
254
|
+
}
|
|
255
|
+
// src/validation.ts
|
|
256
|
+
function assertThreadRoot(root) {
|
|
257
|
+
if (root.type === "channel") {
|
|
258
|
+
if (!root.channelId.trim()) {
|
|
259
|
+
throw new ChatValidationError("channel root requires channelId");
|
|
260
|
+
}
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (!root.postId.trim()) {
|
|
264
|
+
throw new ChatValidationError("post root requires postId");
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
function assertNonEmptyString(value, label) {
|
|
268
|
+
if (!value.trim()) {
|
|
269
|
+
throw new ChatValidationError(`${label} is required`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
// src/persistence/testing/persistence/contract.ts
|
|
273
|
+
function runChatPersistenceContractTests(name, createPersistence) {
|
|
274
|
+
describe(`${name} persistence contract`, () => {
|
|
275
|
+
test("creates channel and thread under channel", async () => {
|
|
276
|
+
const persistence = await createPersistence();
|
|
277
|
+
const channel = await persistence.createChannel({ metadata: { name: "general" } });
|
|
278
|
+
const thread = await persistence.createThread({
|
|
279
|
+
root: { type: "channel", channelId: channel.id }
|
|
280
|
+
});
|
|
281
|
+
expect(await persistence.getChannel(channel.id)).toEqual(channel);
|
|
282
|
+
expect(await persistence.getThread(thread.id)).toEqual(thread);
|
|
283
|
+
const threads = await persistence.listThreads({ channelId: channel.id });
|
|
284
|
+
expect(threads.items).toHaveLength(1);
|
|
285
|
+
});
|
|
286
|
+
test("appends posts with monotonic indexes", async () => {
|
|
287
|
+
const persistence = await createPersistence();
|
|
288
|
+
const channel = await persistence.createChannel({});
|
|
289
|
+
const thread = await persistence.createThread({
|
|
290
|
+
root: { type: "channel", channelId: channel.id }
|
|
291
|
+
});
|
|
292
|
+
const author = { type: "account", id: "user-1" };
|
|
293
|
+
const first = await persistence.appendPost({
|
|
294
|
+
threadId: thread.id,
|
|
295
|
+
author,
|
|
296
|
+
message: { id: "m1", role: "user", parts: [{ type: "text", text: "hello" }] }
|
|
297
|
+
});
|
|
298
|
+
expect(first.ok).toBe(true);
|
|
299
|
+
if (!first.ok)
|
|
300
|
+
return;
|
|
301
|
+
const second = await persistence.appendPost({
|
|
302
|
+
threadId: thread.id,
|
|
303
|
+
author,
|
|
304
|
+
message: { id: "m2", role: "assistant", parts: [{ type: "text", text: "hi" }] },
|
|
305
|
+
expectedHeadPostVersionId: first.head.headPostVersionId
|
|
306
|
+
});
|
|
307
|
+
expect(second.ok).toBe(true);
|
|
308
|
+
if (!second.ok)
|
|
309
|
+
return;
|
|
310
|
+
const posts = await persistence.listPosts({ threadId: thread.id });
|
|
311
|
+
expect(posts.items).toHaveLength(2);
|
|
312
|
+
expect(posts.items[0]?.index).toBe(1);
|
|
313
|
+
expect(posts.items[1]?.index).toBe(2);
|
|
314
|
+
expect(posts.items[0]?.parts[0]).toEqual({ type: "text", text: "hello" });
|
|
315
|
+
});
|
|
316
|
+
test("returns head conflict on stale append", async () => {
|
|
317
|
+
const persistence = await createPersistence();
|
|
318
|
+
const channel = await persistence.createChannel({});
|
|
319
|
+
const thread = await persistence.createThread({
|
|
320
|
+
root: { type: "channel", channelId: channel.id }
|
|
321
|
+
});
|
|
322
|
+
const author = { type: "account", id: "user-1" };
|
|
323
|
+
const first = await persistence.appendPost({
|
|
324
|
+
threadId: thread.id,
|
|
325
|
+
author,
|
|
326
|
+
message: { id: "m1", role: "user", parts: [{ type: "text", text: "one" }] }
|
|
327
|
+
});
|
|
328
|
+
expect(first.ok).toBe(true);
|
|
329
|
+
if (!first.ok)
|
|
330
|
+
return;
|
|
331
|
+
const conflict = await persistence.appendPost({
|
|
332
|
+
threadId: thread.id,
|
|
333
|
+
author,
|
|
334
|
+
message: { id: "m2", role: "user", parts: [{ type: "text", text: "two" }] },
|
|
335
|
+
expectedHeadPostVersionId: null
|
|
336
|
+
});
|
|
337
|
+
expect(conflict.ok).toBe(false);
|
|
338
|
+
if (conflict.ok)
|
|
339
|
+
return;
|
|
340
|
+
expect(conflict.reason).toBe("head_conflict");
|
|
341
|
+
});
|
|
342
|
+
test("supports recursive thread rooted at post", async () => {
|
|
343
|
+
const persistence = await createPersistence();
|
|
344
|
+
const channel = await persistence.createChannel({});
|
|
345
|
+
const parentThread = await persistence.createThread({
|
|
346
|
+
root: { type: "channel", channelId: channel.id }
|
|
347
|
+
});
|
|
348
|
+
const author = { type: "account", id: "user-1" };
|
|
349
|
+
const parentPost = await persistence.appendPost({
|
|
350
|
+
threadId: parentThread.id,
|
|
351
|
+
author,
|
|
352
|
+
message: { id: "root-post", role: "user", parts: [{ type: "text", text: "root" }] }
|
|
353
|
+
});
|
|
354
|
+
expect(parentPost.ok).toBe(true);
|
|
355
|
+
if (!parentPost.ok)
|
|
356
|
+
return;
|
|
357
|
+
const childThread = await persistence.createThread({
|
|
358
|
+
root: { type: "post", postId: parentPost.post.id }
|
|
359
|
+
});
|
|
360
|
+
const childPosts = await persistence.listThreads({ postId: parentPost.post.id });
|
|
361
|
+
expect(childPosts.items.some((item) => item.id === childThread.id)).toBe(true);
|
|
362
|
+
});
|
|
363
|
+
test("edits create immutable versions and preserve lineage", async () => {
|
|
364
|
+
const persistence = await createPersistence();
|
|
365
|
+
const channel = await persistence.createChannel({});
|
|
366
|
+
const thread = await persistence.createThread({
|
|
367
|
+
root: { type: "channel", channelId: channel.id }
|
|
368
|
+
});
|
|
369
|
+
const author = { type: "account", id: "user-1" };
|
|
370
|
+
const appended = await persistence.appendPost({
|
|
371
|
+
threadId: thread.id,
|
|
372
|
+
author,
|
|
373
|
+
message: { id: "m1", role: "user", parts: [{ type: "text", text: "draft" }] }
|
|
374
|
+
});
|
|
375
|
+
expect(appended.ok).toBe(true);
|
|
376
|
+
if (!appended.ok || appended.post.status !== "complete")
|
|
377
|
+
return;
|
|
378
|
+
const edited = await persistence.editPost({
|
|
379
|
+
postId: appended.post.id,
|
|
380
|
+
parentVersionId: appended.post.versionId,
|
|
381
|
+
author,
|
|
382
|
+
message: { id: "m1", role: "user", parts: [{ type: "text", text: "final" }] }
|
|
383
|
+
});
|
|
384
|
+
expect(edited.ok).toBe(true);
|
|
385
|
+
if (!edited.ok || edited.post.status !== "complete")
|
|
386
|
+
return;
|
|
387
|
+
const posts = await persistence.listPosts({ threadId: thread.id });
|
|
388
|
+
expect(posts.items).toHaveLength(1);
|
|
389
|
+
expect(posts.items[0]?.parts[0]).toEqual({ type: "text", text: "final" });
|
|
390
|
+
expect(edited.post.previousVersionId).toBe(appended.post.versionId);
|
|
391
|
+
});
|
|
392
|
+
test("soft delete preserves ledger order", async () => {
|
|
393
|
+
const persistence = await createPersistence();
|
|
394
|
+
const channel = await persistence.createChannel({});
|
|
395
|
+
const thread = await persistence.createThread({
|
|
396
|
+
root: { type: "channel", channelId: channel.id }
|
|
397
|
+
});
|
|
398
|
+
const author = { type: "account", id: "user-1" };
|
|
399
|
+
const first = await persistence.appendPost({
|
|
400
|
+
threadId: thread.id,
|
|
401
|
+
author,
|
|
402
|
+
message: { id: "m1", role: "user", parts: [{ type: "text", text: "keep" }] }
|
|
403
|
+
});
|
|
404
|
+
expect(first.ok).toBe(true);
|
|
405
|
+
if (!first.ok)
|
|
406
|
+
return;
|
|
407
|
+
const second = await persistence.appendPost({
|
|
408
|
+
threadId: thread.id,
|
|
409
|
+
author,
|
|
410
|
+
message: { id: "m2", role: "user", parts: [{ type: "text", text: "remove" }] },
|
|
411
|
+
expectedHeadPostVersionId: first.head.headPostVersionId
|
|
412
|
+
});
|
|
413
|
+
expect(second.ok).toBe(true);
|
|
414
|
+
if (!second.ok)
|
|
415
|
+
return;
|
|
416
|
+
await persistence.deletePost({ postId: second.post.id });
|
|
417
|
+
const posts = await persistence.listPosts({ threadId: thread.id });
|
|
418
|
+
expect(posts.items).toHaveLength(2);
|
|
419
|
+
expect(posts.items[1]?.deletedAtMs).not.toBeNull();
|
|
420
|
+
expect(posts.items[0]?.index).toBe(1);
|
|
421
|
+
expect(posts.items[1]?.index).toBe(2);
|
|
422
|
+
});
|
|
423
|
+
test("records ACL membership changes", async () => {
|
|
424
|
+
const persistence = await createPersistence();
|
|
425
|
+
const channel = await persistence.createChannel({});
|
|
426
|
+
const actor = { type: "account", id: "admin" };
|
|
427
|
+
const member = { type: "account", id: "member" };
|
|
428
|
+
await persistence.addChannelMember({
|
|
429
|
+
channelId: channel.id,
|
|
430
|
+
scope: member,
|
|
431
|
+
role: "member",
|
|
432
|
+
actor
|
|
433
|
+
});
|
|
434
|
+
const members = await persistence.listChannelMembers(channel.id);
|
|
435
|
+
expect(members).toEqual([member]);
|
|
436
|
+
const events2 = await persistence.listAclEvents({ channelId: channel.id });
|
|
437
|
+
expect(events2).toHaveLength(1);
|
|
438
|
+
expect(events2[0]?.type).toBe("channel.member.added");
|
|
439
|
+
});
|
|
440
|
+
test("supports idempotency keys for append", async () => {
|
|
441
|
+
const persistence = await createPersistence();
|
|
442
|
+
const channel = await persistence.createChannel({});
|
|
443
|
+
const thread = await persistence.createThread({
|
|
444
|
+
root: { type: "channel", channelId: channel.id }
|
|
445
|
+
});
|
|
446
|
+
const author = { type: "account", id: "user-1" };
|
|
447
|
+
const input = {
|
|
448
|
+
threadId: thread.id,
|
|
449
|
+
author,
|
|
450
|
+
idempotencyKey: "retry-1",
|
|
451
|
+
message: {
|
|
452
|
+
id: "m1",
|
|
453
|
+
role: "user",
|
|
454
|
+
parts: [{ type: "text", text: "once" }]
|
|
455
|
+
}
|
|
456
|
+
};
|
|
457
|
+
const first = await persistence.appendPost(input);
|
|
458
|
+
const second = await persistence.appendPost(input);
|
|
459
|
+
expect(first.ok).toBe(true);
|
|
460
|
+
expect(second.ok).toBe(true);
|
|
461
|
+
if (!first.ok || !second.ok || first.post.status !== "complete" || second.post.status !== "complete")
|
|
462
|
+
return;
|
|
463
|
+
expect(second.post.versionId).toBe(first.post.versionId);
|
|
464
|
+
const posts = await persistence.listPosts({ threadId: thread.id });
|
|
465
|
+
expect(posts.items).toHaveLength(1);
|
|
466
|
+
});
|
|
467
|
+
test("walks lineage between ancestor and head", async () => {
|
|
468
|
+
const persistence = await createPersistence();
|
|
469
|
+
const channel = await persistence.createChannel({});
|
|
470
|
+
const thread = await persistence.createThread({
|
|
471
|
+
root: { type: "channel", channelId: channel.id }
|
|
472
|
+
});
|
|
473
|
+
const author = { type: "account", id: "user-1" };
|
|
474
|
+
let headVersionId = null;
|
|
475
|
+
const versions = new Map;
|
|
476
|
+
for (const text of ["a", "b", "c"]) {
|
|
477
|
+
const result = await persistence.appendPost({
|
|
478
|
+
threadId: thread.id,
|
|
479
|
+
author,
|
|
480
|
+
message: { id: `m-${text}`, role: "user", parts: [{ type: "text", text }] },
|
|
481
|
+
expectedHeadPostVersionId: headVersionId
|
|
482
|
+
});
|
|
483
|
+
expect(result.ok).toBe(true);
|
|
484
|
+
if (!result.ok)
|
|
485
|
+
return;
|
|
486
|
+
headVersionId = result.head.headPostVersionId;
|
|
487
|
+
const version = await persistence.getPostVersion(result.post.status === "complete" ? result.post.versionId : "");
|
|
488
|
+
if (version) {
|
|
489
|
+
versions.set(version.id, version);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
const middle = [...versions.values()].find((version) => version.postId === "m-b");
|
|
493
|
+
expect(middle).toBeDefined();
|
|
494
|
+
if (!middle || !headVersionId)
|
|
495
|
+
return;
|
|
496
|
+
const slice = lineageBetween(headVersionId, middle.id, versions);
|
|
497
|
+
expect(slice?.map((version) => version.postId)).toEqual(["m-b", "m-c"]);
|
|
498
|
+
});
|
|
499
|
+
test("streams post deltas until completion commits immutable version", async () => {
|
|
500
|
+
const persistence = await createPersistence();
|
|
501
|
+
const channel = await persistence.createChannel({});
|
|
502
|
+
const thread = await persistence.createThread({
|
|
503
|
+
root: { type: "channel", channelId: channel.id }
|
|
504
|
+
});
|
|
505
|
+
const author = { type: "agent", id: "a1" };
|
|
506
|
+
const started = await persistence.startStreamedPost({
|
|
507
|
+
threadId: thread.id,
|
|
508
|
+
author,
|
|
509
|
+
message: {
|
|
510
|
+
id: "stream-1",
|
|
511
|
+
role: "assistant",
|
|
512
|
+
parts: [{ type: "text", text: "hel", state: "streaming" }]
|
|
513
|
+
}
|
|
514
|
+
});
|
|
515
|
+
expect(started.post.status).toBe("streaming");
|
|
516
|
+
expect(started.revision).toBe(1);
|
|
517
|
+
const listed = await persistence.listPosts({ threadId: thread.id });
|
|
518
|
+
expect(listed.items).toHaveLength(1);
|
|
519
|
+
expect(listed.items[0]?.status).toBe("streaming");
|
|
520
|
+
const delta = await persistence.applyPostDelta({
|
|
521
|
+
postId: started.post.id,
|
|
522
|
+
message: {
|
|
523
|
+
id: started.post.id,
|
|
524
|
+
role: "assistant",
|
|
525
|
+
parts: [{ type: "text", text: "hello", state: "streaming" }]
|
|
526
|
+
},
|
|
527
|
+
expectedRevision: 1
|
|
528
|
+
});
|
|
529
|
+
expect(delta.revision).toBe(2);
|
|
530
|
+
const retry = await persistence.applyPostDelta({
|
|
531
|
+
postId: started.post.id,
|
|
532
|
+
message: {
|
|
533
|
+
id: started.post.id,
|
|
534
|
+
role: "assistant",
|
|
535
|
+
parts: [{ type: "text", text: "hello", state: "streaming" }]
|
|
536
|
+
},
|
|
537
|
+
idempotencyKey: "delta-retry",
|
|
538
|
+
expectedRevision: 2
|
|
539
|
+
});
|
|
540
|
+
const retryAgain = await persistence.applyPostDelta({
|
|
541
|
+
postId: started.post.id,
|
|
542
|
+
message: {
|
|
543
|
+
id: started.post.id,
|
|
544
|
+
role: "assistant",
|
|
545
|
+
parts: [{ type: "text", text: "hello", state: "streaming" }]
|
|
546
|
+
},
|
|
547
|
+
idempotencyKey: "delta-retry",
|
|
548
|
+
expectedRevision: 2
|
|
549
|
+
});
|
|
550
|
+
expect(retryAgain.revision).toBe(retry.revision);
|
|
551
|
+
const completed = await persistence.completeStreamedPost({
|
|
552
|
+
postId: started.post.id,
|
|
553
|
+
expectedRevision: retry.revision
|
|
554
|
+
});
|
|
555
|
+
expect(completed.ok).toBe(true);
|
|
556
|
+
if (!completed.ok)
|
|
557
|
+
return;
|
|
558
|
+
expect(completed.post.status).toBe("complete");
|
|
559
|
+
expect(completed.post.parts[0]).toEqual({ type: "text", text: "hello", state: "streaming" });
|
|
560
|
+
const events2 = await persistence.listPostStreamEvents(started.post.id);
|
|
561
|
+
expect(events2.length).toBeGreaterThanOrEqual(3);
|
|
562
|
+
await expect(persistence.applyPostDelta({
|
|
563
|
+
postId: started.post.id,
|
|
564
|
+
message: {
|
|
565
|
+
id: started.post.id,
|
|
566
|
+
role: "assistant",
|
|
567
|
+
parts: [{ type: "text", text: "nope" }]
|
|
568
|
+
}
|
|
569
|
+
})).rejects.toThrow();
|
|
570
|
+
const rebuilt = await persistence.rebuildStreamedPostCache(started.post.id).catch(() => null);
|
|
571
|
+
expect(rebuilt).toBeNull();
|
|
572
|
+
});
|
|
573
|
+
test("rebuilds cached stream state from append-only events", async () => {
|
|
574
|
+
const persistence = await createPersistence();
|
|
575
|
+
const channel = await persistence.createChannel({});
|
|
576
|
+
const thread = await persistence.createThread({
|
|
577
|
+
root: { type: "channel", channelId: channel.id }
|
|
578
|
+
});
|
|
579
|
+
const author = { type: "agent", id: "a1" };
|
|
580
|
+
const started = await persistence.startStreamedPost({
|
|
581
|
+
threadId: thread.id,
|
|
582
|
+
author,
|
|
583
|
+
message: { id: "stream-2", role: "assistant", parts: [{ type: "text", text: "a" }] }
|
|
584
|
+
});
|
|
585
|
+
await persistence.applyPostDelta({
|
|
586
|
+
postId: started.post.id,
|
|
587
|
+
message: { id: started.post.id, role: "assistant", parts: [{ type: "text", text: "ab" }] }
|
|
588
|
+
});
|
|
589
|
+
const rebuilt = await persistence.rebuildStreamedPostCache(started.post.id);
|
|
590
|
+
expect(rebuilt.parts[0]).toEqual({ type: "text", text: "ab" });
|
|
591
|
+
});
|
|
592
|
+
test("getThreadTip returns default-head lineage tip", async () => {
|
|
593
|
+
const persistence = await createPersistence();
|
|
594
|
+
const channel = await persistence.createChannel({});
|
|
595
|
+
const thread = await persistence.createThread({
|
|
596
|
+
root: { type: "channel", channelId: channel.id }
|
|
597
|
+
});
|
|
598
|
+
expect(await persistence.getThreadTip(thread.id)).toBeNull();
|
|
599
|
+
const author = { type: "account", id: "user-1" };
|
|
600
|
+
const first = await persistence.appendPost({
|
|
601
|
+
threadId: thread.id,
|
|
602
|
+
author,
|
|
603
|
+
message: { id: "m1", role: "user", parts: [{ type: "text", text: "one" }] }
|
|
604
|
+
});
|
|
605
|
+
expect(first.ok).toBe(true);
|
|
606
|
+
if (!first.ok || first.post.status !== "complete")
|
|
607
|
+
return;
|
|
608
|
+
const tip = await persistence.getThreadTip(thread.id);
|
|
609
|
+
expect(tip).toEqual({
|
|
610
|
+
id: first.post.versionId,
|
|
611
|
+
lineageHash: first.post.lineageHash
|
|
612
|
+
});
|
|
613
|
+
});
|
|
614
|
+
test("listThreads filters by participant and channel", async () => {
|
|
615
|
+
const persistence = await createPersistence();
|
|
616
|
+
const channel = await persistence.createChannel({});
|
|
617
|
+
const alice = { type: "account", id: "alice" };
|
|
618
|
+
const bob = { type: "account", id: "bob" };
|
|
619
|
+
const shared = await persistence.createThread({
|
|
620
|
+
root: { type: "channel", channelId: channel.id }
|
|
621
|
+
});
|
|
622
|
+
const privateThread = await persistence.createThread({
|
|
623
|
+
root: { type: "channel", channelId: channel.id }
|
|
624
|
+
});
|
|
625
|
+
await persistence.addThreadParticipant({
|
|
626
|
+
threadId: shared.id,
|
|
627
|
+
scope: alice,
|
|
628
|
+
role: "owner",
|
|
629
|
+
actor: alice
|
|
630
|
+
});
|
|
631
|
+
await persistence.addThreadParticipant({
|
|
632
|
+
threadId: shared.id,
|
|
633
|
+
scope: bob,
|
|
634
|
+
role: "participant",
|
|
635
|
+
actor: alice
|
|
636
|
+
});
|
|
637
|
+
await persistence.addThreadParticipant({
|
|
638
|
+
threadId: privateThread.id,
|
|
639
|
+
scope: alice,
|
|
640
|
+
role: "owner",
|
|
641
|
+
actor: alice
|
|
642
|
+
});
|
|
643
|
+
const bobThreads = await persistence.listThreads({
|
|
644
|
+
channelId: channel.id,
|
|
645
|
+
participant: bob
|
|
646
|
+
});
|
|
647
|
+
expect(bobThreads.items.map((thread) => thread.id)).toEqual([shared.id]);
|
|
648
|
+
const aliceThreads = await persistence.listThreads({
|
|
649
|
+
channelId: channel.id,
|
|
650
|
+
participant: alice
|
|
651
|
+
});
|
|
652
|
+
expect(aliceThreads.items.map((thread) => thread.id).sort()).toEqual([shared.id, privateThread.id].sort());
|
|
653
|
+
});
|
|
654
|
+
test("setPostVersionSignature persists and overwrites", async () => {
|
|
655
|
+
const persistence = await createPersistence();
|
|
656
|
+
const channel = await persistence.createChannel({});
|
|
657
|
+
const thread = await persistence.createThread({
|
|
658
|
+
root: { type: "channel", channelId: channel.id }
|
|
659
|
+
});
|
|
660
|
+
const author = { type: "account", id: "user-1" };
|
|
661
|
+
const appended = await persistence.appendPost({
|
|
662
|
+
threadId: thread.id,
|
|
663
|
+
author,
|
|
664
|
+
message: { id: "m-sig", role: "user", parts: [{ type: "text", text: "signed" }] }
|
|
665
|
+
});
|
|
666
|
+
expect(appended.ok).toBe(true);
|
|
667
|
+
if (!appended.ok || appended.post.status !== "complete")
|
|
668
|
+
return;
|
|
669
|
+
const versionId = appended.post.versionId;
|
|
670
|
+
const envelope = {
|
|
671
|
+
algorithm: "test",
|
|
672
|
+
signer: author,
|
|
673
|
+
signature: "sig-1",
|
|
674
|
+
signedAtMs: Date.now()
|
|
675
|
+
};
|
|
676
|
+
await persistence.setPostVersionSignature(versionId, envelope);
|
|
677
|
+
const version = await persistence.getPostVersion(versionId);
|
|
678
|
+
expect(version?.signature).toEqual(envelope);
|
|
679
|
+
const overwrite = { ...envelope, signature: "sig-2" };
|
|
680
|
+
await persistence.setPostVersionSignature(versionId, overwrite);
|
|
681
|
+
const updated = await persistence.getPostVersion(versionId);
|
|
682
|
+
expect(updated?.signature).toEqual(overwrite);
|
|
683
|
+
await expect(persistence.setPostVersionSignature("missing-version", envelope)).rejects.toThrow(/not found/);
|
|
684
|
+
});
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
export {
|
|
688
|
+
runChatPersistenceContractTests
|
|
689
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"contract.d.ts","sourceRoot":"","sources":["../../../../src/persistence/testing/persistence/contract.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAC;AAG1D,wBAAgB,+BAA+B,CAC7C,IAAI,EAAE,MAAM,EACZ,iBAAiB,EAAE,MAAM,eAAe,GAAG,OAAO,CAAC,eAAe,CAAC,QA2cpE"}
|