@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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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,
|
|
102
|
-
updated_at, status, summary, model,
|
|
103
|
-
trigger_conversation_id, trigger_event_id,
|
|
104
|
-
trigger_purpose, trigger_resume_hint
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
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
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
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
|
+
`;
|
package/dist/migrations/index.js
CHANGED
|
@@ -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
|
];
|