@hexis-ai/engram-server 0.14.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,
package/dist/admin.d.ts CHANGED
@@ -1,17 +1,17 @@
1
1
  import { Hono } from "hono";
2
- import { type KeyStore } from "./key-store";
2
+ import type { KeyStore } from "./key-store";
3
3
  import type { OrgStore } from "./org-store";
4
4
  export interface AdminOptions {
5
5
  /** Bearer token required for every /admin/v1 request. */
6
6
  token: string;
7
7
  keyStore: KeyStore;
8
8
  /**
9
- * Optional org store. When provided, mounts the orgs surface
10
- * (\`/admin/v1/orgs/*\` plus \`/admin/v1/orgs/:id/workspaces\` for
11
- * org-scoped workspace creation). Without it only the legacy
12
- * \`/admin/v1/workspaces\` endpoints are exposed.
9
+ * Org store. Required every admin endpoint is org-scoped.
10
+ * (The previous opt-out, where admin.ts also exposed a non-org
11
+ * `/admin/v1/workspaces` surface for `orgStore`-less deployments,
12
+ * was removed in v0.15.)
13
13
  */
14
- orgStore?: OrgStore;
14
+ orgStore: OrgStore;
15
15
  }
16
16
  interface Env {
17
17
  Variables: {
@@ -24,20 +24,20 @@ interface Env {
24
24
  * Auth model: a single platform-level bearer token
25
25
  * (\`ENGRAM_ADMIN_TOKEN\`). Never crosses with workspace api-keys.
26
26
  *
27
- * Surface, when orgStore is wired:
27
+ * Surface:
28
28
  *
29
- * POST /orgs create org
30
- * GET /orgs list orgs
31
- * GET /orgs/:id get org
32
- * DELETE /orgs/:id delete org (CASCADE)
33
- * POST /orgs/:id/members add member (email|userId)
29
+ * POST /orgs create org
30
+ * GET /orgs list orgs
31
+ * GET /orgs/:id get org
32
+ * DELETE /orgs/:id delete org (CASCADE)
33
+ * POST /orgs/:id/members add member (email|userId)
34
34
  * GET /orgs/:id/members
35
35
  * DELETE /orgs/:id/members/:userId
36
- * POST /orgs/:id/workspaces create + key (under org)
36
+ * POST /orgs/:id/workspaces create workspace + key
37
37
  * GET /orgs/:id/workspaces
38
- *
39
- * Plus the lower-level workspace + key endpoints from before
40
- * (\`/workspaces\`, \`/workspaces/:id/keys\`, ...).
38
+ * GET /orgs/:id/workspaces/:wsId/keys list workspace keys
39
+ * POST /orgs/:id/workspaces/:wsId/keys issue a new key
40
+ * DELETE /orgs/:id/workspaces/:wsId/keys/:keyId revoke a key
41
41
  */
42
42
  export declare function createAdminRouter(opts: AdminOptions): Hono<Env>;
43
43
  export {};
package/dist/admin.js CHANGED
@@ -1,5 +1,4 @@
1
1
  import { Hono } from "hono";
2
- import { isValidWorkspaceId } from "./key-store";
3
2
  import { createWorkspaceSchema, issueKeySchema, parseJsonBody } from "./schemas";
4
3
  import { addMember, createOrg, createWorkspaceUnderOrg, deleteOrg, getOrgOrThrow, OrgServiceError, removeMember, requireWorkspaceInOrg, revokeWorkspaceKey, } from "./services/orgs";
5
4
  /**
@@ -8,23 +7,25 @@ import { addMember, createOrg, createWorkspaceUnderOrg, deleteOrg, getOrgOrThrow
8
7
  * Auth model: a single platform-level bearer token
9
8
  * (\`ENGRAM_ADMIN_TOKEN\`). Never crosses with workspace api-keys.
10
9
  *
11
- * Surface, when orgStore is wired:
10
+ * Surface:
12
11
  *
13
- * POST /orgs create org
14
- * GET /orgs list orgs
15
- * GET /orgs/:id get org
16
- * DELETE /orgs/:id delete org (CASCADE)
17
- * POST /orgs/:id/members add member (email|userId)
12
+ * POST /orgs create org
13
+ * GET /orgs list orgs
14
+ * GET /orgs/:id get org
15
+ * DELETE /orgs/:id delete org (CASCADE)
16
+ * POST /orgs/:id/members add member (email|userId)
18
17
  * GET /orgs/:id/members
19
18
  * DELETE /orgs/:id/members/:userId
20
- * POST /orgs/:id/workspaces create + key (under org)
19
+ * POST /orgs/:id/workspaces create workspace + key
21
20
  * GET /orgs/:id/workspaces
22
- *
23
- * Plus the lower-level workspace + key endpoints from before
24
- * (\`/workspaces\`, \`/workspaces/:id/keys\`, ...).
21
+ * GET /orgs/:id/workspaces/:wsId/keys list workspace keys
22
+ * POST /orgs/:id/workspaces/:wsId/keys issue a new key
23
+ * DELETE /orgs/:id/workspaces/:wsId/keys/:keyId revoke a key
25
24
  */
26
25
  export function createAdminRouter(opts) {
27
26
  const app = new Hono();
27
+ const { orgStore } = opts;
28
+ const deps = { orgStore, keyStore: opts.keyStore };
28
29
  app.use("*", async (c, next) => {
29
30
  const supplied = c.req.header("x-admin-token") ??
30
31
  c.req.header("authorization")?.match(/^Bearer\s+(.+)$/i)?.[1];
@@ -33,94 +34,6 @@ export function createAdminRouter(opts) {
33
34
  }
34
35
  await next();
35
36
  });
36
- // -------------------- workspaces (legacy / api-key-only) ----
37
- // Kept for backwards compatibility; new callers should create
38
- // workspaces under an org so they're reachable from the web UI.
39
- app.post("/workspaces", async (c) => {
40
- const body = await parseJsonBody(c, createWorkspaceSchema);
41
- if (body instanceof Response)
42
- return body;
43
- if (body.id !== undefined && !isValidWorkspaceId(body.id)) {
44
- return c.json({ error: "invalid_workspace_id" }, 400);
45
- }
46
- try {
47
- const ws = await opts.keyStore.createWorkspace({
48
- ...(body.id !== undefined ? { id: body.id } : {}),
49
- ...(body.name !== undefined ? { name: body.name } : {}),
50
- ...(body.metadata !== undefined ? { metadata: body.metadata } : {}),
51
- });
52
- if (body.issueKey === false) {
53
- return c.json({ workspace: ws });
54
- }
55
- const key = await opts.keyStore.issueKey(ws.id, {
56
- ...(body.keyName !== undefined ? { name: body.keyName } : {}),
57
- });
58
- return c.json({ workspace: ws, key });
59
- }
60
- catch (e) {
61
- return c.json({ error: e.message }, 400);
62
- }
63
- });
64
- app.get("/workspaces", async (c) => {
65
- const workspaces = await opts.keyStore.listWorkspaces();
66
- return c.json({ workspaces });
67
- });
68
- app.get("/workspaces/:id", async (c) => {
69
- const ws = await opts.keyStore.getWorkspace(c.req.param("id"));
70
- if (!ws)
71
- return c.json({ error: "workspace_not_found" }, 404);
72
- return c.json(ws);
73
- });
74
- app.delete("/workspaces/:id", async (c) => {
75
- const id = c.req.param("id");
76
- const ws = await opts.keyStore.getWorkspace(id);
77
- if (!ws)
78
- return c.json({ error: "workspace_not_found" }, 404);
79
- await opts.keyStore.deleteWorkspace(id);
80
- return c.body(null, 204);
81
- });
82
- app.post("/workspaces/:id/keys", async (c) => {
83
- const workspaceId = c.req.param("id");
84
- const ws = await opts.keyStore.getWorkspace(workspaceId);
85
- if (!ws)
86
- return c.json({ error: "workspace_not_found" }, 404);
87
- const raw = await c.req.json().catch(() => ({}));
88
- const parsed = issueKeySchema.safeParse(raw);
89
- if (!parsed.success) {
90
- return c.json({ error: "invalid_body", issues: parsed.error.issues }, 400);
91
- }
92
- const key = await opts.keyStore.issueKey(workspaceId, {
93
- ...(parsed.data.name !== undefined ? { name: parsed.data.name } : {}),
94
- });
95
- return c.json(key);
96
- });
97
- app.get("/workspaces/:id/keys", async (c) => {
98
- const workspaceId = c.req.param("id");
99
- const ws = await opts.keyStore.getWorkspace(workspaceId);
100
- if (!ws)
101
- return c.json({ error: "workspace_not_found" }, 404);
102
- const keys = await opts.keyStore.listKeys(workspaceId);
103
- return c.json({ keys });
104
- });
105
- app.delete("/workspaces/:id/keys/:keyId", async (c) => {
106
- const workspaceId = c.req.param("id");
107
- const keyId = c.req.param("keyId");
108
- try {
109
- await opts.keyStore.revokeKey(workspaceId, keyId);
110
- }
111
- catch (e) {
112
- if (e.message === "key_not_found") {
113
- return c.json({ error: "key_not_found" }, 404);
114
- }
115
- throw e;
116
- }
117
- return c.body(null, 204);
118
- });
119
- // -------------------- orgs (org-scoped admin surface) -------
120
- const orgStore = opts.orgStore;
121
- if (!orgStore)
122
- return app;
123
- const deps = { orgStore, keyStore: opts.keyStore };
124
37
  app.post("/orgs", async (c) => runService(c, async () => {
125
38
  const body = (await c.req.json().catch(() => ({})));
126
39
  const org = await createOrg(deps, body);
package/dist/main.js CHANGED
@@ -22,6 +22,10 @@ const CORS_ORIGINS = (process.env.ENGRAM_CORS_ORIGINS ?? "")
22
22
  .split(",")
23
23
  .map((s) => s.trim())
24
24
  .filter(Boolean);
25
+ if (!orgStore) {
26
+ console.error("[engram-server] orgStore is required (DATABASE_URL + ENGRAM_AUTH_* env vars) — every admin endpoint is now org-scoped");
27
+ process.exit(1);
28
+ }
25
29
  const app = createServer({
26
30
  auth: async (key) => {
27
31
  const r = await keyStore.resolveKey(key);
@@ -31,14 +35,10 @@ const app = createServer({
31
35
  },
32
36
  ...(authHandler ? { authHandler } : {}),
33
37
  ...(cookieAuth ? { cookieAuth } : {}),
34
- ...(orgStore ? { orgStore } : {}),
38
+ orgStore,
35
39
  keyStore,
36
40
  ...(CORS_ORIGINS.length > 0 ? { corsOrigins: CORS_ORIGINS } : {}),
37
- admin: {
38
- token: ADMIN_TOKEN,
39
- keyStore,
40
- ...(orgStore ? { orgStore } : {}),
41
- },
41
+ admin: { token: ADMIN_TOKEN, keyStore, orgStore },
42
42
  });
43
43
  console.log(`[engram-server] listening on :${PORT}`);
44
44
  export default { port: PORT, fetch: app.fetch };
@@ -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/dist/openapi.js CHANGED
@@ -71,14 +71,6 @@ const TAG_DEFS = [
71
71
  name: "Orgs (admin)",
72
72
  description: "プラットフォーム管理者トークンでの org 管理",
73
73
  },
74
- {
75
- name: "Workspaces (admin)",
76
- description: "ワークスペースの管理(管理者トークン必須)",
77
- },
78
- {
79
- name: "API Keys (admin)",
80
- description: "ワークスペース API キーの発行・無効化(管理者トークン必須)",
81
- },
82
74
  ];
83
75
  /**
84
76
  * Stamp `tag` onto every operation in a path-item. The path declares its
@@ -634,87 +626,6 @@ function buildPaths() {
634
626
  },
635
627
  },
636
628
  }),
637
- "/admin/v1/workspaces": tagged("Workspaces (admin)", {
638
- post: {
639
- summary: "ワークスペースを作成する(デフォルトで初期キーも発行する)。",
640
- security: adminAuth,
641
- requestBody: jsonBody("CreateWorkspace"),
642
- responses: {
643
- "200": res("ワークスペース(issueKey=false でない限りキーも含む)"),
644
- "400": res("リクエストボディまたはワークスペース id が不正"),
645
- "401": res("認証エラー"),
646
- },
647
- },
648
- get: {
649
- summary: "全ワークスペースを一覧取得する。",
650
- security: adminAuth,
651
- responses: {
652
- "200": res("ワークスペース一覧"),
653
- "401": res("認証エラー"),
654
- },
655
- },
656
- }),
657
- "/admin/v1/workspaces/{id}": tagged("Workspaces (admin)", {
658
- get: {
659
- summary: "単一のワークスペースを取得する。",
660
- security: adminAuth,
661
- parameters: [pathParam("id", "ワークスペース id。")],
662
- responses: {
663
- "200": res("ワークスペース"),
664
- "404": res("ワークスペースが見つからない"),
665
- "401": res("認証エラー"),
666
- },
667
- },
668
- delete: {
669
- summary: "ワークスペースを削除する(キー・セッション・イベントにカスケードする)。",
670
- security: adminAuth,
671
- parameters: [pathParam("id", "ワークスペース id。")],
672
- responses: {
673
- "204": res("削除完了"),
674
- "404": res("ワークスペースが見つからない"),
675
- "401": res("認証エラー"),
676
- },
677
- },
678
- }),
679
- "/admin/v1/workspaces/{id}/keys": tagged("API Keys (admin)", {
680
- post: {
681
- summary: "ワークスペースに新しい API キーを発行する。",
682
- security: adminAuth,
683
- parameters: [pathParam("id", "ワークスペース id。")],
684
- requestBody: jsonBody("IssueKey", false),
685
- responses: {
686
- "200": res("発行されたキー(生のキーは一度のみ返却)"),
687
- "400": res("リクエストボディが不正"),
688
- "404": res("ワークスペースが見つからない"),
689
- "401": res("認証エラー"),
690
- },
691
- },
692
- get: {
693
- summary: "ワークスペースの API キー一覧を取得する(ハッシュのみ)。",
694
- security: adminAuth,
695
- parameters: [pathParam("id", "ワークスペース id。")],
696
- responses: {
697
- "200": res("キー一覧"),
698
- "404": res("ワークスペースが見つからない"),
699
- "401": res("認証エラー"),
700
- },
701
- },
702
- }),
703
- "/admin/v1/workspaces/{id}/keys/{keyId}": tagged("API Keys (admin)", {
704
- delete: {
705
- summary: "API キーを無効化する。",
706
- security: adminAuth,
707
- parameters: [
708
- pathParam("id", "ワークスペース id。"),
709
- pathParam("keyId", "キー id。"),
710
- ],
711
- responses: {
712
- "204": res("無効化完了(冪等)"),
713
- "404": res("キーが見つからない"),
714
- "401": res("認証エラー"),
715
- },
716
- },
717
- }),
718
629
  };
719
630
  }
720
631
  /**
package/openapi.json CHANGED
@@ -37,14 +37,6 @@
37
37
  {
38
38
  "name": "Orgs (admin)",
39
39
  "description": "プラットフォーム管理者トークンでの org 管理"
40
- },
41
- {
42
- "name": "Workspaces (admin)",
43
- "description": "ワークスペースの管理(管理者トークン必須)"
44
- },
45
- {
46
- "name": "API Keys (admin)",
47
- "description": "ワークスペース API キーの発行・無効化(管理者トークン必須)"
48
40
  }
49
41
  ],
50
42
  "servers": [
@@ -2502,252 +2494,6 @@
2502
2494
  "Orgs (admin)"
2503
2495
  ]
2504
2496
  }
2505
- },
2506
- "/admin/v1/workspaces": {
2507
- "post": {
2508
- "summary": "ワークスペースを作成する(デフォルトで初期キーも発行する)。",
2509
- "security": [
2510
- {
2511
- "adminToken": []
2512
- }
2513
- ],
2514
- "requestBody": {
2515
- "required": true,
2516
- "content": {
2517
- "application/json": {
2518
- "schema": {
2519
- "$ref": "#/components/schemas/CreateWorkspace"
2520
- }
2521
- }
2522
- }
2523
- },
2524
- "responses": {
2525
- "200": {
2526
- "description": "ワークスペース(issueKey=false でない限りキーも含む)"
2527
- },
2528
- "400": {
2529
- "description": "リクエストボディまたはワークスペース id が不正"
2530
- },
2531
- "401": {
2532
- "description": "認証エラー"
2533
- }
2534
- },
2535
- "tags": [
2536
- "Workspaces (admin)"
2537
- ]
2538
- },
2539
- "get": {
2540
- "summary": "全ワークスペースを一覧取得する。",
2541
- "security": [
2542
- {
2543
- "adminToken": []
2544
- }
2545
- ],
2546
- "responses": {
2547
- "200": {
2548
- "description": "ワークスペース一覧"
2549
- },
2550
- "401": {
2551
- "description": "認証エラー"
2552
- }
2553
- },
2554
- "tags": [
2555
- "Workspaces (admin)"
2556
- ]
2557
- }
2558
- },
2559
- "/admin/v1/workspaces/{id}": {
2560
- "get": {
2561
- "summary": "単一のワークスペースを取得する。",
2562
- "security": [
2563
- {
2564
- "adminToken": []
2565
- }
2566
- ],
2567
- "parameters": [
2568
- {
2569
- "name": "id",
2570
- "in": "path",
2571
- "required": true,
2572
- "schema": {
2573
- "type": "string"
2574
- },
2575
- "description": "ワークスペース id。"
2576
- }
2577
- ],
2578
- "responses": {
2579
- "200": {
2580
- "description": "ワークスペース"
2581
- },
2582
- "401": {
2583
- "description": "認証エラー"
2584
- },
2585
- "404": {
2586
- "description": "ワークスペースが見つからない"
2587
- }
2588
- },
2589
- "tags": [
2590
- "Workspaces (admin)"
2591
- ]
2592
- },
2593
- "delete": {
2594
- "summary": "ワークスペースを削除する(キー・セッション・イベントにカスケードする)。",
2595
- "security": [
2596
- {
2597
- "adminToken": []
2598
- }
2599
- ],
2600
- "parameters": [
2601
- {
2602
- "name": "id",
2603
- "in": "path",
2604
- "required": true,
2605
- "schema": {
2606
- "type": "string"
2607
- },
2608
- "description": "ワークスペース id。"
2609
- }
2610
- ],
2611
- "responses": {
2612
- "204": {
2613
- "description": "削除完了"
2614
- },
2615
- "401": {
2616
- "description": "認証エラー"
2617
- },
2618
- "404": {
2619
- "description": "ワークスペースが見つからない"
2620
- }
2621
- },
2622
- "tags": [
2623
- "Workspaces (admin)"
2624
- ]
2625
- }
2626
- },
2627
- "/admin/v1/workspaces/{id}/keys": {
2628
- "post": {
2629
- "summary": "ワークスペースに新しい API キーを発行する。",
2630
- "security": [
2631
- {
2632
- "adminToken": []
2633
- }
2634
- ],
2635
- "parameters": [
2636
- {
2637
- "name": "id",
2638
- "in": "path",
2639
- "required": true,
2640
- "schema": {
2641
- "type": "string"
2642
- },
2643
- "description": "ワークスペース id。"
2644
- }
2645
- ],
2646
- "requestBody": {
2647
- "required": false,
2648
- "content": {
2649
- "application/json": {
2650
- "schema": {
2651
- "$ref": "#/components/schemas/IssueKey"
2652
- }
2653
- }
2654
- }
2655
- },
2656
- "responses": {
2657
- "200": {
2658
- "description": "発行されたキー(生のキーは一度のみ返却)"
2659
- },
2660
- "400": {
2661
- "description": "リクエストボディが不正"
2662
- },
2663
- "401": {
2664
- "description": "認証エラー"
2665
- },
2666
- "404": {
2667
- "description": "ワークスペースが見つからない"
2668
- }
2669
- },
2670
- "tags": [
2671
- "API Keys (admin)"
2672
- ]
2673
- },
2674
- "get": {
2675
- "summary": "ワークスペースの API キー一覧を取得する(ハッシュのみ)。",
2676
- "security": [
2677
- {
2678
- "adminToken": []
2679
- }
2680
- ],
2681
- "parameters": [
2682
- {
2683
- "name": "id",
2684
- "in": "path",
2685
- "required": true,
2686
- "schema": {
2687
- "type": "string"
2688
- },
2689
- "description": "ワークスペース id。"
2690
- }
2691
- ],
2692
- "responses": {
2693
- "200": {
2694
- "description": "キー一覧"
2695
- },
2696
- "401": {
2697
- "description": "認証エラー"
2698
- },
2699
- "404": {
2700
- "description": "ワークスペースが見つからない"
2701
- }
2702
- },
2703
- "tags": [
2704
- "API Keys (admin)"
2705
- ]
2706
- }
2707
- },
2708
- "/admin/v1/workspaces/{id}/keys/{keyId}": {
2709
- "delete": {
2710
- "summary": "API キーを無効化する。",
2711
- "security": [
2712
- {
2713
- "adminToken": []
2714
- }
2715
- ],
2716
- "parameters": [
2717
- {
2718
- "name": "id",
2719
- "in": "path",
2720
- "required": true,
2721
- "schema": {
2722
- "type": "string"
2723
- },
2724
- "description": "ワークスペース id。"
2725
- },
2726
- {
2727
- "name": "keyId",
2728
- "in": "path",
2729
- "required": true,
2730
- "schema": {
2731
- "type": "string"
2732
- },
2733
- "description": "キー id。"
2734
- }
2735
- ],
2736
- "responses": {
2737
- "204": {
2738
- "description": "無効化完了(冪等)"
2739
- },
2740
- "401": {
2741
- "description": "認証エラー"
2742
- },
2743
- "404": {
2744
- "description": "キーが見つからない"
2745
- }
2746
- },
2747
- "tags": [
2748
- "API Keys (admin)"
2749
- ]
2750
- }
2751
2497
  }
2752
2498
  }
2753
2499
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hexis-ai/engram-server",
3
- "version": "0.14.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",
@@ -50,7 +50,7 @@
50
50
  },
51
51
  "dependencies": {
52
52
  "@hexis-ai/engram-core": "^0.3.0",
53
- "@hexis-ai/engram-sdk": "^0.15.0",
53
+ "@hexis-ai/engram-sdk": "^0.16.0",
54
54
  "better-auth": "^1.6.11",
55
55
  "hono": "^4.6.0",
56
56
  "pg": "^8.13.0",