@agentlip/kernel 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.
@@ -0,0 +1,523 @@
1
+ /**
2
+ * Message mutation helpers for @agentlip/kernel
3
+ *
4
+ * Implements bd-16d.2.6 (edit + tombstone delete) and bd-16d.2.8 (retopic)
5
+ *
6
+ * All mutations:
7
+ * - Run in single DB transaction
8
+ * - Update state (messages row(s))
9
+ * - Increment messages.version
10
+ * - Emit corresponding events via insertEvent
11
+ */
12
+
13
+ import type { Database } from "bun:sqlite";
14
+ import { insertEvent } from "./events";
15
+
16
+ // ─────────────────────────────────────────────────────────────────────────────
17
+ // Types
18
+ // ─────────────────────────────────────────────────────────────────────────────
19
+
20
+ export interface EditMessageOptions {
21
+ db: Database;
22
+ messageId: string;
23
+ newContentRaw: string;
24
+ expectedVersion?: number;
25
+ }
26
+
27
+ export interface EditMessageResult {
28
+ messageId: string;
29
+ version: number;
30
+ eventId: number;
31
+ }
32
+
33
+ export interface TombstoneDeleteOptions {
34
+ db: Database;
35
+ messageId: string;
36
+ actor: string;
37
+ expectedVersion?: number;
38
+ }
39
+
40
+ export interface TombstoneDeleteResult {
41
+ messageId: string;
42
+ version: number;
43
+ eventId: number;
44
+ }
45
+
46
+ export type RetopicMode = "one" | "later" | "all";
47
+
48
+ export interface RetopicMessageOptions {
49
+ db: Database;
50
+ messageId: string;
51
+ toTopicId: string;
52
+ mode: RetopicMode;
53
+ expectedVersion?: number;
54
+ }
55
+
56
+ export interface RetopicMessageResult {
57
+ affectedCount: number;
58
+ affectedMessages: Array<{
59
+ messageId: string;
60
+ version: number;
61
+ eventId: number;
62
+ }>;
63
+ }
64
+
65
+ // ─────────────────────────────────────────────────────────────────────────────
66
+ // Error Classes
67
+ // ─────────────────────────────────────────────────────────────────────────────
68
+
69
+ /**
70
+ * Error thrown when expectedVersion doesn't match current message version.
71
+ * Contains current_version for 409 response.
72
+ */
73
+ export class VersionConflictError extends Error {
74
+ readonly code = "VERSION_CONFLICT";
75
+ readonly messageId: string;
76
+ readonly expectedVersion: number;
77
+ readonly currentVersion: number;
78
+
79
+ constructor(messageId: string, expectedVersion: number, currentVersion: number) {
80
+ super(
81
+ `Version conflict for message ${messageId}: expected ${expectedVersion}, current ${currentVersion}`
82
+ );
83
+ this.name = "VersionConflictError";
84
+ this.messageId = messageId;
85
+ this.expectedVersion = expectedVersion;
86
+ this.currentVersion = currentVersion;
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Error thrown when message is not found.
92
+ */
93
+ export class MessageNotFoundError extends Error {
94
+ readonly code = "NOT_FOUND";
95
+ readonly messageId: string;
96
+
97
+ constructor(messageId: string) {
98
+ super(`Message not found: ${messageId}`);
99
+ this.name = "MessageNotFoundError";
100
+ this.messageId = messageId;
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Error thrown when attempting cross-channel retopic.
106
+ */
107
+ export class CrossChannelMoveError extends Error {
108
+ readonly code = "CROSS_CHANNEL_MOVE";
109
+ readonly messageId: string;
110
+ readonly sourceChannelId: string;
111
+ readonly targetChannelId: string;
112
+
113
+ constructor(messageId: string, sourceChannelId: string, targetChannelId: string) {
114
+ super(
115
+ `Cross-channel move forbidden: message ${messageId} is in channel ${sourceChannelId}, target topic is in channel ${targetChannelId}`
116
+ );
117
+ this.name = "CrossChannelMoveError";
118
+ this.messageId = messageId;
119
+ this.sourceChannelId = sourceChannelId;
120
+ this.targetChannelId = targetChannelId;
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Error thrown when target topic is not found.
126
+ */
127
+ export class TopicNotFoundError extends Error {
128
+ readonly code = "NOT_FOUND";
129
+ readonly topicId: string;
130
+
131
+ constructor(topicId: string) {
132
+ super(`Topic not found: ${topicId}`);
133
+ this.name = "TopicNotFoundError";
134
+ this.topicId = topicId;
135
+ }
136
+ }
137
+
138
+ // ─────────────────────────────────────────────────────────────────────────────
139
+ // Internal helpers
140
+ // ─────────────────────────────────────────────────────────────────────────────
141
+
142
+ interface MessageRow {
143
+ id: string;
144
+ topic_id: string;
145
+ channel_id: string;
146
+ sender: string;
147
+ content_raw: string;
148
+ version: number;
149
+ created_at: string;
150
+ edited_at: string | null;
151
+ deleted_at: string | null;
152
+ deleted_by: string | null;
153
+ }
154
+
155
+ interface TopicRow {
156
+ id: string;
157
+ channel_id: string;
158
+ title: string;
159
+ created_at: string;
160
+ updated_at: string;
161
+ }
162
+
163
+ function getMessageOrThrow(db: Database, messageId: string): MessageRow {
164
+ const message = db
165
+ .query<MessageRow, [string]>(`
166
+ SELECT id, topic_id, channel_id, sender, content_raw, version,
167
+ created_at, edited_at, deleted_at, deleted_by
168
+ FROM messages
169
+ WHERE id = ?
170
+ `)
171
+ .get(messageId);
172
+
173
+ if (!message) {
174
+ throw new MessageNotFoundError(messageId);
175
+ }
176
+
177
+ return message;
178
+ }
179
+
180
+ function getTopicOrThrow(db: Database, topicId: string): TopicRow {
181
+ const topic = db
182
+ .query<TopicRow, [string]>(`
183
+ SELECT id, channel_id, title, created_at, updated_at
184
+ FROM topics
185
+ WHERE id = ?
186
+ `)
187
+ .get(topicId);
188
+
189
+ if (!topic) {
190
+ throw new TopicNotFoundError(topicId);
191
+ }
192
+
193
+ return topic;
194
+ }
195
+
196
+ function checkVersion(
197
+ message: MessageRow,
198
+ expectedVersion: number | undefined
199
+ ): void {
200
+ if (expectedVersion !== undefined && message.version !== expectedVersion) {
201
+ throw new VersionConflictError(message.id, expectedVersion, message.version);
202
+ }
203
+ }
204
+
205
+ // ─────────────────────────────────────────────────────────────────────────────
206
+ // Edit Message
207
+ // ─────────────────────────────────────────────────────────────────────────────
208
+
209
+ /**
210
+ * Edit message content.
211
+ *
212
+ * Updates:
213
+ * - content_raw to newContentRaw
214
+ * - edited_at to now
215
+ * - version incremented by 1
216
+ *
217
+ * Emits: message.edited event with old_content, new_content, version
218
+ *
219
+ * @throws MessageNotFoundError if message doesn't exist
220
+ * @throws VersionConflictError if expectedVersion provided and mismatched
221
+ */
222
+ export function editMessage(options: EditMessageOptions): EditMessageResult {
223
+ const { db, messageId, newContentRaw, expectedVersion } = options;
224
+
225
+ // Validate content length (per schema: max 64KB)
226
+ if (newContentRaw.length > 65536) {
227
+ throw new Error("Content too large: max 64KB");
228
+ }
229
+
230
+ // Use transaction for atomicity
231
+ const result = db.transaction(() => {
232
+ // 1. Get and validate message
233
+ const message = getMessageOrThrow(db, messageId);
234
+
235
+ // 2. Check version if provided
236
+ checkVersion(message, expectedVersion);
237
+
238
+ const oldContent = message.content_raw;
239
+ const newVersion = message.version + 1;
240
+ const now = new Date().toISOString();
241
+
242
+ // 3. Update message state
243
+ db.run(
244
+ `UPDATE messages
245
+ SET content_raw = ?, edited_at = ?, version = ?
246
+ WHERE id = ?`,
247
+ [newContentRaw, now, newVersion, messageId]
248
+ );
249
+
250
+ // 4. Emit event
251
+ const eventId = insertEvent({
252
+ db,
253
+ name: "message.edited",
254
+ scopes: {
255
+ channel_id: message.channel_id,
256
+ topic_id: message.topic_id,
257
+ },
258
+ entity: { type: "message", id: messageId },
259
+ data: {
260
+ message_id: messageId,
261
+ old_content: oldContent,
262
+ new_content: newContentRaw,
263
+ version: newVersion,
264
+ },
265
+ });
266
+
267
+ return { messageId, version: newVersion, eventId };
268
+ })();
269
+
270
+ return result;
271
+ }
272
+
273
+ // ─────────────────────────────────────────────────────────────────────────────
274
+ // Tombstone Delete Message
275
+ // ─────────────────────────────────────────────────────────────────────────────
276
+
277
+ const TOMBSTONE_CONTENT = "[deleted]";
278
+
279
+ /**
280
+ * Tombstone delete a message.
281
+ *
282
+ * Updates:
283
+ * - deleted_at to now
284
+ * - deleted_by to actor
285
+ * - content_raw to "[deleted]"
286
+ * - edited_at to now
287
+ * - version incremented by 1
288
+ *
289
+ * Emits: message.deleted event with message_id, deleted_by, version
290
+ *
291
+ * Idempotent: if already deleted, returns success with no new event.
292
+ *
293
+ * @throws MessageNotFoundError if message doesn't exist
294
+ * @throws VersionConflictError if expectedVersion provided and mismatched
295
+ */
296
+ export function tombstoneDeleteMessage(
297
+ options: TombstoneDeleteOptions
298
+ ): TombstoneDeleteResult {
299
+ const { db, messageId, actor, expectedVersion } = options;
300
+
301
+ if (!actor || actor.trim().length === 0) {
302
+ throw new Error("Actor must be a non-empty string");
303
+ }
304
+
305
+ const result = db.transaction(() => {
306
+ // 1. Get and validate message
307
+ const message = getMessageOrThrow(db, messageId);
308
+
309
+ // 2. Check idempotency: if already deleted, return success
310
+ if (message.deleted_at !== null) {
311
+ // Return current state without new event (idempotent)
312
+ return {
313
+ messageId,
314
+ version: message.version,
315
+ eventId: 0, // No new event
316
+ alreadyDeleted: true,
317
+ };
318
+ }
319
+
320
+ // 3. Check version if provided
321
+ checkVersion(message, expectedVersion);
322
+
323
+ const newVersion = message.version + 1;
324
+ const now = new Date().toISOString();
325
+
326
+ // 4. Update message state (tombstone)
327
+ db.run(
328
+ `UPDATE messages
329
+ SET deleted_at = ?, deleted_by = ?, content_raw = ?, edited_at = ?, version = ?
330
+ WHERE id = ?`,
331
+ [now, actor, TOMBSTONE_CONTENT, now, newVersion, messageId]
332
+ );
333
+
334
+ // 5. Emit event
335
+ const eventId = insertEvent({
336
+ db,
337
+ name: "message.deleted",
338
+ scopes: {
339
+ channel_id: message.channel_id,
340
+ topic_id: message.topic_id,
341
+ },
342
+ entity: { type: "message", id: messageId },
343
+ data: {
344
+ message_id: messageId,
345
+ deleted_by: actor,
346
+ version: newVersion,
347
+ },
348
+ });
349
+
350
+ return { messageId, version: newVersion, eventId, alreadyDeleted: false };
351
+ })();
352
+
353
+ return {
354
+ messageId: result.messageId,
355
+ version: result.version,
356
+ eventId: result.eventId,
357
+ };
358
+ }
359
+
360
+ // ─────────────────────────────────────────────────────────────────────────────
361
+ // Retopic Message
362
+ // ─────────────────────────────────────────────────────────────────────────────
363
+
364
+ /**
365
+ * Move message(s) to a different topic (same channel only).
366
+ *
367
+ * Modes:
368
+ * - "one": only the specified message
369
+ * - "later": message and all subsequent messages in the topic (by id order)
370
+ * - "all": all messages in the topic (regardless of position)
371
+ *
372
+ * For each affected message:
373
+ * - Updates topic_id
374
+ * - Increments version
375
+ * - Emits message.moved_topic event
376
+ *
377
+ * Idempotent: if message(s) already in target topic, returns success with no changes.
378
+ *
379
+ * @throws MessageNotFoundError if anchor message doesn't exist
380
+ * @throws TopicNotFoundError if target topic doesn't exist
381
+ * @throws CrossChannelMoveError if target topic is in different channel
382
+ * @throws VersionConflictError if expectedVersion provided and mismatched (for anchor message only)
383
+ */
384
+ export function retopicMessage(
385
+ options: RetopicMessageOptions
386
+ ): RetopicMessageResult {
387
+ const { db, messageId, toTopicId, mode, expectedVersion } = options;
388
+
389
+ const result = db.transaction(() => {
390
+ // 1. Get and validate anchor message
391
+ const anchorMessage = getMessageOrThrow(db, messageId);
392
+
393
+ // 2. Idempotent: if already in target topic (mode=one), return success
394
+ if (mode === "one" && anchorMessage.topic_id === toTopicId) {
395
+ return { affectedCount: 0, affectedMessages: [] };
396
+ }
397
+
398
+ // 3. Check version if provided (only for anchor message)
399
+ checkVersion(anchorMessage, expectedVersion);
400
+
401
+ // 4. Get and validate target topic
402
+ const targetTopic = getTopicOrThrow(db, toTopicId);
403
+
404
+ // 5. Enforce same-channel constraint
405
+ if (anchorMessage.channel_id !== targetTopic.channel_id) {
406
+ throw new CrossChannelMoveError(
407
+ messageId,
408
+ anchorMessage.channel_id,
409
+ targetTopic.channel_id
410
+ );
411
+ }
412
+
413
+ const oldTopicId = anchorMessage.topic_id;
414
+
415
+ // Idempotent: if already in target topic (any mode), return
416
+ if (oldTopicId === toTopicId) {
417
+ return { affectedCount: 0, affectedMessages: [] };
418
+ }
419
+
420
+ // 6. Determine affected messages based on mode
421
+ let affectedMessageIds: string[];
422
+
423
+ switch (mode) {
424
+ case "one":
425
+ affectedMessageIds = [messageId];
426
+ break;
427
+
428
+ case "later":
429
+ // Messages with id >= anchor id in the same topic (by id order)
430
+ affectedMessageIds = db
431
+ .query<{ id: string }, [string, string]>(
432
+ `SELECT id FROM messages
433
+ WHERE topic_id = ? AND id >= ?
434
+ ORDER BY id ASC`
435
+ )
436
+ .all(oldTopicId, messageId)
437
+ .map((row) => row.id);
438
+ break;
439
+
440
+ case "all":
441
+ // All messages in the topic
442
+ affectedMessageIds = db
443
+ .query<{ id: string }, [string]>(
444
+ `SELECT id FROM messages
445
+ WHERE topic_id = ?
446
+ ORDER BY id ASC`
447
+ )
448
+ .all(oldTopicId)
449
+ .map((row) => row.id);
450
+ break;
451
+
452
+ default:
453
+ throw new Error(`Invalid retopic mode: ${mode}`);
454
+ }
455
+
456
+ if (affectedMessageIds.length === 0) {
457
+ return { affectedCount: 0, affectedMessages: [] };
458
+ }
459
+
460
+ const affectedMessages: Array<{
461
+ messageId: string;
462
+ version: number;
463
+ eventId: number;
464
+ }> = [];
465
+
466
+ const channelId = anchorMessage.channel_id;
467
+
468
+ // 7. For each affected message: update state + emit event
469
+ for (const affectedId of affectedMessageIds) {
470
+ // Get current version
471
+ const current = db
472
+ .query<{ version: number }, [string]>(
473
+ "SELECT version FROM messages WHERE id = ?"
474
+ )
475
+ .get(affectedId);
476
+
477
+ if (!current) continue; // Shouldn't happen, but be defensive
478
+
479
+ const newVersion = current.version + 1;
480
+
481
+ // Update topic_id and version
482
+ db.run(
483
+ `UPDATE messages
484
+ SET topic_id = ?, version = ?
485
+ WHERE id = ?`,
486
+ [toTopicId, newVersion, affectedId]
487
+ );
488
+
489
+ // Emit event
490
+ const eventId = insertEvent({
491
+ db,
492
+ name: "message.moved_topic",
493
+ scopes: {
494
+ channel_id: channelId,
495
+ topic_id: oldTopicId,
496
+ topic_id2: toTopicId,
497
+ },
498
+ entity: { type: "message", id: affectedId },
499
+ data: {
500
+ message_id: affectedId,
501
+ old_topic_id: oldTopicId,
502
+ new_topic_id: toTopicId,
503
+ channel_id: channelId,
504
+ mode,
505
+ version: newVersion,
506
+ },
507
+ });
508
+
509
+ affectedMessages.push({
510
+ messageId: affectedId,
511
+ version: newVersion,
512
+ eventId,
513
+ });
514
+ }
515
+
516
+ return {
517
+ affectedCount: affectedMessages.length,
518
+ affectedMessages,
519
+ };
520
+ })();
521
+
522
+ return result;
523
+ }