@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/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
+ }