@hexis-ai/engram-server 0.15.0 → 0.16.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.
@@ -10,6 +10,12 @@ export interface SqlClient {
10
10
  <T = Record<string, unknown>>(strings: TemplateStringsArray, ...values: unknown[]): Promise<T[]>;
11
11
  /** Raw query for DDL / multi-statement strings. */
12
12
  unsafe: (query: string) => Promise<unknown>;
13
+ /**
14
+ * Open a transaction. Statements issued via the callback's `tx`
15
+ * argument commit together, or roll back together if the callback
16
+ * throws. Mirrors the `postgres` package's signature.
17
+ */
18
+ begin<T>(fn: (tx: SqlClient) => Promise<T>): Promise<T>;
13
19
  }
14
20
  export interface PostgresAdapterOptions {
15
21
  /** Workspace scope baked into every row. Required for multi-tenant isolation. */
@@ -57,63 +57,73 @@ export class PostgresAdapter {
57
57
  async appendEvents(sessionId, events) {
58
58
  if (events.length === 0)
59
59
  return;
60
- for (const ev of events) {
61
- await this.sql `
62
- INSERT INTO engram_events (workspace_id, session_id, seq, type, at, payload)
63
- VALUES (
64
- ${this.workspaceId},
65
- ${sessionId},
66
- ${ev.seq},
67
- ${ev.type},
68
- ${ev.at},
69
- ${ev}
70
- )
71
- ON CONFLICT (workspace_id, session_id, seq) DO NOTHING
72
- `;
73
- // Participant events also widen the session's participants array so
74
- // a one-shot listSessions can answer "who took part" without folding
75
- // events at read time. viewable_by widens too (participants ⊆ viewable_by).
76
- if (ev.type === "participant") {
77
- await this.sql `
78
- UPDATE engram_sessions
79
- SET participants = (
80
- SELECT ARRAY(SELECT DISTINCT unnest(participants || ARRAY[${ev.personId}]::text[]))
81
- ),
82
- viewable_by = (
83
- SELECT ARRAY(SELECT DISTINCT unnest(viewable_by || ARRAY[${ev.personId}]::text[]))
84
- )
85
- WHERE workspace_id = ${this.workspaceId} AND id = ${sessionId}
60
+ // Wrap the whole batch in a transaction so the event log and the
61
+ // denormalized session row (participants[] / viewable_by[] /
62
+ // updated_at) can never diverge. Previously each INSERT + UPDATE
63
+ // landed as a separate statement; a mid-batch failure left the
64
+ // materialized state out of sync with the ledger with no way to
65
+ // reconstruct it.
66
+ await this.sql.begin(async (tx) => {
67
+ for (const ev of events) {
68
+ await tx `
69
+ INSERT INTO engram_events (workspace_id, session_id, seq, type, at, payload)
70
+ VALUES (
71
+ ${this.workspaceId},
72
+ ${sessionId},
73
+ ${ev.seq},
74
+ ${ev.type},
75
+ ${ev.at},
76
+ ${ev}
77
+ )
78
+ ON CONFLICT (workspace_id, session_id, seq) DO NOTHING
86
79
  `;
80
+ // Participant events also widen the session's participants array
81
+ // so a one-shot listSessions can answer "who took part" without
82
+ // folding events at read time. viewable_by widens too
83
+ // (participants ⊆ viewable_by).
84
+ if (ev.type === "participant") {
85
+ await tx `
86
+ UPDATE engram_sessions
87
+ SET participants = (
88
+ SELECT ARRAY(SELECT DISTINCT unnest(participants || ARRAY[${ev.personId}]::text[]))
89
+ ),
90
+ viewable_by = (
91
+ SELECT ARRAY(SELECT DISTINCT unnest(viewable_by || ARRAY[${ev.personId}]::text[]))
92
+ )
93
+ WHERE workspace_id = ${this.workspaceId} AND id = ${sessionId}
94
+ `;
95
+ }
87
96
  }
88
- }
89
- // Bump updated_at once per batch so list-by-recent-activity stays
90
- // accurate. Using the latest event's `at` (not now()) keeps the
91
- // semantics deterministic for back-dated batches.
92
- const latest = events[events.length - 1];
93
- await this.sql `
94
- UPDATE engram_sessions
95
- SET updated_at = GREATEST(updated_at, ${latest.at}::timestamptz)
96
- WHERE workspace_id = ${this.workspaceId} AND id = ${sessionId}
97
- `;
97
+ // Bump updated_at once per batch so list-by-recent-activity stays
98
+ // accurate. Using the latest event's `at` (not now()) keeps the
99
+ // semantics deterministic for back-dated batches.
100
+ const latest = events[events.length - 1];
101
+ await tx `
102
+ UPDATE engram_sessions
103
+ SET updated_at = GREATEST(updated_at, ${latest.at}::timestamptz)
104
+ WHERE workspace_id = ${this.workspaceId} AND id = ${sessionId}
105
+ `;
106
+ });
98
107
  }
99
108
  async getSession(sessionId) {
100
109
  const rows = await this.sql `
101
- SELECT id, title, channel, participants, viewable_by, created_at,
102
- updated_at, status, summary, model,
103
- trigger_conversation_id, trigger_event_id,
104
- trigger_purpose, trigger_resume_hint
105
- FROM engram_sessions
106
- WHERE workspace_id = ${this.workspaceId} AND id = ${sessionId}
110
+ SELECT s.id, s.title, s.channel, s.participants, s.viewable_by,
111
+ s.created_at, s.updated_at, s.status, s.summary, s.model,
112
+ s.trigger_conversation_id, s.trigger_event_id,
113
+ s.trigger_purpose, s.trigger_resume_hint,
114
+ COALESCE(
115
+ (SELECT json_agg(e.payload ORDER BY e.seq)
116
+ FROM engram_events e
117
+ WHERE e.workspace_id = s.workspace_id AND e.session_id = s.id),
118
+ '[]'::json
119
+ ) AS events
120
+ FROM engram_sessions s
121
+ WHERE s.workspace_id = ${this.workspaceId} AND s.id = ${sessionId}
107
122
  LIMIT 1
108
123
  `;
109
124
  if (rows.length === 0)
110
125
  return null;
111
- const events = await this.sql `
112
- SELECT payload FROM engram_events
113
- WHERE workspace_id = ${this.workspaceId} AND session_id = ${sessionId}
114
- ORDER BY seq
115
- `;
116
- return foldEvents(toSessionRow(rows[0]), events.map((e) => e.payload), new Date());
126
+ return foldRowWithEvents(rows[0]);
117
127
  }
118
128
  async updateSession(sessionId, patch) {
119
129
  // Per-column "provided" flags drive a CASE WHEN per field: undefined
@@ -173,45 +183,78 @@ export class PostgresAdapter {
173
183
  const hasTrigger = opts.has_trigger === true;
174
184
  const noSummary = opts.no_summary === true;
175
185
  const updatedBefore = opts.updated_before ?? null;
186
+ // Single query: session columns + the per-session event payload
187
+ // array via a correlated subquery (uses the engram_events PK index
188
+ // (workspace_id, session_id, seq)). Replaces the prior pattern of
189
+ // SELECT id + N follow-up getSession() round-trips.
176
190
  const rows = await this.sql `
177
- SELECT id FROM engram_sessions
178
- WHERE workspace_id = ${this.workspaceId}
179
- AND (${channelFilter}::text IS NULL OR channel = ${channelFilter}::text)
180
- AND (${channelPrefix}::text IS NULL OR channel LIKE ${channelPrefix ? channelPrefix + "%" : null}::text)
181
- AND (${statusFilter}::text IS NULL OR status = ${statusFilter}::text)
182
- AND (${hasTrigger}::boolean = FALSE OR trigger_conversation_id IS NOT NULL)
183
- AND (${noSummary}::boolean = FALSE OR summary IS NULL)
184
- AND (${updatedBefore}::timestamptz IS NULL OR updated_at < ${updatedBefore}::timestamptz)
185
- ORDER BY updated_at DESC
191
+ SELECT s.id, s.title, s.channel, s.participants, s.viewable_by,
192
+ s.created_at, s.updated_at, s.status, s.summary, s.model,
193
+ s.trigger_conversation_id, s.trigger_event_id,
194
+ s.trigger_purpose, s.trigger_resume_hint,
195
+ COALESCE(
196
+ (SELECT json_agg(e.payload ORDER BY e.seq)
197
+ FROM engram_events e
198
+ WHERE e.workspace_id = s.workspace_id AND e.session_id = s.id),
199
+ '[]'::json
200
+ ) AS events
201
+ FROM engram_sessions s
202
+ WHERE s.workspace_id = ${this.workspaceId}
203
+ AND (${channelFilter}::text IS NULL OR s.channel = ${channelFilter}::text)
204
+ AND (${channelPrefix}::text IS NULL OR s.channel LIKE ${channelPrefix ? channelPrefix + "%" : null}::text)
205
+ AND (${statusFilter}::text IS NULL OR s.status = ${statusFilter}::text)
206
+ AND (${hasTrigger}::boolean = FALSE OR s.trigger_conversation_id IS NOT NULL)
207
+ AND (${noSummary}::boolean = FALSE OR s.summary IS NULL)
208
+ AND (${updatedBefore}::timestamptz IS NULL OR s.updated_at < ${updatedBefore}::timestamptz)
209
+ ORDER BY s.updated_at DESC
186
210
  LIMIT ${opts.limit}
187
211
  `;
188
- const sessions = await Promise.all(rows.map((r) => this.getSession(r.id)));
189
- return sessions.filter((s) => s !== null);
212
+ return rows.map(foldRowWithEvents);
190
213
  }
191
214
  async sessionsForPerson(personId, opts) {
192
215
  const channelFilter = opts.channel ?? null;
193
216
  const scope = opts.scope ?? "participant";
194
217
  // Identifier (column name) can't be parameterized in tagged templates,
195
- // so branch the query. Both arms are otherwise identical.
218
+ // so branch the query. Both arms are otherwise identical and share
219
+ // the same single-query fold as listSessions.
196
220
  const rows = scope === "viewable"
197
221
  ? await this.sql `
198
- SELECT id FROM engram_sessions
199
- WHERE workspace_id = ${this.workspaceId}
200
- AND viewable_by @> ARRAY[${personId}]::text[]
201
- AND (${channelFilter}::text IS NULL OR channel = ${channelFilter}::text)
202
- ORDER BY created_at DESC
222
+ SELECT s.id, s.title, s.channel, s.participants, s.viewable_by,
223
+ s.created_at, s.updated_at, s.status, s.summary, s.model,
224
+ s.trigger_conversation_id, s.trigger_event_id,
225
+ s.trigger_purpose, s.trigger_resume_hint,
226
+ COALESCE(
227
+ (SELECT json_agg(e.payload ORDER BY e.seq)
228
+ FROM engram_events e
229
+ WHERE e.workspace_id = s.workspace_id AND e.session_id = s.id),
230
+ '[]'::json
231
+ ) AS events
232
+ FROM engram_sessions s
233
+ WHERE s.workspace_id = ${this.workspaceId}
234
+ AND s.viewable_by @> ARRAY[${personId}]::text[]
235
+ AND (${channelFilter}::text IS NULL OR s.channel = ${channelFilter}::text)
236
+ ORDER BY s.created_at DESC
203
237
  LIMIT ${opts.limit}
204
238
  `
205
239
  : await this.sql `
206
- SELECT id FROM engram_sessions
207
- WHERE workspace_id = ${this.workspaceId}
208
- AND participants @> ARRAY[${personId}]::text[]
209
- AND (${channelFilter}::text IS NULL OR channel = ${channelFilter}::text)
210
- ORDER BY created_at DESC
240
+ SELECT s.id, s.title, s.channel, s.participants, s.viewable_by,
241
+ s.created_at, s.updated_at, s.status, s.summary, s.model,
242
+ s.trigger_conversation_id, s.trigger_event_id,
243
+ s.trigger_purpose, s.trigger_resume_hint,
244
+ COALESCE(
245
+ (SELECT json_agg(e.payload ORDER BY e.seq)
246
+ FROM engram_events e
247
+ WHERE e.workspace_id = s.workspace_id AND e.session_id = s.id),
248
+ '[]'::json
249
+ ) AS events
250
+ FROM engram_sessions s
251
+ WHERE s.workspace_id = ${this.workspaceId}
252
+ AND s.participants @> ARRAY[${personId}]::text[]
253
+ AND (${channelFilter}::text IS NULL OR s.channel = ${channelFilter}::text)
254
+ ORDER BY s.created_at DESC
211
255
  LIMIT ${opts.limit}
212
256
  `;
213
- const sessions = await Promise.all(rows.map((r) => this.getSession(r.id)));
214
- return sessions.filter((s) => s !== null);
257
+ return rows.map(foldRowWithEvents);
215
258
  }
216
259
  // --- Persons ------------------------------------------------------
217
260
  async createPerson(input) {
@@ -464,6 +507,11 @@ function toPersonInfo(r) {
464
507
  updated_at: isoString(r.updated_at),
465
508
  };
466
509
  }
510
+ /** Convenience wrapper used by every read path: lifts the joined row
511
+ * into a SessionRow + events and folds in a single step. */
512
+ function foldRowWithEvents(r) {
513
+ return foldEvents(toSessionRow(r), r.events ?? [], new Date());
514
+ }
467
515
  function toSessionRow(r) {
468
516
  return {
469
517
  id: r.id,
@@ -0,0 +1,2 @@
1
+ export declare const name = "0009-events-type-index";
2
+ export declare const sql = "\n-- Type-aware event index for callers that filter by event type\n-- (the most common is monet's conversation-repository, which fetches\n-- only `type='message'` events to materialize the chat transcript).\n--\n-- The existing PK `(workspace_id, session_id, seq)` lets PG find\n-- events for a session quickly, but it has to read every row to filter\n-- by type. This composite index trades a small write-side cost for an\n-- index-only scan on the typical \"messages for this session\" query.\n\nCREATE INDEX IF NOT EXISTS idx_engram_events_workspace_session_type\n ON engram_events (workspace_id, session_id, type);\n";
@@ -0,0 +1,14 @@
1
+ export const name = "0009-events-type-index";
2
+ export const sql = `
3
+ -- Type-aware event index for callers that filter by event type
4
+ -- (the most common is monet's conversation-repository, which fetches
5
+ -- only \`type='message'\` events to materialize the chat transcript).
6
+ --
7
+ -- The existing PK \`(workspace_id, session_id, seq)\` lets PG find
8
+ -- events for a session quickly, but it has to read every row to filter
9
+ -- by type. This composite index trades a small write-side cost for an
10
+ -- index-only scan on the typical "messages for this session" query.
11
+
12
+ CREATE INDEX IF NOT EXISTS idx_engram_events_workspace_session_type
13
+ ON engram_events (workspace_id, session_id, type);
14
+ `;
@@ -6,6 +6,7 @@ import * as m0005 from "./0005-session-updated-at";
6
6
  import * as m0006 from "./0006-auth";
7
7
  import * as m0007 from "./0007-orgs";
8
8
  import * as m0008 from "./0008-trigger-metadata";
9
+ import * as m0009 from "./0009-events-type-index";
9
10
  /**
10
11
  * Schema migrations, applied in array order. Add a new file under
11
12
  * `migrations/NNNN-<slug>.ts` exporting `name` and `sql`, then append it
@@ -22,4 +23,5 @@ export const MIGRATIONS = [
22
23
  { name: m0006.name, sql: m0006.sql },
23
24
  { name: m0007.name, sql: m0007.sql },
24
25
  { name: m0008.name, sql: m0008.sql },
26
+ { name: m0009.name, sql: m0009.sql },
25
27
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hexis-ai/engram-server",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Engram server: ingest agent session events, persist via a pluggable adapter, expose search.",
5
5
  "keywords": [
6
6
  "engram",