@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.
- package/migrations/0001_schema_v1.sql +156 -0
- package/migrations/0001_schema_v1_fts.sql +32 -0
- package/package.json +27 -0
- package/src/events.ts +365 -0
- package/src/index.ts +316 -0
- package/src/messageMutations.ts +523 -0
- package/src/queries.ts +418 -0
package/src/queries.ts
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical read query helpers for @agentlip/kernel
|
|
3
|
+
*
|
|
4
|
+
* Implements bd-16d.2.3: index-backed read queries for channels, topics, messages, attachments.
|
|
5
|
+
* Designed to be usable by hub/cli later.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Database } from "bun:sqlite";
|
|
9
|
+
|
|
10
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
11
|
+
// Types
|
|
12
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
export interface Channel {
|
|
15
|
+
id: string;
|
|
16
|
+
name: string;
|
|
17
|
+
description: string | null;
|
|
18
|
+
created_at: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface Topic {
|
|
22
|
+
id: string;
|
|
23
|
+
channel_id: string;
|
|
24
|
+
title: string;
|
|
25
|
+
created_at: string;
|
|
26
|
+
updated_at: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface Message {
|
|
30
|
+
id: string;
|
|
31
|
+
topic_id: string;
|
|
32
|
+
channel_id: string;
|
|
33
|
+
sender: string;
|
|
34
|
+
content_raw: string;
|
|
35
|
+
version: number;
|
|
36
|
+
created_at: string;
|
|
37
|
+
edited_at: string | null;
|
|
38
|
+
deleted_at: string | null;
|
|
39
|
+
deleted_by: string | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface TopicAttachment {
|
|
43
|
+
id: string;
|
|
44
|
+
topic_id: string;
|
|
45
|
+
kind: string;
|
|
46
|
+
key: string | null;
|
|
47
|
+
value_json: Record<string, unknown>;
|
|
48
|
+
dedupe_key: string;
|
|
49
|
+
source_message_id: string | null;
|
|
50
|
+
created_at: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface TopicAttachmentRow {
|
|
54
|
+
id: string;
|
|
55
|
+
topic_id: string;
|
|
56
|
+
kind: string;
|
|
57
|
+
key: string | null;
|
|
58
|
+
value_json: string;
|
|
59
|
+
dedupe_key: string;
|
|
60
|
+
source_message_id: string | null;
|
|
61
|
+
created_at: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface PaginationOptions {
|
|
65
|
+
limit?: number;
|
|
66
|
+
offset?: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface MessageQueryOptions {
|
|
70
|
+
channelId?: string;
|
|
71
|
+
topicId?: string;
|
|
72
|
+
limit?: number;
|
|
73
|
+
beforeId?: string;
|
|
74
|
+
afterId?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface ListResult<T> {
|
|
78
|
+
items: T[];
|
|
79
|
+
hasMore: boolean;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
83
|
+
// Channel Queries
|
|
84
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* List all channels.
|
|
88
|
+
*
|
|
89
|
+
* @param db - Database instance
|
|
90
|
+
* @returns Array of channels ordered by name
|
|
91
|
+
*/
|
|
92
|
+
export function listChannels(db: Database): Channel[] {
|
|
93
|
+
return db
|
|
94
|
+
.query<Channel, []>(`
|
|
95
|
+
SELECT id, name, description, created_at
|
|
96
|
+
FROM channels
|
|
97
|
+
ORDER BY name ASC
|
|
98
|
+
`)
|
|
99
|
+
.all();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Get a channel by ID.
|
|
104
|
+
*
|
|
105
|
+
* @param db - Database instance
|
|
106
|
+
* @param channelId - Channel ID
|
|
107
|
+
* @returns Channel or null if not found
|
|
108
|
+
*/
|
|
109
|
+
export function getChannelById(db: Database, channelId: string): Channel | null {
|
|
110
|
+
return db
|
|
111
|
+
.query<Channel, [string]>(`
|
|
112
|
+
SELECT id, name, description, created_at
|
|
113
|
+
FROM channels
|
|
114
|
+
WHERE id = ?
|
|
115
|
+
`)
|
|
116
|
+
.get(channelId) ?? null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Get a channel by name.
|
|
121
|
+
*
|
|
122
|
+
* @param db - Database instance
|
|
123
|
+
* @param name - Channel name
|
|
124
|
+
* @returns Channel or null if not found
|
|
125
|
+
*/
|
|
126
|
+
export function getChannelByName(db: Database, name: string): Channel | null {
|
|
127
|
+
return db
|
|
128
|
+
.query<Channel, [string]>(`
|
|
129
|
+
SELECT id, name, description, created_at
|
|
130
|
+
FROM channels
|
|
131
|
+
WHERE name = ?
|
|
132
|
+
`)
|
|
133
|
+
.get(name) ?? null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
137
|
+
// Topic Queries
|
|
138
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* List topics by channel with pagination.
|
|
142
|
+
*
|
|
143
|
+
* Uses idx_topics_channel index (channel_id, updated_at DESC).
|
|
144
|
+
*
|
|
145
|
+
* @param db - Database instance
|
|
146
|
+
* @param channelId - Channel ID
|
|
147
|
+
* @param pagination - Pagination options
|
|
148
|
+
* @returns List result with topics and hasMore flag
|
|
149
|
+
*/
|
|
150
|
+
export function listTopicsByChannel(
|
|
151
|
+
db: Database,
|
|
152
|
+
channelId: string,
|
|
153
|
+
pagination: PaginationOptions = {}
|
|
154
|
+
): ListResult<Topic> {
|
|
155
|
+
const { limit = 50, offset = 0 } = pagination;
|
|
156
|
+
const fetchLimit = limit + 1; // Fetch one extra to determine hasMore
|
|
157
|
+
|
|
158
|
+
const rows = db
|
|
159
|
+
.query<Topic, [string, number, number]>(`
|
|
160
|
+
SELECT id, channel_id, title, created_at, updated_at
|
|
161
|
+
FROM topics
|
|
162
|
+
WHERE channel_id = ?
|
|
163
|
+
ORDER BY updated_at DESC
|
|
164
|
+
LIMIT ? OFFSET ?
|
|
165
|
+
`)
|
|
166
|
+
.all(channelId, fetchLimit, offset);
|
|
167
|
+
|
|
168
|
+
const hasMore = rows.length > limit;
|
|
169
|
+
const items = hasMore ? rows.slice(0, limit) : rows;
|
|
170
|
+
|
|
171
|
+
return { items, hasMore };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Get a topic by ID.
|
|
176
|
+
*
|
|
177
|
+
* @param db - Database instance
|
|
178
|
+
* @param topicId - Topic ID
|
|
179
|
+
* @returns Topic or null if not found
|
|
180
|
+
*/
|
|
181
|
+
export function getTopicById(db: Database, topicId: string): Topic | null {
|
|
182
|
+
return db
|
|
183
|
+
.query<Topic, [string]>(`
|
|
184
|
+
SELECT id, channel_id, title, created_at, updated_at
|
|
185
|
+
FROM topics
|
|
186
|
+
WHERE id = ?
|
|
187
|
+
`)
|
|
188
|
+
.get(topicId) ?? null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Get a topic by channel ID and title.
|
|
193
|
+
*
|
|
194
|
+
* @param db - Database instance
|
|
195
|
+
* @param channelId - Channel ID
|
|
196
|
+
* @param title - Topic title
|
|
197
|
+
* @returns Topic or null if not found
|
|
198
|
+
*/
|
|
199
|
+
export function getTopicByTitle(db: Database, channelId: string, title: string): Topic | null {
|
|
200
|
+
return db
|
|
201
|
+
.query<Topic, [string, string]>(`
|
|
202
|
+
SELECT id, channel_id, title, created_at, updated_at
|
|
203
|
+
FROM topics
|
|
204
|
+
WHERE channel_id = ? AND title = ?
|
|
205
|
+
`)
|
|
206
|
+
.get(channelId, title) ?? null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
210
|
+
// Message Queries
|
|
211
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* List messages with flexible filtering and pagination.
|
|
215
|
+
*
|
|
216
|
+
* Supports querying by channel OR topic (at least one required).
|
|
217
|
+
* Uses idx_messages_topic or idx_messages_channel for efficient pagination.
|
|
218
|
+
*
|
|
219
|
+
* Pagination:
|
|
220
|
+
* - beforeId: get messages with id < beforeId (older messages)
|
|
221
|
+
* - afterId: get messages with id > afterId (newer messages)
|
|
222
|
+
* - If neither: gets latest messages
|
|
223
|
+
*
|
|
224
|
+
* @param db - Database instance
|
|
225
|
+
* @param options - Query options
|
|
226
|
+
* @returns List result with messages and hasMore flag
|
|
227
|
+
*/
|
|
228
|
+
export function listMessages(
|
|
229
|
+
db: Database,
|
|
230
|
+
options: MessageQueryOptions
|
|
231
|
+
): ListResult<Message> {
|
|
232
|
+
const { channelId, topicId, limit = 50, beforeId, afterId } = options;
|
|
233
|
+
|
|
234
|
+
if (!channelId && !topicId) {
|
|
235
|
+
throw new Error("At least one of channelId or topicId must be provided");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const fetchLimit = limit + 1;
|
|
239
|
+
const params: (string | number)[] = [];
|
|
240
|
+
const conditions: string[] = [];
|
|
241
|
+
|
|
242
|
+
// Scope condition
|
|
243
|
+
if (topicId) {
|
|
244
|
+
conditions.push("topic_id = ?");
|
|
245
|
+
params.push(topicId);
|
|
246
|
+
} else if (channelId) {
|
|
247
|
+
conditions.push("channel_id = ?");
|
|
248
|
+
params.push(channelId);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Cursor conditions
|
|
252
|
+
let orderDirection = "DESC";
|
|
253
|
+
|
|
254
|
+
if (beforeId) {
|
|
255
|
+
conditions.push("id < ?");
|
|
256
|
+
params.push(beforeId);
|
|
257
|
+
orderDirection = "DESC";
|
|
258
|
+
} else if (afterId) {
|
|
259
|
+
conditions.push("id > ?");
|
|
260
|
+
params.push(afterId);
|
|
261
|
+
orderDirection = "ASC";
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
params.push(fetchLimit);
|
|
265
|
+
|
|
266
|
+
const sql = `
|
|
267
|
+
SELECT id, topic_id, channel_id, sender, content_raw, version,
|
|
268
|
+
created_at, edited_at, deleted_at, deleted_by
|
|
269
|
+
FROM messages
|
|
270
|
+
WHERE ${conditions.join(" AND ")}
|
|
271
|
+
ORDER BY id ${orderDirection}
|
|
272
|
+
LIMIT ?
|
|
273
|
+
`;
|
|
274
|
+
|
|
275
|
+
let rows = db.query<Message, (string | number)[]>(sql).all(...params);
|
|
276
|
+
|
|
277
|
+
// If we fetched with ASC order (afterId), reverse to get consistent DESC order
|
|
278
|
+
if (afterId) {
|
|
279
|
+
rows = rows.reverse();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const hasMore = rows.length > limit;
|
|
283
|
+
const items = hasMore ? rows.slice(0, limit) : rows;
|
|
284
|
+
|
|
285
|
+
return { items, hasMore };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Tail messages (get latest N messages from a topic or channel).
|
|
290
|
+
*
|
|
291
|
+
* Convenience wrapper for listMessages optimized for "tail" use case.
|
|
292
|
+
*
|
|
293
|
+
* @param db - Database instance
|
|
294
|
+
* @param topicId - Topic ID
|
|
295
|
+
* @param limit - Maximum messages to return (default 50)
|
|
296
|
+
* @returns Array of messages, newest first
|
|
297
|
+
*/
|
|
298
|
+
export function tailMessages(db: Database, topicId: string, limit = 50): Message[] {
|
|
299
|
+
const result = listMessages(db, { topicId, limit });
|
|
300
|
+
return result.items;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Get a message by ID.
|
|
305
|
+
*
|
|
306
|
+
* @param db - Database instance
|
|
307
|
+
* @param messageId - Message ID
|
|
308
|
+
* @returns Message or null if not found
|
|
309
|
+
*/
|
|
310
|
+
export function getMessageById(db: Database, messageId: string): Message | null {
|
|
311
|
+
return db
|
|
312
|
+
.query<Message, [string]>(`
|
|
313
|
+
SELECT id, topic_id, channel_id, sender, content_raw, version,
|
|
314
|
+
created_at, edited_at, deleted_at, deleted_by
|
|
315
|
+
FROM messages
|
|
316
|
+
WHERE id = ?
|
|
317
|
+
`)
|
|
318
|
+
.get(messageId) ?? null;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
322
|
+
// Attachment Queries
|
|
323
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* List attachments for a topic.
|
|
327
|
+
*
|
|
328
|
+
* Uses idx_attachments_topic index (topic_id, created_at DESC).
|
|
329
|
+
*
|
|
330
|
+
* @param db - Database instance
|
|
331
|
+
* @param topicId - Topic ID
|
|
332
|
+
* @param kind - Optional filter by attachment kind
|
|
333
|
+
* @returns Array of attachments with parsed value_json
|
|
334
|
+
*/
|
|
335
|
+
export function listTopicAttachments(
|
|
336
|
+
db: Database,
|
|
337
|
+
topicId: string,
|
|
338
|
+
kind?: string
|
|
339
|
+
): TopicAttachment[] {
|
|
340
|
+
let sql = `
|
|
341
|
+
SELECT id, topic_id, kind, key, value_json, dedupe_key, source_message_id, created_at
|
|
342
|
+
FROM topic_attachments
|
|
343
|
+
WHERE topic_id = ?
|
|
344
|
+
`;
|
|
345
|
+
const params: string[] = [topicId];
|
|
346
|
+
|
|
347
|
+
if (kind) {
|
|
348
|
+
sql += " AND kind = ?";
|
|
349
|
+
params.push(kind);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
sql += " ORDER BY created_at DESC";
|
|
353
|
+
|
|
354
|
+
const rows = db.query<TopicAttachmentRow, string[]>(sql).all(...params);
|
|
355
|
+
|
|
356
|
+
// Parse value_json for each row
|
|
357
|
+
return rows.map((row) => ({
|
|
358
|
+
...row,
|
|
359
|
+
value_json: JSON.parse(row.value_json),
|
|
360
|
+
}));
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Get an attachment by ID.
|
|
365
|
+
*
|
|
366
|
+
* @param db - Database instance
|
|
367
|
+
* @param attachmentId - Attachment ID
|
|
368
|
+
* @returns Attachment with parsed value_json or null if not found
|
|
369
|
+
*/
|
|
370
|
+
export function getAttachmentById(db: Database, attachmentId: string): TopicAttachment | null {
|
|
371
|
+
const row = db
|
|
372
|
+
.query<TopicAttachmentRow, [string]>(`
|
|
373
|
+
SELECT id, topic_id, kind, key, value_json, dedupe_key, source_message_id, created_at
|
|
374
|
+
FROM topic_attachments
|
|
375
|
+
WHERE id = ?
|
|
376
|
+
`)
|
|
377
|
+
.get(attachmentId);
|
|
378
|
+
|
|
379
|
+
if (!row) return null;
|
|
380
|
+
|
|
381
|
+
return {
|
|
382
|
+
...row,
|
|
383
|
+
value_json: JSON.parse(row.value_json),
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Find attachment by dedupe key (for idempotent insert checks).
|
|
389
|
+
*
|
|
390
|
+
* @param db - Database instance
|
|
391
|
+
* @param topicId - Topic ID
|
|
392
|
+
* @param kind - Attachment kind
|
|
393
|
+
* @param key - Attachment key (optional, use empty string if null)
|
|
394
|
+
* @param dedupeKey - Dedupe key
|
|
395
|
+
* @returns Attachment or null if not found
|
|
396
|
+
*/
|
|
397
|
+
export function findAttachmentByDedupeKey(
|
|
398
|
+
db: Database,
|
|
399
|
+
topicId: string,
|
|
400
|
+
kind: string,
|
|
401
|
+
key: string | null,
|
|
402
|
+
dedupeKey: string
|
|
403
|
+
): TopicAttachment | null {
|
|
404
|
+
const row = db
|
|
405
|
+
.query<TopicAttachmentRow, [string, string, string, string]>(`
|
|
406
|
+
SELECT id, topic_id, kind, key, value_json, dedupe_key, source_message_id, created_at
|
|
407
|
+
FROM topic_attachments
|
|
408
|
+
WHERE topic_id = ? AND kind = ? AND COALESCE(key, '') = ? AND dedupe_key = ?
|
|
409
|
+
`)
|
|
410
|
+
.get(topicId, kind, key ?? "", dedupeKey);
|
|
411
|
+
|
|
412
|
+
if (!row) return null;
|
|
413
|
+
|
|
414
|
+
return {
|
|
415
|
+
...row,
|
|
416
|
+
value_json: JSON.parse(row.value_json),
|
|
417
|
+
};
|
|
418
|
+
}
|