@khoralabs/chat 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +7 -0
  2. package/dist/domain.d.ts +10 -0
  3. package/dist/domain.d.ts.map +1 -0
  4. package/dist/errors.d.ts +20 -0
  5. package/dist/errors.d.ts.map +1 -0
  6. package/dist/events.d.ts +59 -0
  7. package/dist/events.d.ts.map +1 -0
  8. package/dist/hash.d.ts +47 -0
  9. package/dist/hash.d.ts.map +1 -0
  10. package/dist/http/client.d.ts +21 -0
  11. package/dist/http/client.d.ts.map +1 -0
  12. package/dist/http/client.js +367 -0
  13. package/dist/http/config.d.ts +5 -0
  14. package/dist/http/config.d.ts.map +1 -0
  15. package/dist/http/index.d.ts +6 -0
  16. package/dist/http/index.d.ts.map +1 -0
  17. package/dist/http/index.js +1084 -0
  18. package/dist/http/routes.d.ts +15 -0
  19. package/dist/http/routes.d.ts.map +1 -0
  20. package/dist/http/routes.js +645 -0
  21. package/dist/http/server.d.ts +19 -0
  22. package/dist/http/server.d.ts.map +1 -0
  23. package/dist/http/server.js +973 -0
  24. package/dist/http/service.d.ts +34 -0
  25. package/dist/http/service.d.ts.map +1 -0
  26. package/dist/http/service.js +501 -0
  27. package/dist/ids.d.ts +8 -0
  28. package/dist/ids.d.ts.map +1 -0
  29. package/dist/index.d.ts +3 -0
  30. package/dist/index.d.ts.map +1 -0
  31. package/dist/index.js +453 -0
  32. package/dist/lineage.d.ts +5 -0
  33. package/dist/lineage.d.ts.map +1 -0
  34. package/dist/persistence/core/index.d.ts +2 -0
  35. package/dist/persistence/core/index.d.ts.map +1 -0
  36. package/dist/persistence/core/persistence/base-persistence.d.ts +12 -0
  37. package/dist/persistence/core/persistence/base-persistence.d.ts.map +1 -0
  38. package/dist/persistence/core/persistence/helpers.d.ts +30 -0
  39. package/dist/persistence/core/persistence/helpers.d.ts.map +1 -0
  40. package/dist/persistence/core/persistence/index.d.ts +6 -0
  41. package/dist/persistence/core/persistence/index.d.ts.map +1 -0
  42. package/dist/persistence/core/persistence/index.js +1276 -0
  43. package/dist/persistence/core/persistence/memory-persistence.d.ts +55 -0
  44. package/dist/persistence/core/persistence/memory-persistence.d.ts.map +1 -0
  45. package/dist/persistence/core/persistence/signed-persistence.d.ts +18 -0
  46. package/dist/persistence/core/persistence/signed-persistence.d.ts.map +1 -0
  47. package/dist/persistence/core/persistence/types.d.ts +210 -0
  48. package/dist/persistence/core/persistence/types.d.ts.map +1 -0
  49. package/dist/persistence/sqlite/index.d.ts +3 -0
  50. package/dist/persistence/sqlite/index.d.ts.map +1 -0
  51. package/dist/persistence/sqlite/index.js +1544 -0
  52. package/dist/persistence/sqlite/persistence/persistence.d.ts +49 -0
  53. package/dist/persistence/sqlite/persistence/persistence.d.ts.map +1 -0
  54. package/dist/persistence/sqlite/persistence/schema.d.ts +4 -0
  55. package/dist/persistence/sqlite/persistence/schema.d.ts.map +1 -0
  56. package/dist/persistence/sqlite/persistence/streaming.d.ts +27 -0
  57. package/dist/persistence/sqlite/persistence/streaming.d.ts.map +1 -0
  58. package/dist/persistence/testing/index.d.ts +2 -0
  59. package/dist/persistence/testing/index.d.ts.map +1 -0
  60. package/dist/persistence/testing/index.js +689 -0
  61. package/dist/persistence/testing/persistence/contract.d.ts +3 -0
  62. package/dist/persistence/testing/persistence/contract.d.ts.map +1 -0
  63. package/dist/persistence/turso-serverless/index.d.ts +10 -0
  64. package/dist/persistence/turso-serverless/index.d.ts.map +1 -0
  65. package/dist/persistence/turso-serverless/index.js +1812 -0
  66. package/dist/persistence/turso-serverless/persistence/local-sqlite.d.ts +5 -0
  67. package/dist/persistence/turso-serverless/persistence/local-sqlite.d.ts.map +1 -0
  68. package/dist/persistence/turso-serverless/persistence/persistence.d.ts +49 -0
  69. package/dist/persistence/turso-serverless/persistence/persistence.d.ts.map +1 -0
  70. package/dist/persistence/turso-serverless/persistence/schema.d.ts +4 -0
  71. package/dist/persistence/turso-serverless/persistence/schema.d.ts.map +1 -0
  72. package/dist/persistence/turso-serverless/persistence/sql.d.ts +20 -0
  73. package/dist/persistence/turso-serverless/persistence/sql.d.ts.map +1 -0
  74. package/dist/persistence/turso-serverless/persistence/sql.js +53 -0
  75. package/dist/persistence/turso-serverless/persistence/streaming.d.ts +27 -0
  76. package/dist/persistence/turso-serverless/persistence/streaming.d.ts.map +1 -0
  77. package/dist/service.d.ts +49 -0
  78. package/dist/service.d.ts.map +1 -0
  79. package/dist/stream.d.ts +32 -0
  80. package/dist/stream.d.ts.map +1 -0
  81. package/dist/types.d.ts +201 -0
  82. package/dist/types.d.ts.map +1 -0
  83. package/dist/validation.d.ts +4 -0
  84. package/dist/validation.d.ts.map +1 -0
  85. package/package.json +104 -0
@@ -0,0 +1,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,3 @@
1
+ import type { ChatPersistence } from "../../../domain.js";
2
+ export declare function runChatPersistenceContractTests(name: string, createPersistence: () => ChatPersistence | Promise<ChatPersistence>): void;
3
+ //# sourceMappingURL=contract.d.ts.map
@@ -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"}