@hexis-ai/engram-server 0.12.0 → 0.14.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.
@@ -6,13 +6,26 @@ export declare class InMemoryKeyStore implements KeyStore {
6
6
  private readonly workspaces;
7
7
  private readonly keys;
8
8
  private readonly byHash;
9
+ /** Mirror of engram_workspaces.org_id. Lookup-only — fakes / org-stores
10
+ * in tests read this to answer membership queries without re-tracking
11
+ * the binding themselves. */
12
+ private readonly orgIds;
9
13
  createWorkspace(input: {
10
14
  id?: string;
11
15
  name?: string;
12
16
  metadata?: Record<string, unknown>;
17
+ orgId?: string;
13
18
  }): Promise<Workspace>;
19
+ /** Org id stamped on a workspace at creation, or undefined if not org-scoped. */
20
+ getWorkspaceOrgId(workspaceId: string): string | undefined;
21
+ /** All (workspaceId, orgId) pairs. Read-only view for fakes. */
22
+ workspaceOrgBindings(): ReadonlyMap<string, string>;
14
23
  getWorkspace(id: string): Promise<Workspace | null>;
15
24
  listWorkspaces(): Promise<Workspace[]>;
25
+ updateWorkspace(id: string, patch: {
26
+ name?: string;
27
+ metadata?: Record<string, unknown>;
28
+ }): Promise<Workspace>;
16
29
  deleteWorkspace(id: string): Promise<void>;
17
30
  issueKey(workspaceId: string, opts?: {
18
31
  name?: string;
@@ -6,6 +6,10 @@ export class InMemoryKeyStore {
6
6
  workspaces = new Map();
7
7
  keys = new Map();
8
8
  byHash = new Map();
9
+ /** Mirror of engram_workspaces.org_id. Lookup-only — fakes / org-stores
10
+ * in tests read this to answer membership queries without re-tracking
11
+ * the binding themselves. */
12
+ orgIds = new Map();
9
13
  async createWorkspace(input) {
10
14
  const id = resolveWorkspaceId(input);
11
15
  const existing = this.workspaces.get(id);
@@ -18,16 +22,39 @@ export class InMemoryKeyStore {
18
22
  createdAt: new Date().toISOString(),
19
23
  };
20
24
  this.workspaces.set(id, ws);
25
+ if (input.orgId !== undefined)
26
+ this.orgIds.set(id, input.orgId);
21
27
  return ws;
22
28
  }
29
+ /** Org id stamped on a workspace at creation, or undefined if not org-scoped. */
30
+ getWorkspaceOrgId(workspaceId) {
31
+ return this.orgIds.get(workspaceId);
32
+ }
33
+ /** All (workspaceId, orgId) pairs. Read-only view for fakes. */
34
+ workspaceOrgBindings() {
35
+ return this.orgIds;
36
+ }
23
37
  async getWorkspace(id) {
24
38
  return this.workspaces.get(id) ?? null;
25
39
  }
26
40
  async listWorkspaces() {
27
41
  return [...this.workspaces.values()].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
28
42
  }
43
+ async updateWorkspace(id, patch) {
44
+ const existing = this.workspaces.get(id);
45
+ if (!existing)
46
+ throw new Error("workspace_not_found");
47
+ const next = {
48
+ ...existing,
49
+ ...(patch.name !== undefined ? { name: patch.name } : {}),
50
+ ...(patch.metadata !== undefined ? { metadata: patch.metadata } : {}),
51
+ };
52
+ this.workspaces.set(id, next);
53
+ return next;
54
+ }
29
55
  async deleteWorkspace(id) {
30
56
  this.workspaces.delete(id);
57
+ this.orgIds.delete(id);
31
58
  for (const [keyId, row] of this.keys) {
32
59
  if (row.workspaceId === id) {
33
60
  this.byHash.delete(row.keyHash);
@@ -1,11 +1,5 @@
1
- import { foldEvents } from "../storage";
2
- function defaultPersonId() {
3
- const ALPHA = "abcdefghijklmnopqrstuvwxyz0123456789";
4
- let out = "p_";
5
- for (let i = 0; i < 8; i++)
6
- out += ALPHA[Math.floor(Math.random() * ALPHA.length)];
7
- return out;
8
- }
1
+ import { foldEvents, newPersonId } from "../storage";
2
+ import { applyPartial } from "./util";
9
3
  /**
10
4
  * In-process storage adapter for tests, dev, and small single-node deploys.
11
5
  * Idempotency: events keyed by (sessionId, seq) — duplicates overwrite by seq.
@@ -19,7 +13,7 @@ export class InMemoryAdapter {
19
13
  identities = new Map();
20
14
  newPersonId;
21
15
  constructor(opts = {}) {
22
- this.newPersonId = opts.newPersonId ?? defaultPersonId;
16
+ this.newPersonId = opts.newPersonId ?? newPersonId;
23
17
  }
24
18
  // --- Sessions -----------------------------------------------------
25
19
  async createSession(init) {
@@ -48,6 +42,12 @@ export class InMemoryAdapter {
48
42
  ...(init.trigger_event_id != null
49
43
  ? { trigger_event_id: init.trigger_event_id }
50
44
  : {}),
45
+ ...(init.trigger_purpose != null
46
+ ? { trigger_purpose: init.trigger_purpose }
47
+ : {}),
48
+ ...(init.trigger_resume_hint != null
49
+ ? { trigger_resume_hint: init.trigger_resume_hint }
50
+ : {}),
51
51
  },
52
52
  events: new Map(),
53
53
  });
@@ -88,49 +88,12 @@ export class InMemoryAdapter {
88
88
  const s = this.sessions.get(sessionId);
89
89
  if (!s)
90
90
  return null;
91
- // Patch semantics: undefined = leave alone; null = clear; value = set.
92
- // SessionRow optional fields use `string | undefined`, so a "null"
93
- // request collapses to `undefined` storage-side.
94
- const next = { ...s.row };
95
- if (patch.title !== undefined) {
96
- if (patch.title === null)
97
- delete next.title;
98
- else
99
- next.title = patch.title;
100
- }
101
- if (patch.channel !== undefined) {
102
- if (patch.channel === null)
103
- delete next.channel;
104
- else
105
- next.channel = patch.channel;
106
- }
107
- if (patch.status !== undefined) {
108
- next.status = patch.status;
109
- }
110
- if (patch.summary !== undefined) {
111
- if (patch.summary === null)
112
- delete next.summary;
113
- else
114
- next.summary = patch.summary;
115
- }
116
- if (patch.model !== undefined) {
117
- if (patch.model === null)
118
- delete next.model;
119
- else
120
- next.model = patch.model;
121
- }
122
- if (patch.trigger_conversation_id !== undefined) {
123
- if (patch.trigger_conversation_id === null)
124
- delete next.trigger_conversation_id;
125
- else
126
- next.trigger_conversation_id = patch.trigger_conversation_id;
127
- }
128
- if (patch.trigger_event_id !== undefined) {
129
- if (patch.trigger_event_id === null)
130
- delete next.trigger_event_id;
131
- else
132
- next.trigger_event_id = patch.trigger_event_id;
133
- }
91
+ // Patch semantics: undefined = leave alone; null = clear (deletes the
92
+ // key, matching the SessionRow shape where optional fields are absent);
93
+ // value = set. `status` clears to "active" to match the column default.
94
+ const next = applyPartial(s.row, patch, {
95
+ status: "active",
96
+ });
134
97
  next.updatedAt = new Date().toISOString();
135
98
  s.row = next;
136
99
  return foldEvents(s.row, [...s.events.values()], new Date());
@@ -213,16 +176,15 @@ export class InMemoryAdapter {
213
176
  const existing = this.persons.get(id);
214
177
  if (!existing)
215
178
  return null;
216
- const now = new Date().toISOString();
217
- // undefined = no-op, null = clear (per the SDK contract).
218
- const next = {
219
- ...existing,
220
- display_name: patch.display_name !== undefined ? patch.display_name : existing.display_name,
221
- role: patch.role !== undefined ? patch.role : existing.role ?? null,
222
- team: patch.team !== undefined ? patch.team : existing.team ?? null,
223
- source: patch.source !== undefined ? patch.source : existing.source ?? "auto",
224
- updated_at: now,
225
- };
179
+ // undefined = no-op; null = clear (display_name/role/team to null,
180
+ // source to "auto" to match the column default).
181
+ const next = applyPartial(existing, patch, {
182
+ display_name: null,
183
+ role: null,
184
+ team: null,
185
+ source: "auto",
186
+ });
187
+ next.updated_at = new Date().toISOString();
226
188
  this.persons.set(id, next);
227
189
  return next;
228
190
  }
@@ -269,8 +231,18 @@ export class InMemoryAdapter {
269
231
  }
270
232
  // --- Aliases ------------------------------------------------------
271
233
  async upsertAlias(personId, input) {
272
- if (!this.persons.has(personId))
273
- return null;
234
+ if (!this.persons.has(personId)) {
235
+ const stubNow = new Date().toISOString();
236
+ this.persons.set(personId, {
237
+ id: personId,
238
+ display_name: null,
239
+ role: null,
240
+ team: null,
241
+ source: undefined,
242
+ created_at: stubNow,
243
+ updated_at: stubNow,
244
+ });
245
+ }
274
246
  const key = aliasKey(personId, input.name);
275
247
  const now = new Date().toISOString();
276
248
  const existing = this.aliases.get(key);
@@ -304,8 +276,23 @@ export class InMemoryAdapter {
304
276
  }
305
277
  // --- Identities ---------------------------------------------------
306
278
  async upsertIdentity(ref, input) {
307
- if (!this.persons.has(input.person_id))
308
- return null;
279
+ // Auto-create a stub person when missing. The two telemetry
280
+ // calls (person + identity) race over the network; treating
281
+ // the identity PUT as authoritative for "this person id exists"
282
+ // matches the host-supplied-id upsert model and avoids a 404
283
+ // when the calls arrive out of order.
284
+ if (!this.persons.has(input.person_id)) {
285
+ const stubNow = new Date().toISOString();
286
+ this.persons.set(input.person_id, {
287
+ id: input.person_id,
288
+ display_name: null,
289
+ role: null,
290
+ team: null,
291
+ source: undefined,
292
+ created_at: stubNow,
293
+ updated_at: stubNow,
294
+ });
295
+ }
309
296
  const now = new Date().toISOString();
310
297
  const existing = this.identities.get(ref);
311
298
  // COALESCE-on-conflict for the soft-overlap fields; ref-level
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Tagged-template SQL client backed by node-postgres (`pg`).
3
+ *
4
+ * The rest of the codebase was written against the `postgres` package's
5
+ * tagged-template surface (`SqlClient` in ./postgres.ts). This adapter
6
+ * exposes the same shape on top of a `pg.Pool` (or `pg.PoolClient`) so
7
+ * we can unify on a single SQL driver — better-auth requires `pg`, and
8
+ * keeping two pools around just to satisfy `postgres-js`'s template API
9
+ * was preventing cross-store transactions.
10
+ *
11
+ * Translation rule: each `${value}` becomes `$N` in the emitted SQL,
12
+ * and the values land in the parameter array in tag order. Arrays
13
+ * map directly to Postgres arrays via `pg`'s native handling (callers
14
+ * already include explicit `::text[]` casts where needed).
15
+ */
16
+ import type { Pool, PoolClient } from "pg";
17
+ import type { SqlClient } from "./postgres";
18
+ export declare function pgSqlClient(executor: Pool | PoolClient): SqlClient;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Tagged-template SQL client backed by node-postgres (`pg`).
3
+ *
4
+ * The rest of the codebase was written against the `postgres` package's
5
+ * tagged-template surface (`SqlClient` in ./postgres.ts). This adapter
6
+ * exposes the same shape on top of a `pg.Pool` (or `pg.PoolClient`) so
7
+ * we can unify on a single SQL driver — better-auth requires `pg`, and
8
+ * keeping two pools around just to satisfy `postgres-js`'s template API
9
+ * was preventing cross-store transactions.
10
+ *
11
+ * Translation rule: each `${value}` becomes `$N` in the emitted SQL,
12
+ * and the values land in the parameter array in tag order. Arrays
13
+ * map directly to Postgres arrays via `pg`'s native handling (callers
14
+ * already include explicit `::text[]` casts where needed).
15
+ */
16
+ export function pgSqlClient(executor) {
17
+ const tagged = (async (strings, ...values) => {
18
+ let text = strings[0];
19
+ for (let i = 0; i < values.length; i++) {
20
+ text += `$${i + 1}` + strings[i + 1];
21
+ }
22
+ const r = await executor.query({ text, values });
23
+ return r.rows;
24
+ });
25
+ tagged.unsafe = async (text) => {
26
+ await executor.query(text);
27
+ };
28
+ return tagged;
29
+ }
@@ -12,9 +12,14 @@ export declare class PostgresKeyStore implements KeyStore {
12
12
  id?: string;
13
13
  name?: string;
14
14
  metadata?: Record<string, unknown>;
15
+ orgId?: string;
15
16
  }): Promise<Workspace>;
16
17
  getWorkspace(id: string): Promise<Workspace | null>;
17
18
  listWorkspaces(): Promise<Workspace[]>;
19
+ updateWorkspace(id: string, patch: {
20
+ name?: string;
21
+ metadata?: Record<string, unknown>;
22
+ }): Promise<Workspace>;
18
23
  deleteWorkspace(id: string): Promise<void>;
19
24
  issueKey(workspaceId: string, opts?: {
20
25
  name?: string;
@@ -1,5 +1,6 @@
1
1
  import { deriveKey, hashKey, mintKey, resolveWorkspaceId, } from "../key-store";
2
2
  import { runMigrations } from "../migrator";
3
+ import { isoString } from "./util";
3
4
  export class PostgresKeyStore {
4
5
  sql;
5
6
  constructor(sql) {
@@ -15,8 +16,13 @@ export class PostgresKeyStore {
15
16
  async createWorkspace(input) {
16
17
  const id = resolveWorkspaceId(input);
17
18
  await this.sql `
18
- INSERT INTO engram_workspaces (id, name, metadata)
19
- VALUES (${id}, ${input.name ?? null}, ${(input.metadata ?? {})})
19
+ INSERT INTO engram_workspaces (id, name, metadata, org_id)
20
+ VALUES (
21
+ ${id},
22
+ ${input.name ?? null},
23
+ ${(input.metadata ?? {})},
24
+ ${input.orgId ?? null}
25
+ )
20
26
  ON CONFLICT (id) DO NOTHING
21
27
  `;
22
28
  const ws = await this.getWorkspace(id);
@@ -36,6 +42,19 @@ export class PostgresKeyStore {
36
42
  `;
37
43
  return rows.map(toWorkspace);
38
44
  }
45
+ async updateWorkspace(id, patch) {
46
+ const rows = await this.sql `
47
+ UPDATE engram_workspaces
48
+ SET
49
+ name = COALESCE(${patch.name ?? null}, name),
50
+ metadata = COALESCE(${patch.metadata ? patch.metadata : null}, metadata)
51
+ WHERE id = ${id}
52
+ RETURNING id, name, metadata, created_at
53
+ `;
54
+ if (rows.length === 0)
55
+ throw new Error("workspace_not_found");
56
+ return toWorkspace(rows[0]);
57
+ }
39
58
  async deleteWorkspace(id) {
40
59
  // engram_api_keys cascades via FK. engram_sessions/events are explicit
41
60
  // since they predate the workspaces table and have no FK to it.
@@ -132,6 +151,3 @@ function toInfo(r) {
132
151
  ...(r.revoked_at !== null ? { revokedAt: isoString(r.revoked_at) } : {}),
133
152
  };
134
153
  }
135
- function isoString(v) {
136
- return typeof v === "string" ? v : v.toISOString();
137
- }
@@ -10,6 +10,10 @@ export declare class PostgresOrgStore implements OrgStore {
10
10
  }): Promise<OrgRow>;
11
11
  getOrg(id: string): Promise<OrgRow | null>;
12
12
  listOrgs(): Promise<OrgRow[]>;
13
+ updateOrg(id: string, patch: {
14
+ name?: string;
15
+ metadata?: Record<string, unknown>;
16
+ }): Promise<OrgRow>;
13
17
  deleteOrg(id: string): Promise<void>;
14
18
  listMembers(orgId: string): Promise<OrgMembershipRow[]>;
15
19
  upsertMember(input: {
@@ -23,7 +27,6 @@ export declare class PostgresOrgStore implements OrgStore {
23
27
  email: string;
24
28
  } | null>;
25
29
  listOrgsForUser(userId: string): Promise<OrgMembershipRow[]>;
26
- setWorkspaceOrg(workspaceId: string, orgId: string): Promise<void>;
27
30
  listWorkspacesForOrg(orgId: string): Promise<{
28
31
  id: string;
29
32
  name: string | null;
@@ -34,4 +37,5 @@ export declare class PostgresOrgStore implements OrgStore {
34
37
  orgId: string;
35
38
  }[]>;
36
39
  userCanAccessWorkspace(userId: string, workspaceId: string): Promise<boolean>;
40
+ workspaceInOrg(orgId: string, workspaceId: string): Promise<boolean>;
37
41
  }
@@ -1,13 +1,11 @@
1
1
  import { randomUUID } from "node:crypto";
2
- function iso(v) {
3
- return typeof v === "string" ? v : v.toISOString();
4
- }
2
+ import { isoString } from "./util";
5
3
  function toOrg(r) {
6
4
  return {
7
5
  id: r.id,
8
6
  name: r.name,
9
7
  metadata: r.metadata ?? {},
10
- createdAt: iso(r.created_at),
8
+ createdAt: isoString(r.created_at),
11
9
  };
12
10
  }
13
11
  function toMember(r) {
@@ -15,7 +13,7 @@ function toMember(r) {
15
13
  orgId: r.org_id,
16
14
  userId: r.user_id,
17
15
  role: r.role,
18
- joinedAt: iso(r.joined_at),
16
+ joinedAt: isoString(r.joined_at),
19
17
  };
20
18
  }
21
19
  export class PostgresOrgStore {
@@ -45,6 +43,21 @@ export class PostgresOrgStore {
45
43
  const { rows } = await this.pool.query(`SELECT id, name, metadata, created_at FROM engram_orgs ORDER BY created_at`);
46
44
  return rows.map(toOrg);
47
45
  }
46
+ async updateOrg(id, patch) {
47
+ const { rows } = await this.pool.query(`UPDATE engram_orgs
48
+ SET
49
+ name = COALESCE($2, name),
50
+ metadata = COALESCE($3::jsonb, metadata)
51
+ WHERE id = $1
52
+ RETURNING id, name, metadata, created_at`, [
53
+ id,
54
+ patch.name ?? null,
55
+ patch.metadata ? JSON.stringify(patch.metadata) : null,
56
+ ]);
57
+ if (rows.length === 0)
58
+ throw new Error("org_not_found");
59
+ return toOrg(rows[0]);
60
+ }
48
61
  async deleteOrg(id) {
49
62
  // CASCADE drops members + workspaces under this org.
50
63
  await this.pool.query(`DELETE FROM engram_orgs WHERE id = $1`, [id]);
@@ -76,9 +89,8 @@ export class PostgresOrgStore {
76
89
  return rows.map(toMember);
77
90
  }
78
91
  // ---------- workspace ↔ org link -----------------------------
79
- async setWorkspaceOrg(workspaceId, orgId) {
80
- await this.pool.query(`UPDATE engram_workspaces SET org_id = $1 WHERE id = $2`, [orgId, workspaceId]);
81
- }
92
+ // engram_workspaces.org_id is written by KeyStore.createWorkspace in
93
+ // the same INSERT as the workspace row. This store only reads.
82
94
  async listWorkspacesForOrg(orgId) {
83
95
  const { rows } = await this.pool.query(`SELECT id, name FROM engram_workspaces WHERE org_id = $1 ORDER BY created_at`, [orgId]);
84
96
  return rows;
@@ -99,4 +111,9 @@ export class PostgresOrgStore {
99
111
  LIMIT 1`, [userId, workspaceId]);
100
112
  return rows.length > 0;
101
113
  }
114
+ async workspaceInOrg(orgId, workspaceId) {
115
+ const { rows } = await this.pool.query(`SELECT 1 AS ok FROM engram_workspaces
116
+ WHERE id = $1 AND org_id = $2 LIMIT 1`, [workspaceId, orgId]);
117
+ return rows.length > 0;
118
+ }
102
119
  }