@kernl-sdk/pg 0.1.9

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,460 @@
1
+ import assert from "assert";
2
+ import type { Pool, PoolClient } from "pg";
3
+
4
+ import {
5
+ SCHEMA_NAME,
6
+ NewThreadCodec,
7
+ ThreadEventRecordCodec,
8
+ type ThreadRecord,
9
+ type ThreadEventRecord,
10
+ } from "@kernl-sdk/storage";
11
+ import { Thread, type ThreadEvent } from "kernl/internal";
12
+ import {
13
+ Context,
14
+ type AgentRegistry,
15
+ type ModelRegistry,
16
+ type ThreadStore,
17
+ type NewThread,
18
+ type ThreadUpdate,
19
+ type ThreadInclude,
20
+ type ThreadListOptions,
21
+ type ThreadHistoryOptions,
22
+ } from "kernl";
23
+
24
+ /**
25
+ * PostgreSQL Thread store implementation.
26
+ */
27
+ export class PGThreadStore implements ThreadStore {
28
+ private db: Pool | PoolClient;
29
+ private registries: { agents: AgentRegistry; models: ModelRegistry } | null;
30
+
31
+ constructor(db: Pool | PoolClient) {
32
+ this.db = db;
33
+ this.registries = null;
34
+ }
35
+
36
+ /**
37
+ * Bind runtime registries for hydrating Thread instances.
38
+ *
39
+ * (TODO): move into abstract ThreadStore class
40
+ */
41
+ bind(registries: { agents: AgentRegistry; models: ModelRegistry }): void {
42
+ this.registries = registries;
43
+ }
44
+
45
+ /**
46
+ * Get a thread by id.
47
+ */
48
+ async get(tid: string, include?: ThreadInclude): Promise<Thread | null> {
49
+ // JOIN with thread_events if include.history
50
+ if (include?.history) {
51
+ const opts =
52
+ typeof include.history === "object" ? include.history : undefined;
53
+
54
+ const params: any[] = [tid];
55
+ let index = 2;
56
+ let eventFilter = "";
57
+
58
+ if (opts?.after !== undefined) {
59
+ eventFilter += ` AND e.seq > $${index++}`;
60
+ params.push(opts.after);
61
+ }
62
+
63
+ if (opts?.kinds && opts.kinds.length > 0) {
64
+ eventFilter += ` AND e.kind = ANY($${index++})`;
65
+ params.push(opts.kinds);
66
+ }
67
+
68
+ const order = opts?.order ?? "asc";
69
+ const limit = opts?.limit ? ` LIMIT ${opts.limit}` : "";
70
+
71
+ const query = `
72
+ SELECT
73
+ t.*,
74
+ e.id as event_id,
75
+ e.tid as event_tid,
76
+ e.seq,
77
+ e.kind as event_kind,
78
+ e.timestamp,
79
+ e.data,
80
+ e.metadata as event_metadata
81
+ FROM ${SCHEMA_NAME}.threads t
82
+ LEFT JOIN ${SCHEMA_NAME}.thread_events e ON t.id = e.tid${eventFilter}
83
+ WHERE t.id = $1
84
+ ORDER BY e.seq ${order.toUpperCase()}
85
+ ${limit}
86
+ `;
87
+
88
+ const result = await this.db.query(query, params);
89
+
90
+ if (result.rows.length === 0) {
91
+ return null;
92
+ }
93
+
94
+ // first row has thread data (all rows have same thread data)
95
+ const first = result.rows[0];
96
+ const record: ThreadRecord = {
97
+ id: first.id,
98
+ namespace: first.namespace,
99
+ agent_id: first.agent_id,
100
+ model: first.model,
101
+ context: first.context,
102
+ tick: first.tick,
103
+ state: first.state,
104
+ parent_task_id: first.parent_task_id,
105
+ metadata: first.metadata,
106
+ created_at: first.created_at,
107
+ updated_at: first.updated_at,
108
+ };
109
+
110
+ // collect events from all rows (skip rows where event_id is null)
111
+ const events: ThreadEvent[] = result.rows
112
+ .filter((row) => row.event_id !== null)
113
+ .map((row) =>
114
+ ThreadEventRecordCodec.decode({
115
+ id: row.event_id,
116
+ tid: row.event_tid,
117
+ seq: row.seq,
118
+ kind: row.event_kind,
119
+ timestamp: Number(row.timestamp), // pg returns BIGINT as string by default, normalize to number
120
+ data: row.data,
121
+ metadata: row.event_metadata,
122
+ } as ThreadEventRecord),
123
+ );
124
+
125
+ try {
126
+ return this.hydrate({ record, events });
127
+ } catch (error) {
128
+ return null;
129
+ }
130
+ }
131
+
132
+ // simple query without events
133
+ const result = await this.db.query<ThreadRecord>(
134
+ `SELECT * FROM ${SCHEMA_NAME}.threads WHERE id = $1`,
135
+ [tid],
136
+ );
137
+
138
+ if (result.rows.length === 0) {
139
+ return null;
140
+ }
141
+
142
+ try {
143
+ return this.hydrate({ record: result.rows[0] });
144
+ } catch (error) {
145
+ return null;
146
+ }
147
+ }
148
+
149
+ /**
150
+ * List threads matching the filter.
151
+ */
152
+ async list(options?: ThreadListOptions): Promise<Thread[]> {
153
+ let query = `SELECT * FROM ${SCHEMA_NAME}.threads`;
154
+ const values: any[] = [];
155
+ let paramIndex = 1;
156
+
157
+ // build WHERE clause
158
+ const conditions: string[] = [];
159
+ if (options?.filter) {
160
+ const {
161
+ state,
162
+ agentId,
163
+ parentTaskId,
164
+ createdAfter,
165
+ createdBefore,
166
+ namespace,
167
+ } = options.filter;
168
+
169
+ if (namespace) {
170
+ conditions.push(`namespace = $${paramIndex++}`);
171
+ values.push(namespace);
172
+ }
173
+
174
+ if (state) {
175
+ if (Array.isArray(state)) {
176
+ conditions.push(`state = ANY($${paramIndex++})`);
177
+ values.push(state);
178
+ } else {
179
+ conditions.push(`state = $${paramIndex++}`);
180
+ values.push(state);
181
+ }
182
+ }
183
+
184
+ if (agentId) {
185
+ conditions.push(`agent_id = $${paramIndex++}`);
186
+ values.push(agentId);
187
+ }
188
+
189
+ if (parentTaskId) {
190
+ conditions.push(`parent_task_id = $${paramIndex++}`);
191
+ values.push(parentTaskId);
192
+ }
193
+
194
+ if (createdAfter) {
195
+ conditions.push(`created_at > $${paramIndex++}`);
196
+ values.push(createdAfter.getTime());
197
+ }
198
+
199
+ if (createdBefore) {
200
+ conditions.push(`created_at < $${paramIndex++}`);
201
+ values.push(createdBefore.getTime());
202
+ }
203
+ }
204
+
205
+ if (conditions.length > 0) {
206
+ query += ` WHERE ${conditions.join(" AND ")}`;
207
+ }
208
+
209
+ // build ORDER BY clause
210
+ const orderClauses: string[] = [];
211
+ if (options?.order?.createdAt) {
212
+ orderClauses.push(`created_at ${options.order.createdAt.toUpperCase()}`);
213
+ }
214
+ if (options?.order?.updatedAt) {
215
+ orderClauses.push(`updated_at ${options.order.updatedAt.toUpperCase()}`);
216
+ }
217
+ if (orderClauses.length > 0) {
218
+ query += ` ORDER BY ${orderClauses.join(", ")}`;
219
+ } else {
220
+ // default: most recent first
221
+ query += ` ORDER BY created_at DESC`;
222
+ }
223
+
224
+ if (options?.limit) {
225
+ query += ` LIMIT $${paramIndex++}`;
226
+ values.push(options.limit);
227
+ }
228
+
229
+ if (options?.offset) {
230
+ query += ` OFFSET $${paramIndex++}`;
231
+ values.push(options.offset);
232
+ }
233
+
234
+ const result = await this.db.query<ThreadRecord>(query, values);
235
+ return result.rows
236
+ .map((record) => {
237
+ try {
238
+ return this.hydrate({ record });
239
+ } catch (error) {
240
+ // Skip threads with non-existent agent/model (graceful degradation)
241
+ //
242
+ // (TODO): what do we want to do with this?
243
+ return null;
244
+ }
245
+ })
246
+ .filter((thread) => thread !== null);
247
+ }
248
+
249
+ /**
250
+ * Insert a new thread into the store.
251
+ */
252
+ async insert(thread: NewThread): Promise<Thread> {
253
+ const record = NewThreadCodec.encode(thread);
254
+
255
+ const result = await this.db.query<ThreadRecord>(
256
+ `INSERT INTO ${SCHEMA_NAME}.threads
257
+ (id, namespace, agent_id, model, context, tick, state, parent_task_id, metadata, created_at, updated_at)
258
+ VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7, $8, $9::jsonb, $10, $11)
259
+ RETURNING *`,
260
+ [
261
+ record.id,
262
+ record.namespace,
263
+ record.agent_id,
264
+ record.model,
265
+ record.context,
266
+ record.tick,
267
+ record.state,
268
+ record.parent_task_id,
269
+ record.metadata,
270
+ record.created_at,
271
+ record.updated_at,
272
+ ],
273
+ );
274
+
275
+ return this.hydrate({ record: result.rows[0] });
276
+ }
277
+
278
+ /**
279
+ * Update thread runtime state.
280
+ */
281
+ async update(tid: string, patch: ThreadUpdate): Promise<Thread> {
282
+ const updates: string[] = [];
283
+ const values: any[] = [];
284
+ let paramIndex = 1;
285
+
286
+ if (patch.tick !== undefined) {
287
+ updates.push(`tick = $${paramIndex++}`);
288
+ values.push(patch.tick);
289
+ }
290
+
291
+ if (patch.state !== undefined) {
292
+ updates.push(`state = $${paramIndex++}`);
293
+ values.push(patch.state);
294
+ }
295
+
296
+ if (patch.context !== undefined) {
297
+ updates.push(`context = $${paramIndex++}`);
298
+ // NOTE: Store the raw context value, not the Context wrapper.
299
+ //
300
+ // THis may change in the future depending on Context implementation.
301
+ values.push(JSON.stringify(patch.context.context));
302
+ }
303
+
304
+ if (patch.metadata !== undefined) {
305
+ updates.push(`metadata = $${paramIndex++}`);
306
+ values.push(patch.metadata ? JSON.stringify(patch.metadata) : null);
307
+ }
308
+
309
+ // always update `updated_at`
310
+ updates.push(`updated_at = $${paramIndex++}`);
311
+ values.push(Date.now());
312
+
313
+ values.push(tid); // WHERE id = $N
314
+
315
+ const result = await this.db.query<ThreadRecord>(
316
+ `UPDATE ${SCHEMA_NAME}.threads
317
+ SET ${updates.join(", ")}
318
+ WHERE id = $${paramIndex}
319
+ RETURNING *`,
320
+ values,
321
+ );
322
+
323
+ return this.hydrate({ record: result.rows[0] });
324
+ }
325
+
326
+ /**
327
+ * Delete a thread and cascade to thread_events.
328
+ */
329
+ async delete(tid: string): Promise<void> {
330
+ await this.db.query(`DELETE FROM ${SCHEMA_NAME}.threads WHERE id = $1`, [
331
+ tid,
332
+ ]);
333
+ }
334
+
335
+ /**
336
+ * Get the event history for a thread.
337
+ */
338
+ async history(
339
+ tid: string,
340
+ opts?: ThreadHistoryOptions,
341
+ ): Promise<ThreadEvent[]> {
342
+ let query = `SELECT * FROM ${SCHEMA_NAME}.thread_events WHERE tid = $1`;
343
+ const values: any[] = [tid];
344
+ let paramIndex = 2;
345
+
346
+ // - filter:seq -
347
+ if (opts?.after !== undefined) {
348
+ query += ` AND seq > $${paramIndex++}`;
349
+ values.push(opts.after);
350
+ }
351
+
352
+ // - filter:kind -
353
+ if (opts?.kinds && opts.kinds.length > 0) {
354
+ query += ` AND kind = ANY($${paramIndex++})`;
355
+ values.push(opts.kinds);
356
+ }
357
+
358
+ // - order -
359
+ const order = opts?.order ?? "asc";
360
+ query += ` ORDER BY seq ${order.toUpperCase()}`;
361
+
362
+ // - limit -
363
+ if (opts?.limit !== undefined) {
364
+ query += ` LIMIT $${paramIndex++}`;
365
+ values.push(opts.limit);
366
+ }
367
+
368
+ const result = await this.db.query<ThreadEventRecord>(query, values);
369
+
370
+ return result.rows.map((record) =>
371
+ ThreadEventRecordCodec.decode({
372
+ ...record,
373
+ // Normalize BIGINT (string) to number for zod schema
374
+ timestamp: Number(record.timestamp),
375
+ } as ThreadEventRecord),
376
+ );
377
+ }
378
+
379
+ /**
380
+ * Append events to the thread history.
381
+ *
382
+ * Semantics:
383
+ * - Guaranteed per-thread ordering via monotonically increasing `seq`
384
+ * - Idempotent on `(tid, event.id)`: duplicate ids MUST NOT create duplicate rows
385
+ * - Events maintain insertion order
386
+ *
387
+ * NOTE: Thread class manages monotonic seq and timestamp assignment, is the only entrypoint.
388
+ */
389
+ async append(events: ThreadEvent[]): Promise<void> {
390
+ if (events.length === 0) return;
391
+
392
+ const records = events.map((e) => ThreadEventRecordCodec.encode(e));
393
+
394
+ const values: any[] = [];
395
+ const placeholders: string[] = [];
396
+
397
+ let index = 1;
398
+ for (const record of records) {
399
+ placeholders.push(
400
+ `($${index++}, $${index++}, $${index++}, $${index++}, $${index++}, $${index++}::jsonb, $${index++}::jsonb)`,
401
+ );
402
+ values.push(
403
+ record.id,
404
+ record.tid,
405
+ record.seq,
406
+ record.kind,
407
+ record.timestamp,
408
+ record.data,
409
+ record.metadata,
410
+ );
411
+ }
412
+
413
+ // insert with ON CONFLICT DO NOTHING for idempotency
414
+ await this.db.query(
415
+ `INSERT INTO ${SCHEMA_NAME}.thread_events
416
+ (id, tid, seq, kind, timestamp, data, metadata)
417
+ VALUES ${placeholders.join(", ")}
418
+ ON CONFLICT (tid, id) DO NOTHING`,
419
+ values,
420
+ );
421
+ }
422
+
423
+ /**
424
+ * Hydrate a Thread instance from a database record.
425
+ */
426
+ hydrate(thread: { record: ThreadRecord; events?: ThreadEvent[] }): Thread {
427
+ assert(
428
+ this.registries,
429
+ "registries should be bound to storage in Kernl constructor",
430
+ );
431
+
432
+ const { record, events = [] } = thread;
433
+
434
+ const agent = this.registries.agents.get(record.agent_id);
435
+ const model = this.registries.models.get(record.model);
436
+
437
+ if (!agent || !model) {
438
+ throw new Error(
439
+ `Thread ${record.id} references non-existent agent/model (agent: ${record.agent_id}, model: ${record.model})`,
440
+ );
441
+ }
442
+
443
+ return new Thread({
444
+ agent,
445
+ history: events,
446
+ context: new Context(record.namespace, record.context),
447
+ model,
448
+ task: null, // TODO: load from TaskStore when it exists
449
+ tid: record.id,
450
+ namespace: record.namespace,
451
+ tick: record.tick,
452
+ state: record.state,
453
+ metadata: record.metadata,
454
+ createdAt: new Date(record.created_at),
455
+ updatedAt: new Date(record.updated_at),
456
+ storage: this, // pass storage reference so resumed thread can persist
457
+ persisted: true,
458
+ });
459
+ }
460
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "extends": "../../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src",
6
+ "baseUrl": ".",
7
+ "paths": {
8
+ "@/*": ["./src/*"],
9
+ "kernl": ["../kernl/src/index.ts"],
10
+ "kernl/internal": ["../kernl/src/internal.ts"]
11
+ }
12
+ },
13
+ "include": ["src/**/*"],
14
+ "exclude": ["node_modules", "dist"]
15
+ }
@@ -0,0 +1,16 @@
1
+ import { defineConfig } from "vitest/config";
2
+ import path from "path";
3
+
4
+ export default defineConfig({
5
+ test: {
6
+ globals: true,
7
+ environment: "node",
8
+ exclude: ["**/node_modules/**", "**/dist/**"],
9
+ fileParallelism: false,
10
+ },
11
+ resolve: {
12
+ alias: {
13
+ "@": path.resolve(__dirname, "./src"),
14
+ },
15
+ },
16
+ });