@hexis-ai/engram-server 0.3.0 → 0.5.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.
@@ -1,4 +1,4 @@
1
- import { generateRawKey, hashKey, isValidWorkspaceId, keyPrefix, } from "../key-store";
1
+ import { deriveKey, hashKey, mintKey, resolveWorkspaceId, } from "../key-store";
2
2
  /**
3
3
  * In-process KeyStore for tests and single-node dev. Volatile.
4
4
  */
@@ -7,9 +7,7 @@ export class InMemoryKeyStore {
7
7
  keys = new Map();
8
8
  byHash = new Map();
9
9
  async createWorkspace(input) {
10
- const id = input.id ?? crypto.randomUUID();
11
- if (!isValidWorkspaceId(id))
12
- throw new Error("invalid_workspace_id");
10
+ const id = resolveWorkspaceId(input);
13
11
  const existing = this.workspaces.get(id);
14
12
  if (existing)
15
13
  return existing;
@@ -40,12 +38,12 @@ export class InMemoryKeyStore {
40
38
  async issueKey(workspaceId, opts = {}) {
41
39
  if (!this.workspaces.has(workspaceId))
42
40
  throw new Error("workspace_not_found");
43
- const raw = generateRawKey();
41
+ const { id, hash, prefix, raw } = mintKey();
44
42
  const row = {
45
- id: crypto.randomUUID(),
43
+ id,
46
44
  workspaceId,
47
- keyHash: hashKey(raw),
48
- prefix: keyPrefix(raw),
45
+ keyHash: hash,
46
+ prefix,
49
47
  ...(opts.name !== undefined ? { name: opts.name } : {}),
50
48
  createdAt: new Date().toISOString(),
51
49
  };
@@ -80,14 +78,14 @@ export class InMemoryKeyStore {
80
78
  async registerKey(workspaceId, rawKey, name) {
81
79
  if (!this.workspaces.has(workspaceId))
82
80
  throw new Error("workspace_not_found");
83
- const hash = hashKey(rawKey);
81
+ const { id, hash, prefix } = deriveKey(rawKey);
84
82
  if (this.byHash.has(hash))
85
83
  return;
86
84
  const row = {
87
- id: crypto.randomUUID(),
85
+ id,
88
86
  workspaceId,
89
87
  keyHash: hash,
90
- prefix: keyPrefix(rawKey),
88
+ prefix,
91
89
  ...(name !== undefined ? { name } : {}),
92
90
  createdAt: new Date().toISOString(),
93
91
  };
@@ -1,5 +1,5 @@
1
1
  import type { Session } from "@hexis-ai/engram-core";
2
- import type { PersonCreate, PersonInfo, PersonUpdate, SessionEvent, SessionInit } from "@hexis-ai/engram-sdk";
2
+ import type { AliasInfo, AliasUpsert, PersonCreate, PersonInfo, PersonUpdate, SessionEvent, SessionInit } from "@hexis-ai/engram-sdk";
3
3
  import { type StorageAdapter } from "../storage";
4
4
  export interface InMemoryAdapterOptions {
5
5
  /** Override for tests. Default: `p_${random}` with 8 chars. */
@@ -12,6 +12,8 @@ export interface InMemoryAdapterOptions {
12
12
  export declare class InMemoryAdapter implements StorageAdapter {
13
13
  private readonly sessions;
14
14
  private readonly persons;
15
+ /** Keyed by `${personId} ${name.toLowerCase()}` — see `aliasKey` below. */
16
+ private readonly aliases;
15
17
  private readonly newPersonId;
16
18
  constructor(opts?: InMemoryAdapterOptions);
17
19
  createSession(init: SessionInit & {
@@ -20,6 +22,7 @@ export declare class InMemoryAdapter implements StorageAdapter {
20
22
  }): Promise<void>;
21
23
  appendEvents(sessionId: string, events: SessionEvent[]): Promise<void>;
22
24
  getSession(sessionId: string): Promise<Session | null>;
25
+ getSessionEvents(sessionId: string): Promise<SessionEvent[] | null>;
23
26
  listSessions(opts: {
24
27
  limit: number;
25
28
  channel?: string;
@@ -38,4 +41,8 @@ export declare class InMemoryAdapter implements StorageAdapter {
38
41
  limit: number;
39
42
  q?: string;
40
43
  }): Promise<PersonInfo[]>;
44
+ upsertAlias(personId: string, input: {
45
+ name: string;
46
+ } & AliasUpsert): Promise<AliasInfo | null>;
47
+ listAliases(personId: string): Promise<AliasInfo[]>;
41
48
  }
Binary file
@@ -1,4 +1,4 @@
1
- import { generateRawKey, hashKey, isValidWorkspaceId, keyPrefix, } from "../key-store";
1
+ import { deriveKey, hashKey, mintKey, resolveWorkspaceId, } from "../key-store";
2
2
  import { runMigrations } from "../migrator";
3
3
  export class PostgresKeyStore {
4
4
  sql;
@@ -13,9 +13,7 @@ export class PostgresKeyStore {
13
13
  await runMigrations(this.sql);
14
14
  }
15
15
  async createWorkspace(input) {
16
- const id = input.id ?? crypto.randomUUID();
17
- if (!isValidWorkspaceId(id))
18
- throw new Error("invalid_workspace_id");
16
+ const id = resolveWorkspaceId(input);
19
17
  await this.sql `
20
18
  INSERT INTO engram_workspaces (id, name, metadata)
21
19
  VALUES (${id}, ${input.name ?? null}, ${(input.metadata ?? {})})
@@ -49,10 +47,7 @@ export class PostgresKeyStore {
49
47
  const ws = await this.getWorkspace(workspaceId);
50
48
  if (!ws)
51
49
  throw new Error("workspace_not_found");
52
- const raw = generateRawKey();
53
- const id = crypto.randomUUID();
54
- const hash = hashKey(raw);
55
- const prefix = keyPrefix(raw);
50
+ const { id, hash, prefix, raw } = mintKey();
56
51
  const rows = await this.sql `
57
52
  INSERT INTO engram_api_keys (id, workspace_id, key_hash, prefix, name)
58
53
  VALUES (${id}, ${workspaceId}, ${hash}, ${prefix}, ${opts.name ?? null})
@@ -110,11 +105,10 @@ export class PostgresKeyStore {
110
105
  const ws = await this.getWorkspace(workspaceId);
111
106
  if (!ws)
112
107
  throw new Error("workspace_not_found");
113
- const hash = hashKey(rawKey);
114
- const id = crypto.randomUUID();
108
+ const { id, hash, prefix } = deriveKey(rawKey);
115
109
  await this.sql `
116
110
  INSERT INTO engram_api_keys (id, workspace_id, key_hash, prefix, name)
117
- VALUES (${id}, ${workspaceId}, ${hash}, ${keyPrefix(rawKey)}, ${name ?? null})
111
+ VALUES (${id}, ${workspaceId}, ${hash}, ${prefix}, ${name ?? null})
118
112
  ON CONFLICT (key_hash) DO NOTHING
119
113
  `;
120
114
  }
@@ -1,5 +1,5 @@
1
1
  import type { Session } from "@hexis-ai/engram-core";
2
- import type { PersonCreate, PersonInfo, PersonUpdate, SessionEvent, SessionInit } from "@hexis-ai/engram-sdk";
2
+ import type { AliasInfo, AliasUpsert, PersonCreate, PersonInfo, PersonUpdate, SessionEvent, SessionInit } from "@hexis-ai/engram-sdk";
3
3
  import { type StorageAdapter } from "../storage";
4
4
  /**
5
5
  * Minimal subset of `postgres` driver's tagged-template surface that this
@@ -37,6 +37,7 @@ export declare class PostgresAdapter implements StorageAdapter {
37
37
  }): Promise<void>;
38
38
  appendEvents(sessionId: string, events: SessionEvent[]): Promise<void>;
39
39
  getSession(sessionId: string): Promise<Session | null>;
40
+ getSessionEvents(sessionId: string): Promise<SessionEvent[] | null>;
40
41
  listSessions(opts: {
41
42
  limit: number;
42
43
  channel?: string;
@@ -55,4 +56,8 @@ export declare class PostgresAdapter implements StorageAdapter {
55
56
  limit: number;
56
57
  q?: string;
57
58
  }): Promise<PersonInfo[]>;
59
+ upsertAlias(personId: string, input: {
60
+ name: string;
61
+ } & AliasUpsert): Promise<AliasInfo | null>;
62
+ listAliases(personId: string): Promise<AliasInfo[]>;
58
63
  }
@@ -110,6 +110,21 @@ export class PostgresAdapter {
110
110
  };
111
111
  return foldEvents(row, events.map((e) => e.payload), new Date());
112
112
  }
113
+ async getSessionEvents(sessionId) {
114
+ const rows = await this.sql `
115
+ SELECT id FROM engram_sessions
116
+ WHERE workspace_id = ${this.workspaceId} AND id = ${sessionId}
117
+ LIMIT 1
118
+ `;
119
+ if (rows.length === 0)
120
+ return null;
121
+ const events = await this.sql `
122
+ SELECT payload FROM engram_events
123
+ WHERE workspace_id = ${this.workspaceId} AND session_id = ${sessionId}
124
+ ORDER BY seq
125
+ `;
126
+ return events.map((e) => e.payload);
127
+ }
113
128
  async listSessions(opts) {
114
129
  const channelFilter = opts.channel ?? null;
115
130
  const rows = await this.sql `
@@ -222,6 +237,57 @@ export class PostgresAdapter {
222
237
  `;
223
238
  return rows.map(toPersonInfo);
224
239
  }
240
+ // --- Aliases ------------------------------------------------------
241
+ async upsertAlias(personId, input) {
242
+ // Pre-check rather than rely on the FK so unknown persons return
243
+ // `null` instead of throwing a constraint violation.
244
+ const personExists = await this.sql `
245
+ SELECT id FROM engram_persons
246
+ WHERE workspace_id = ${this.workspaceId} AND id = ${personId}
247
+ LIMIT 1
248
+ `;
249
+ if (personExists.length === 0)
250
+ return null;
251
+ const increment = input.increment ?? true;
252
+ // Branch on the upsert behaviour. `increment=true` bumps usage_count
253
+ // and replaces caller/last_used; `increment=false` is idempotent —
254
+ // a no-op when the row already exists.
255
+ const rows = increment
256
+ ? await this.sql `
257
+ INSERT INTO engram_aliases (workspace_id, person_id, name, caller, usage_count, last_used)
258
+ VALUES (
259
+ ${this.workspaceId}, ${personId}, ${input.name},
260
+ ${input.caller}, 1, ${input.last_used}
261
+ )
262
+ ON CONFLICT (workspace_id, person_id, name_lower) DO UPDATE SET
263
+ usage_count = engram_aliases.usage_count + 1,
264
+ caller = EXCLUDED.caller,
265
+ last_used = EXCLUDED.last_used,
266
+ updated_at = now()
267
+ RETURNING person_id, name, caller, usage_count, last_used, created_at, updated_at
268
+ `
269
+ : await this.sql `
270
+ INSERT INTO engram_aliases (workspace_id, person_id, name, caller, usage_count, last_used)
271
+ VALUES (
272
+ ${this.workspaceId}, ${personId}, ${input.name},
273
+ ${input.caller}, 1, ${input.last_used}
274
+ )
275
+ ON CONFLICT (workspace_id, person_id, name_lower) DO UPDATE SET
276
+ -- No-op update so RETURNING still produces a row.
277
+ updated_at = engram_aliases.updated_at
278
+ RETURNING person_id, name, caller, usage_count, last_used, created_at, updated_at
279
+ `;
280
+ return toAliasInfo(rows[0]);
281
+ }
282
+ async listAliases(personId) {
283
+ const rows = await this.sql `
284
+ SELECT person_id, name, caller, usage_count, last_used, created_at, updated_at
285
+ FROM engram_aliases
286
+ WHERE workspace_id = ${this.workspaceId} AND person_id = ${personId}
287
+ ORDER BY last_used DESC
288
+ `;
289
+ return rows.map(toAliasInfo);
290
+ }
225
291
  }
226
292
  function toPersonInfo(r) {
227
293
  const toIso = (v) => (typeof v === "string" ? v : v.toISOString());
@@ -232,3 +298,20 @@ function toPersonInfo(r) {
232
298
  updated_at: toIso(r.updated_at),
233
299
  };
234
300
  }
301
+ function toAliasInfo(r) {
302
+ const toIso = (v) => (typeof v === "string" ? v : v.toISOString());
303
+ // last_used is a DATE column; postgres-js returns it as a Date at UTC
304
+ // midnight. Render as plain YYYY-MM-DD to match the wire type.
305
+ const lastUsed = typeof r.last_used === "string"
306
+ ? r.last_used.slice(0, 10)
307
+ : r.last_used.toISOString().slice(0, 10);
308
+ return {
309
+ person_id: r.person_id,
310
+ name: r.name,
311
+ caller: r.caller,
312
+ usage_count: r.usage_count,
313
+ last_used: lastUsed,
314
+ created_at: toIso(r.created_at),
315
+ updated_at: toIso(r.updated_at),
316
+ };
317
+ }
@@ -61,3 +61,32 @@ export declare function generateRawKey(): string;
61
61
  export declare function hashKey(raw: string): string;
62
62
  export declare function keyPrefix(raw: string): string;
63
63
  export declare function isValidWorkspaceId(id: string): boolean;
64
+ /**
65
+ * Resolve the workspace id for a `createWorkspace` call: fall back to a random
66
+ * UUID when none is supplied, then validate. Shared by every KeyStore so the
67
+ * id policy can't drift between the in-memory and Postgres adapters.
68
+ */
69
+ export declare function resolveWorkspaceId(input: {
70
+ id?: string;
71
+ }): string;
72
+ /** Derived material for a single API key row. */
73
+ export interface KeyMaterial {
74
+ /** Row id (primary key). */
75
+ id: string;
76
+ /** SHA-256 hash of the raw key — the only form that gets persisted. */
77
+ hash: string;
78
+ /** Human-readable prefix shown in listings. */
79
+ prefix: string;
80
+ }
81
+ /**
82
+ * Mint a brand-new random key. Returns the persistable material plus the
83
+ * plaintext `raw` key, which the caller must return to the user exactly once.
84
+ */
85
+ export declare function mintKey(): KeyMaterial & {
86
+ raw: string;
87
+ };
88
+ /**
89
+ * Derive persistable material for a caller-supplied raw key (`registerKey`).
90
+ * Unlike `mintKey`, the raw key already exists and is not returned.
91
+ */
92
+ export declare function deriveKey(raw: string): KeyMaterial;
package/dist/key-store.js CHANGED
@@ -15,3 +15,29 @@ export function keyPrefix(raw) {
15
15
  export function isValidWorkspaceId(id) {
16
16
  return WORKSPACE_ID_RE.test(id);
17
17
  }
18
+ /**
19
+ * Resolve the workspace id for a `createWorkspace` call: fall back to a random
20
+ * UUID when none is supplied, then validate. Shared by every KeyStore so the
21
+ * id policy can't drift between the in-memory and Postgres adapters.
22
+ */
23
+ export function resolveWorkspaceId(input) {
24
+ const id = input.id ?? crypto.randomUUID();
25
+ if (!isValidWorkspaceId(id))
26
+ throw new Error("invalid_workspace_id");
27
+ return id;
28
+ }
29
+ /**
30
+ * Mint a brand-new random key. Returns the persistable material plus the
31
+ * plaintext `raw` key, which the caller must return to the user exactly once.
32
+ */
33
+ export function mintKey() {
34
+ const raw = generateRawKey();
35
+ return { id: crypto.randomUUID(), hash: hashKey(raw), prefix: keyPrefix(raw), raw };
36
+ }
37
+ /**
38
+ * Derive persistable material for a caller-supplied raw key (`registerKey`).
39
+ * Unlike `mintKey`, the raw key already exists and is not returned.
40
+ */
41
+ export function deriveKey(raw) {
42
+ return { id: crypto.randomUUID(), hash: hashKey(raw), prefix: keyPrefix(raw) };
43
+ }
@@ -0,0 +1,2 @@
1
+ export declare const name = "0002-aliases";
2
+ export declare const sql = "\n-- Per-person alias history. Mirrors monet's `aliases` table at the\n-- canonical-engram level so identity resolution can move out of monet\n-- once consumers catch up. Keyed by name_lower (case-insensitive,\n-- DB-generated) so case variants collapse.\nCREATE TABLE IF NOT EXISTS engram_aliases (\n workspace_id TEXT NOT NULL,\n person_id TEXT NOT NULL,\n name TEXT NOT NULL,\n name_lower TEXT GENERATED ALWAYS AS (lower(name)) STORED,\n caller TEXT NOT NULL,\n usage_count INTEGER NOT NULL DEFAULT 1,\n last_used DATE NOT NULL,\n created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n PRIMARY KEY (workspace_id, person_id, name_lower),\n FOREIGN KEY (workspace_id, person_id)\n REFERENCES engram_persons(workspace_id, id) ON DELETE CASCADE\n);\n\n-- Reverse lookup: \"who answers to this name?\" is the common identity-\n-- resolution query and benefits from an index on the lowercased form.\nCREATE INDEX IF NOT EXISTS idx_engram_aliases_name_lower\n ON engram_aliases (workspace_id, name_lower);\n\n-- Forward lookup: list all of a person's aliases by recency.\nCREATE INDEX IF NOT EXISTS idx_engram_aliases_person_last_used\n ON engram_aliases (workspace_id, person_id, last_used DESC);\n";
@@ -0,0 +1,30 @@
1
+ export const name = "0002-aliases";
2
+ export const sql = `
3
+ -- Per-person alias history. Mirrors monet's \`aliases\` table at the
4
+ -- canonical-engram level so identity resolution can move out of monet
5
+ -- once consumers catch up. Keyed by name_lower (case-insensitive,
6
+ -- DB-generated) so case variants collapse.
7
+ CREATE TABLE IF NOT EXISTS engram_aliases (
8
+ workspace_id TEXT NOT NULL,
9
+ person_id TEXT NOT NULL,
10
+ name TEXT NOT NULL,
11
+ name_lower TEXT GENERATED ALWAYS AS (lower(name)) STORED,
12
+ caller TEXT NOT NULL,
13
+ usage_count INTEGER NOT NULL DEFAULT 1,
14
+ last_used DATE NOT NULL,
15
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
16
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
17
+ PRIMARY KEY (workspace_id, person_id, name_lower),
18
+ FOREIGN KEY (workspace_id, person_id)
19
+ REFERENCES engram_persons(workspace_id, id) ON DELETE CASCADE
20
+ );
21
+
22
+ -- Reverse lookup: \"who answers to this name?\" is the common identity-
23
+ -- resolution query and benefits from an index on the lowercased form.
24
+ CREATE INDEX IF NOT EXISTS idx_engram_aliases_name_lower
25
+ ON engram_aliases (workspace_id, name_lower);
26
+
27
+ -- Forward lookup: list all of a person's aliases by recency.
28
+ CREATE INDEX IF NOT EXISTS idx_engram_aliases_person_last_used
29
+ ON engram_aliases (workspace_id, person_id, last_used DESC);
30
+ `;
@@ -1,4 +1,5 @@
1
1
  import * as m0001 from "./0001-baseline";
2
+ import * as m0002 from "./0002-aliases";
2
3
  /**
3
4
  * Schema migrations, applied in array order. Add a new file under
4
5
  * `migrations/NNNN-<slug>.ts` exporting `name` and `sql`, then append it
@@ -6,4 +7,7 @@ import * as m0001 from "./0001-baseline";
6
7
  * EXISTS, ADD COLUMN IF NOT EXISTS, etc.) so a first apply on a DB that
7
8
  * predates the migrator is a no-op.
8
9
  */
9
- export const MIGRATIONS = [{ name: m0001.name, sql: m0001.sql }];
10
+ export const MIGRATIONS = [
11
+ { name: m0001.name, sql: m0001.sql },
12
+ { name: m0002.name, sql: m0002.sql },
13
+ ];