@kuralle-agents/postgres-store 0.12.0 → 0.13.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/README.md CHANGED
@@ -17,6 +17,7 @@ Three backend implementations — sessions, long-term memory, and pgvector simil
17
17
  **Key exports:**
18
18
 
19
19
  - **`PostgresSessionStore`** — `SessionStore` implementation for durable session persistence.
20
+ - **`PostgresTraceStore`** — independent native trace persistence and read API.
20
21
  - **`PostgresMemoryService`** — `MemoryService` implementation for cross-session long-term memory.
21
22
  - **`PostgresPersistentMemoryStore`** — `PersistentMemoryStore` for durable USER/MEMORY markdown blocks.
22
23
  - **`PgVectorStore`** — `VectorStoreCore` implementation using pgvector for similarity search.
@@ -38,6 +39,21 @@ const runtime = createRuntime({
38
39
  });
39
40
  ```
40
41
 
42
+ ## Trace store
43
+
44
+ ```ts
45
+ import { PostgresTraceStore } from '@kuralle-agents/postgres-store';
46
+
47
+ const traceStore = new PostgresTraceStore({ client: pool, retentionMs: 604_800_000 });
48
+ const runtime = createRuntime({
49
+ agents: [agent],
50
+ defaultAgentId: 'support',
51
+ tracing: { store: traceStore },
52
+ });
53
+ ```
54
+
55
+ Spans live in the separate `kuralle_trace_spans` table. Set `tableName` to override it.
56
+
41
57
  ## Store options
42
58
 
43
59
  - `tableName` (default: `'kuralle_sessions'`) — table to store sessions.
@@ -1,4 +1,4 @@
1
- import type { AuditListOptions, ConversationAuditEntry, Session, SessionStore } from '@kuralle-agents/core';
1
+ import { type AuditListOptions, type ConversationAuditEntry, type Session, type SessionStore } from '@kuralle-agents/core';
2
2
  import type { QueryResult } from 'pg';
3
3
  type PostgresClient = {
4
4
  query: (text: string, params?: unknown[]) => Promise<QueryResult>;
@@ -1,3 +1,4 @@
1
+ import { StaleWriteError, } from '@kuralle-agents/core';
1
2
  const defaultTable = 'kuralle_sessions';
2
3
  const defaultAuditTable = 'audit_entries';
3
4
  const reviveSession = (raw) => {
@@ -56,8 +57,10 @@ export class PostgresSessionStore {
56
57
  conversation_id TEXT NOT NULL DEFAULT '',
57
58
  channel_id TEXT NOT NULL DEFAULT 'web',
58
59
  data JSONB NOT NULL,
60
+ version INTEGER NOT NULL DEFAULT 0,
59
61
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
60
62
  )`);
63
+ await this.client.query(`ALTER TABLE ${this.table} ADD COLUMN IF NOT EXISTS version INTEGER NOT NULL DEFAULT 0`);
61
64
  await this.client.query(`ALTER TABLE ${this.table} ADD COLUMN IF NOT EXISTS conversation_id TEXT NOT NULL DEFAULT ''`);
62
65
  await this.client.query(`ALTER TABLE ${this.table} ADD COLUMN IF NOT EXISTS channel_id TEXT NOT NULL DEFAULT 'web'`);
63
66
  await this.client.query(`UPDATE ${this.table} SET conversation_id = id WHERE conversation_id = ''`);
@@ -74,14 +77,17 @@ export class PostgresSessionStore {
74
77
  }
75
78
  async get(id) {
76
79
  await this.ready;
77
- const result = await this.client.query(`SELECT data FROM ${this.table} WHERE id = $1`, [id]);
80
+ const result = await this.client.query(`SELECT data, version FROM ${this.table} WHERE id = $1`, [id]);
78
81
  if (!result.rows.length) {
79
82
  return null;
80
83
  }
81
84
  const raw = result.rows[0]?.data;
85
+ const version = result.rows[0]?.version;
82
86
  try {
83
87
  const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
84
- return reviveSession(parsed);
88
+ const session = reviveSession(parsed);
89
+ session.version = version ?? session.version ?? 0;
90
+ return session;
85
91
  }
86
92
  catch (error) {
87
93
  console.error('Failed to parse session data from Postgres', error);
@@ -93,11 +99,37 @@ export class PostgresSessionStore {
93
99
  session.updatedAt = new Date();
94
100
  session.conversationId = session.conversationId ?? session.id;
95
101
  session.channelId = session.channelId ?? 'web';
102
+ const expectedVersion = session.version ?? 0;
96
103
  const data = JSON.stringify(session);
97
- await this.client.query(`INSERT INTO ${this.table} (id, user_id, conversation_id, channel_id, data, updated_at)
98
- VALUES ($1, $2, $3, $4, $5::jsonb, NOW())
99
- ON CONFLICT (id)
100
- DO UPDATE SET data = EXCLUDED.data, user_id = EXCLUDED.user_id, conversation_id = EXCLUDED.conversation_id, channel_id = EXCLUDED.channel_id, updated_at = NOW()`, [session.id, session.userId ?? null, session.conversationId, session.channelId, data]);
104
+ const update = await this.client.query(`UPDATE ${this.table}
105
+ SET data = $5::jsonb,
106
+ user_id = $2,
107
+ conversation_id = $3,
108
+ channel_id = $4,
109
+ version = version + 1,
110
+ updated_at = NOW()
111
+ WHERE id = $1 AND version = $6`, [
112
+ session.id,
113
+ session.userId ?? null,
114
+ session.conversationId,
115
+ session.channelId,
116
+ data,
117
+ expectedVersion,
118
+ ]);
119
+ if ((update.rowCount ?? 0) > 0) {
120
+ return;
121
+ }
122
+ if (expectedVersion === 0) {
123
+ const insert = await this.client.query(`INSERT INTO ${this.table} (id, user_id, conversation_id, channel_id, data, version, updated_at)
124
+ VALUES ($1, $2, $3, $4, $5::jsonb, 1, NOW())
125
+ ON CONFLICT (id) DO NOTHING`, [session.id, session.userId ?? null, session.conversationId, session.channelId, data]);
126
+ if ((insert.rowCount ?? 0) > 0) {
127
+ return;
128
+ }
129
+ }
130
+ const current = await this.client.query(`SELECT version FROM ${this.table} WHERE id = $1`, [session.id]);
131
+ const actual = current.rows[0]?.version ?? 0;
132
+ throw new StaleWriteError(session.id, expectedVersion, actual);
101
133
  }
102
134
  async delete(id) {
103
135
  await this.ready;
@@ -0,0 +1,24 @@
1
+ import type { AgentSpan, AgentTrace, TraceListWindow, TraceStore } from '@kuralle-agents/core';
2
+ import type { QueryResult } from 'pg';
3
+ type PostgresClient = {
4
+ query: (text: string, params?: unknown[]) => Promise<QueryResult>;
5
+ };
6
+ export interface PostgresTraceStoreOptions {
7
+ client: PostgresClient;
8
+ tableName?: string;
9
+ autoMigrate?: boolean;
10
+ retentionMs?: number;
11
+ }
12
+ export declare class PostgresTraceStore implements TraceStore {
13
+ private readonly options;
14
+ private readonly table;
15
+ private readonly ready;
16
+ constructor(options: PostgresTraceStoreOptions);
17
+ write(span: AgentSpan): Promise<void>;
18
+ putSpan(span: AgentSpan): Promise<void>;
19
+ getTrace(traceId: string): Promise<AgentTrace | null>;
20
+ listTraces(sessionId: string, window?: TraceListWindow): Promise<AgentTrace[]>;
21
+ cleanup(maxAgeMs: number): Promise<number>;
22
+ private init;
23
+ }
24
+ export {};
@@ -0,0 +1,68 @@
1
+ import { traceFromSpans } from '@kuralle-agents/core/tracing';
2
+ export class PostgresTraceStore {
3
+ options;
4
+ table;
5
+ ready;
6
+ constructor(options) {
7
+ this.options = options;
8
+ this.table = normalizeTableName(options.tableName ?? 'kuralle_trace_spans');
9
+ this.ready = options.autoMigrate === false ? Promise.resolve() : this.init();
10
+ }
11
+ write(span) { return this.putSpan(span); }
12
+ async putSpan(span) {
13
+ await this.ready;
14
+ await this.options.client.query(`INSERT INTO ${this.table} (trace_id, span_id, session_id, started_at, payload)
15
+ VALUES ($1, $2, $3, $4, $5::jsonb)
16
+ ON CONFLICT (trace_id, span_id) DO UPDATE SET payload = EXCLUDED.payload, started_at = EXCLUDED.started_at`, [span.traceId, span.spanId, span.attributes.sessionId, new Date(span.startTime), JSON.stringify(span)]);
17
+ if (this.options.retentionMs !== undefined)
18
+ await this.cleanup(this.options.retentionMs);
19
+ }
20
+ async getTrace(traceId) {
21
+ await this.ready;
22
+ const result = await this.options.client.query(`SELECT payload FROM ${this.table} WHERE trace_id = $1 ORDER BY started_at ASC`, [traceId]);
23
+ return traceFromSpans(result.rows.map(parseSpan));
24
+ }
25
+ async listTraces(sessionId, window) {
26
+ await this.ready;
27
+ const params = [sessionId];
28
+ const having = [];
29
+ if (window?.from) {
30
+ params.push(window.from);
31
+ having.push(`MIN(started_at) >= $${params.length}`);
32
+ }
33
+ if (window?.to) {
34
+ params.push(window.to);
35
+ having.push(`MIN(started_at) <= $${params.length}`);
36
+ }
37
+ params.push(window?.limit ?? 100);
38
+ const ids = await this.options.client.query(`SELECT trace_id, MIN(started_at) AS trace_started_at FROM ${this.table}
39
+ WHERE session_id = $1 GROUP BY trace_id ${having.length > 0 ? `HAVING ${having.join(' AND ')}` : ''}
40
+ ORDER BY trace_started_at DESC LIMIT $${params.length}`, params);
41
+ const traces = await Promise.all(ids.rows.map((row) => this.getTrace(String(row.trace_id))));
42
+ return traces.filter((trace) => trace !== null);
43
+ }
44
+ async cleanup(maxAgeMs) {
45
+ await this.ready;
46
+ const result = await this.options.client.query(`DELETE FROM ${this.table} WHERE trace_id IN (
47
+ SELECT trace_id FROM ${this.table} GROUP BY trace_id HAVING MIN(started_at) < $1
48
+ )`, [new Date(Date.now() - maxAgeMs)]);
49
+ return result.rowCount ?? 0;
50
+ }
51
+ async init() {
52
+ await this.options.client.query(`CREATE TABLE IF NOT EXISTS ${this.table} (
53
+ trace_id TEXT NOT NULL, span_id TEXT NOT NULL, session_id TEXT NOT NULL,
54
+ started_at TIMESTAMPTZ NOT NULL, payload JSONB NOT NULL,
55
+ PRIMARY KEY (trace_id, span_id)
56
+ )`);
57
+ await this.options.client.query(`CREATE INDEX IF NOT EXISTS ${this.table.replace(/\./g, '_')}_session_started_idx
58
+ ON ${this.table} (session_id, started_at DESC)`);
59
+ }
60
+ }
61
+ function normalizeTableName(table) {
62
+ if (!/^[a-zA-Z0-9_.]+$/.test(table))
63
+ throw new Error(`Invalid table name: ${table}`);
64
+ return table;
65
+ }
66
+ function parseSpan(row) {
67
+ return (typeof row.payload === 'string' ? JSON.parse(row.payload) : row.payload);
68
+ }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  export { PostgresSessionStore } from './PostgresSessionStore.js';
2
2
  export type { PostgresStoreOptions } from './PostgresSessionStore.js';
3
+ export { PostgresTraceStore } from './PostgresTraceStore.js';
4
+ export type { PostgresTraceStoreOptions } from './PostgresTraceStore.js';
3
5
  export { PostgresMemoryService } from './PostgresMemoryService.js';
4
6
  export type { PostgresMemoryStoreOptions } from './PostgresMemoryService.js';
5
7
  export { PostgresPersistentMemoryStore } from './PostgresPersistentMemoryStore.js';
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { PostgresSessionStore } from './PostgresSessionStore.js';
2
+ export { PostgresTraceStore } from './PostgresTraceStore.js';
2
3
  export { PostgresMemoryService } from './PostgresMemoryService.js';
3
4
  export { PostgresPersistentMemoryStore } from './PostgresPersistentMemoryStore.js';
4
5
  export { PgVectorStore } from './PgVectorStore.js';
package/package.json CHANGED
@@ -4,9 +4,9 @@
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/kuralle/kuralle-agents.git",
7
- "directory": "packages/kuralle-postgres-store"
7
+ "directory": "packages/postgres-store"
8
8
  },
9
- "version": "0.12.0",
9
+ "version": "0.13.0",
10
10
  "description": "PostgreSQL-backed SessionStore for Kuralle",
11
11
  "type": "module",
12
12
  "main": "dist/index.js",
@@ -18,19 +18,19 @@
18
18
  }
19
19
  },
20
20
  "dependencies": {
21
- "@kuralle-agents/core": "0.12.0"
21
+ "@kuralle-agents/core": "0.13.0"
22
22
  },
23
23
  "peerDependencies": {
24
24
  "pg": "^8.0.0",
25
- "@kuralle-agents/core": "0.12.0",
26
- "@kuralle-agents/rag": "0.12.0"
25
+ "@kuralle-agents/core": "0.13.0",
26
+ "@kuralle-agents/rag": "0.13.0"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@types/node": "^20.11.0",
30
30
  "@types/pg": "^8.11.0",
31
31
  "pg": "^8.11.0",
32
32
  "typescript": "^5.3.0",
33
- "@kuralle-agents/rag": "0.12.0"
33
+ "@kuralle-agents/rag": "0.13.0"
34
34
  },
35
35
  "publishConfig": {
36
36
  "access": "public"