@hexis-ai/engram-server 0.12.0 → 0.13.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.
- package/dist/adapters/memory-key-store.d.ts +4 -0
- package/dist/adapters/memory-key-store.js +12 -0
- package/dist/adapters/memory.js +47 -66
- package/dist/adapters/pg-tagged.d.ts +18 -0
- package/dist/adapters/pg-tagged.js +29 -0
- package/dist/adapters/postgres-key-store.d.ts +4 -0
- package/dist/adapters/postgres-key-store.js +14 -3
- package/dist/adapters/postgres-org-store.d.ts +5 -0
- package/dist/adapters/postgres-org-store.js +23 -5
- package/dist/adapters/postgres.js +57 -80
- package/dist/adapters/util.d.ts +27 -0
- package/dist/adapters/util.js +47 -0
- package/dist/admin.js +78 -89
- package/dist/key-store.d.ts +5 -0
- package/dist/main.js +29 -44
- package/dist/openapi.js +340 -3
- package/dist/org-store.d.ts +7 -0
- package/dist/routes/orgs.d.ts +27 -0
- package/dist/routes/orgs.js +185 -0
- package/dist/schemas.d.ts +18 -0
- package/dist/schemas.js +19 -0
- package/dist/server.d.ts +13 -0
- package/dist/server.js +40 -23
- package/dist/services/orgs.d.ts +95 -0
- package/dist/services/orgs.js +159 -0
- package/dist/storage.d.ts +6 -0
- package/dist/storage.js +14 -0
- package/openapi.json +1279 -1
- package/package.json +3 -12
|
@@ -13,6 +13,10 @@ export declare class InMemoryKeyStore implements KeyStore {
|
|
|
13
13
|
}): Promise<Workspace>;
|
|
14
14
|
getWorkspace(id: string): Promise<Workspace | null>;
|
|
15
15
|
listWorkspaces(): Promise<Workspace[]>;
|
|
16
|
+
updateWorkspace(id: string, patch: {
|
|
17
|
+
name?: string;
|
|
18
|
+
metadata?: Record<string, unknown>;
|
|
19
|
+
}): Promise<Workspace>;
|
|
16
20
|
deleteWorkspace(id: string): Promise<void>;
|
|
17
21
|
issueKey(workspaceId: string, opts?: {
|
|
18
22
|
name?: string;
|
|
@@ -26,6 +26,18 @@ export class InMemoryKeyStore {
|
|
|
26
26
|
async listWorkspaces() {
|
|
27
27
|
return [...this.workspaces.values()].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
28
28
|
}
|
|
29
|
+
async updateWorkspace(id, patch) {
|
|
30
|
+
const existing = this.workspaces.get(id);
|
|
31
|
+
if (!existing)
|
|
32
|
+
throw new Error("workspace_not_found");
|
|
33
|
+
const next = {
|
|
34
|
+
...existing,
|
|
35
|
+
...(patch.name !== undefined ? { name: patch.name } : {}),
|
|
36
|
+
...(patch.metadata !== undefined ? { metadata: patch.metadata } : {}),
|
|
37
|
+
};
|
|
38
|
+
this.workspaces.set(id, next);
|
|
39
|
+
return next;
|
|
40
|
+
}
|
|
29
41
|
async deleteWorkspace(id) {
|
|
30
42
|
this.workspaces.delete(id);
|
|
31
43
|
for (const [keyId, row] of this.keys) {
|
package/dist/adapters/memory.js
CHANGED
|
@@ -1,11 +1,5 @@
|
|
|
1
|
-
import { foldEvents } from "../storage";
|
|
2
|
-
|
|
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 ??
|
|
16
|
+
this.newPersonId = opts.newPersonId ?? newPersonId;
|
|
23
17
|
}
|
|
24
18
|
// --- Sessions -----------------------------------------------------
|
|
25
19
|
async createSession(init) {
|
|
@@ -88,49 +82,12 @@ export class InMemoryAdapter {
|
|
|
88
82
|
const s = this.sessions.get(sessionId);
|
|
89
83
|
if (!s)
|
|
90
84
|
return null;
|
|
91
|
-
// Patch semantics: undefined = leave alone; null = clear
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
const next =
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
}
|
|
85
|
+
// Patch semantics: undefined = leave alone; null = clear (deletes the
|
|
86
|
+
// key, matching the SessionRow shape where optional fields are absent);
|
|
87
|
+
// value = set. `status` clears to "active" to match the column default.
|
|
88
|
+
const next = applyPartial(s.row, patch, {
|
|
89
|
+
status: "active",
|
|
90
|
+
});
|
|
134
91
|
next.updatedAt = new Date().toISOString();
|
|
135
92
|
s.row = next;
|
|
136
93
|
return foldEvents(s.row, [...s.events.values()], new Date());
|
|
@@ -213,16 +170,15 @@ export class InMemoryAdapter {
|
|
|
213
170
|
const existing = this.persons.get(id);
|
|
214
171
|
if (!existing)
|
|
215
172
|
return null;
|
|
216
|
-
|
|
217
|
-
//
|
|
218
|
-
const next = {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
};
|
|
173
|
+
// undefined = no-op; null = clear (display_name/role/team to null,
|
|
174
|
+
// source to "auto" to match the column default).
|
|
175
|
+
const next = applyPartial(existing, patch, {
|
|
176
|
+
display_name: null,
|
|
177
|
+
role: null,
|
|
178
|
+
team: null,
|
|
179
|
+
source: "auto",
|
|
180
|
+
});
|
|
181
|
+
next.updated_at = new Date().toISOString();
|
|
226
182
|
this.persons.set(id, next);
|
|
227
183
|
return next;
|
|
228
184
|
}
|
|
@@ -269,8 +225,18 @@ export class InMemoryAdapter {
|
|
|
269
225
|
}
|
|
270
226
|
// --- Aliases ------------------------------------------------------
|
|
271
227
|
async upsertAlias(personId, input) {
|
|
272
|
-
if (!this.persons.has(personId))
|
|
273
|
-
|
|
228
|
+
if (!this.persons.has(personId)) {
|
|
229
|
+
const stubNow = new Date().toISOString();
|
|
230
|
+
this.persons.set(personId, {
|
|
231
|
+
id: personId,
|
|
232
|
+
display_name: null,
|
|
233
|
+
role: null,
|
|
234
|
+
team: null,
|
|
235
|
+
source: undefined,
|
|
236
|
+
created_at: stubNow,
|
|
237
|
+
updated_at: stubNow,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
274
240
|
const key = aliasKey(personId, input.name);
|
|
275
241
|
const now = new Date().toISOString();
|
|
276
242
|
const existing = this.aliases.get(key);
|
|
@@ -304,8 +270,23 @@ export class InMemoryAdapter {
|
|
|
304
270
|
}
|
|
305
271
|
// --- Identities ---------------------------------------------------
|
|
306
272
|
async upsertIdentity(ref, input) {
|
|
307
|
-
|
|
308
|
-
|
|
273
|
+
// Auto-create a stub person when missing. The two telemetry
|
|
274
|
+
// calls (person + identity) race over the network; treating
|
|
275
|
+
// the identity PUT as authoritative for "this person id exists"
|
|
276
|
+
// matches the host-supplied-id upsert model and avoids a 404
|
|
277
|
+
// when the calls arrive out of order.
|
|
278
|
+
if (!this.persons.has(input.person_id)) {
|
|
279
|
+
const stubNow = new Date().toISOString();
|
|
280
|
+
this.persons.set(input.person_id, {
|
|
281
|
+
id: input.person_id,
|
|
282
|
+
display_name: null,
|
|
283
|
+
role: null,
|
|
284
|
+
team: null,
|
|
285
|
+
source: undefined,
|
|
286
|
+
created_at: stubNow,
|
|
287
|
+
updated_at: stubNow,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
309
290
|
const now = new Date().toISOString();
|
|
310
291
|
const existing = this.identities.get(ref);
|
|
311
292
|
// 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
|
+
}
|
|
@@ -15,6 +15,10 @@ export declare class PostgresKeyStore implements KeyStore {
|
|
|
15
15
|
}): Promise<Workspace>;
|
|
16
16
|
getWorkspace(id: string): Promise<Workspace | null>;
|
|
17
17
|
listWorkspaces(): Promise<Workspace[]>;
|
|
18
|
+
updateWorkspace(id: string, patch: {
|
|
19
|
+
name?: string;
|
|
20
|
+
metadata?: Record<string, unknown>;
|
|
21
|
+
}): Promise<Workspace>;
|
|
18
22
|
deleteWorkspace(id: string): Promise<void>;
|
|
19
23
|
issueKey(workspaceId: string, opts?: {
|
|
20
24
|
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) {
|
|
@@ -36,6 +37,19 @@ export class PostgresKeyStore {
|
|
|
36
37
|
`;
|
|
37
38
|
return rows.map(toWorkspace);
|
|
38
39
|
}
|
|
40
|
+
async updateWorkspace(id, patch) {
|
|
41
|
+
const rows = await this.sql `
|
|
42
|
+
UPDATE engram_workspaces
|
|
43
|
+
SET
|
|
44
|
+
name = COALESCE(${patch.name ?? null}, name),
|
|
45
|
+
metadata = COALESCE(${patch.metadata ? patch.metadata : null}, metadata)
|
|
46
|
+
WHERE id = ${id}
|
|
47
|
+
RETURNING id, name, metadata, created_at
|
|
48
|
+
`;
|
|
49
|
+
if (rows.length === 0)
|
|
50
|
+
throw new Error("workspace_not_found");
|
|
51
|
+
return toWorkspace(rows[0]);
|
|
52
|
+
}
|
|
39
53
|
async deleteWorkspace(id) {
|
|
40
54
|
// engram_api_keys cascades via FK. engram_sessions/events are explicit
|
|
41
55
|
// since they predate the workspaces table and have no FK to it.
|
|
@@ -132,6 +146,3 @@ function toInfo(r) {
|
|
|
132
146
|
...(r.revoked_at !== null ? { revokedAt: isoString(r.revoked_at) } : {}),
|
|
133
147
|
};
|
|
134
148
|
}
|
|
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: {
|
|
@@ -34,4 +38,5 @@ export declare class PostgresOrgStore implements OrgStore {
|
|
|
34
38
|
orgId: string;
|
|
35
39
|
}[]>;
|
|
36
40
|
userCanAccessWorkspace(userId: string, workspaceId: string): Promise<boolean>;
|
|
41
|
+
workspaceInOrg(orgId: string, workspaceId: string): Promise<boolean>;
|
|
37
42
|
}
|
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
|
|
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:
|
|
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:
|
|
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]);
|
|
@@ -99,4 +112,9 @@ export class PostgresOrgStore {
|
|
|
99
112
|
LIMIT 1`, [userId, workspaceId]);
|
|
100
113
|
return rows.length > 0;
|
|
101
114
|
}
|
|
115
|
+
async workspaceInOrg(orgId, workspaceId) {
|
|
116
|
+
const { rows } = await this.pool.query(`SELECT 1 AS ok FROM engram_workspaces
|
|
117
|
+
WHERE id = $1 AND org_id = $2 LIMIT 1`, [workspaceId, orgId]);
|
|
118
|
+
return rows.length > 0;
|
|
119
|
+
}
|
|
102
120
|
}
|
|
@@ -1,19 +1,6 @@
|
|
|
1
1
|
import { runMigrations } from "../migrator";
|
|
2
|
-
import { foldEvents } from "../storage";
|
|
3
|
-
|
|
4
|
-
* 10-char alphanumeric id, e.g. `p_a8b3c2d4`. Cryptographic randomness via
|
|
5
|
-
* the platform's getRandomValues; collision probability negligible for any
|
|
6
|
-
* realistic person count.
|
|
7
|
-
*/
|
|
8
|
-
function defaultPersonId() {
|
|
9
|
-
const ALPHA = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
10
|
-
const buf = new Uint8Array(8);
|
|
11
|
-
crypto.getRandomValues(buf);
|
|
12
|
-
let out = "p_";
|
|
13
|
-
for (const b of buf)
|
|
14
|
-
out += ALPHA[b % ALPHA.length];
|
|
15
|
-
return out;
|
|
16
|
-
}
|
|
2
|
+
import { foldEvents, newPersonId } from "../storage";
|
|
3
|
+
import { isoString, pickPatch } from "./util";
|
|
17
4
|
export class PostgresAdapter {
|
|
18
5
|
workspaceId;
|
|
19
6
|
sql;
|
|
@@ -21,7 +8,7 @@ export class PostgresAdapter {
|
|
|
21
8
|
constructor(opts) {
|
|
22
9
|
this.workspaceId = opts.workspaceId;
|
|
23
10
|
this.sql = opts.sql;
|
|
24
|
-
this.newPersonId = opts.newPersonId ??
|
|
11
|
+
this.newPersonId = opts.newPersonId ?? newPersonId;
|
|
25
12
|
}
|
|
26
13
|
/**
|
|
27
14
|
* Apply all pending schema migrations. Safe to call repeatedly and
|
|
@@ -125,29 +112,27 @@ export class PostgresAdapter {
|
|
|
125
112
|
return foldEvents(toSessionRow(rows[0]), events.map((e) => e.payload), new Date());
|
|
126
113
|
}
|
|
127
114
|
async updateSession(sessionId, patch) {
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
const
|
|
132
|
-
const
|
|
133
|
-
const
|
|
134
|
-
const
|
|
135
|
-
const
|
|
136
|
-
const
|
|
137
|
-
const
|
|
115
|
+
// Per-column "provided" flags drive a CASE WHEN per field: undefined
|
|
116
|
+
// leaves the column alone, null clears, value sets. `status` defaults
|
|
117
|
+
// to "active" on clear to match the column default.
|
|
118
|
+
const title = pickPatch(patch, "title");
|
|
119
|
+
const channel = pickPatch(patch, "channel");
|
|
120
|
+
const status = pickPatch(patch, "status", "active");
|
|
121
|
+
const summary = pickPatch(patch, "summary");
|
|
122
|
+
const model = pickPatch(patch, "model");
|
|
123
|
+
const tcId = pickPatch(patch, "trigger_conversation_id");
|
|
124
|
+
const teId = pickPatch(patch, "trigger_event_id");
|
|
138
125
|
const rows = await this.sql `
|
|
139
126
|
UPDATE engram_sessions SET
|
|
140
|
-
title = CASE WHEN ${
|
|
141
|
-
channel = CASE WHEN ${
|
|
142
|
-
status = CASE WHEN ${
|
|
143
|
-
summary = CASE WHEN ${
|
|
144
|
-
model = CASE WHEN ${
|
|
145
|
-
trigger_conversation_id = CASE WHEN ${
|
|
146
|
-
THEN ${
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
THEN ${patch.trigger_event_id ?? null}
|
|
150
|
-
ELSE trigger_event_id END,
|
|
127
|
+
title = CASE WHEN ${title.provided} THEN ${title.value} ELSE title END,
|
|
128
|
+
channel = CASE WHEN ${channel.provided} THEN ${channel.value} ELSE channel END,
|
|
129
|
+
status = CASE WHEN ${status.provided} THEN ${status.value} ELSE status END,
|
|
130
|
+
summary = CASE WHEN ${summary.provided} THEN ${summary.value} ELSE summary END,
|
|
131
|
+
model = CASE WHEN ${model.provided} THEN ${model.value} ELSE model END,
|
|
132
|
+
trigger_conversation_id = CASE WHEN ${tcId.provided}
|
|
133
|
+
THEN ${tcId.value} ELSE trigger_conversation_id END,
|
|
134
|
+
trigger_event_id = CASE WHEN ${teId.provided}
|
|
135
|
+
THEN ${teId.value} ELSE trigger_event_id END,
|
|
151
136
|
updated_at = now()
|
|
152
137
|
WHERE workspace_id = ${this.workspaceId} AND id = ${sessionId}
|
|
153
138
|
RETURNING id
|
|
@@ -246,17 +231,18 @@ export class PostgresAdapter {
|
|
|
246
231
|
return toPersonInfo(rows[0]);
|
|
247
232
|
}
|
|
248
233
|
async updatePerson(id, patch) {
|
|
249
|
-
//
|
|
250
|
-
|
|
251
|
-
const
|
|
252
|
-
const
|
|
253
|
-
const
|
|
234
|
+
// undefined = no-op, null = clear (source clears to "auto" to match
|
|
235
|
+
// the column default).
|
|
236
|
+
const name = pickPatch(patch, "display_name");
|
|
237
|
+
const role = pickPatch(patch, "role");
|
|
238
|
+
const team = pickPatch(patch, "team");
|
|
239
|
+
const source = pickPatch(patch, "source", "auto");
|
|
254
240
|
const rows = await this.sql `
|
|
255
241
|
UPDATE engram_persons SET
|
|
256
|
-
display_name = CASE WHEN ${
|
|
257
|
-
role = CASE WHEN ${
|
|
258
|
-
team = CASE WHEN ${
|
|
259
|
-
source = CASE WHEN ${
|
|
242
|
+
display_name = CASE WHEN ${name.provided} THEN ${name.value} ELSE display_name END,
|
|
243
|
+
role = CASE WHEN ${role.provided} THEN ${role.value} ELSE role END,
|
|
244
|
+
team = CASE WHEN ${team.provided} THEN ${team.value} ELSE team END,
|
|
245
|
+
source = CASE WHEN ${source.provided} THEN ${source.value} ELSE source END,
|
|
260
246
|
updated_at = now()
|
|
261
247
|
WHERE workspace_id = ${this.workspaceId} AND id = ${id}
|
|
262
248
|
RETURNING id, display_name, role, team, source, created_at, updated_at
|
|
@@ -326,15 +312,13 @@ export class PostgresAdapter {
|
|
|
326
312
|
}
|
|
327
313
|
// --- Aliases ------------------------------------------------------
|
|
328
314
|
async upsertAlias(personId, input) {
|
|
329
|
-
//
|
|
330
|
-
//
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
315
|
+
// Same race as upsertIdentity — alias telemetry can land before
|
|
316
|
+
// the matching person telemetry. Auto-create a stub person.
|
|
317
|
+
await this.sql `
|
|
318
|
+
INSERT INTO engram_persons (workspace_id, id)
|
|
319
|
+
VALUES (${this.workspaceId}, ${personId})
|
|
320
|
+
ON CONFLICT (workspace_id, id) DO NOTHING
|
|
335
321
|
`;
|
|
336
|
-
if (personExists.length === 0)
|
|
337
|
-
return null;
|
|
338
322
|
const increment = input.increment ?? true;
|
|
339
323
|
// Branch on the upsert behaviour. `increment=true` bumps usage_count
|
|
340
324
|
// and replaces caller/last_used; `increment=false` is idempotent —
|
|
@@ -390,15 +374,15 @@ export class PostgresAdapter {
|
|
|
390
374
|
}
|
|
391
375
|
// --- Identities ---------------------------------------------------
|
|
392
376
|
async upsertIdentity(ref, input) {
|
|
393
|
-
//
|
|
394
|
-
//
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
377
|
+
// Auto-create a stub person if missing. The two telemetry calls
|
|
378
|
+
// (person + identity) leave monet fire-and-forget and race over
|
|
379
|
+
// the network, so it's normal for the identity PUT to land first.
|
|
380
|
+
// Empty stub gets enriched by the trailing person PUT.
|
|
381
|
+
await this.sql `
|
|
382
|
+
INSERT INTO engram_persons (workspace_id, id)
|
|
383
|
+
VALUES (${this.workspaceId}, ${input.person_id})
|
|
384
|
+
ON CONFLICT (workspace_id, id) DO NOTHING
|
|
399
385
|
`;
|
|
400
|
-
if (personExists.length === 0)
|
|
401
|
-
return null;
|
|
402
386
|
// unlinked_at semantics: undefined = leave alone, null = clear,
|
|
403
387
|
// value = set. matches the patch contract for the other fields.
|
|
404
388
|
const unlinkedProvided = input.unlinked_at !== undefined;
|
|
@@ -460,27 +444,25 @@ export class PostgresAdapter {
|
|
|
460
444
|
}
|
|
461
445
|
}
|
|
462
446
|
function toPersonInfo(r) {
|
|
463
|
-
const toIso = (v) => (typeof v === "string" ? v : v.toISOString());
|
|
464
447
|
return {
|
|
465
448
|
id: r.id,
|
|
466
449
|
display_name: r.display_name,
|
|
467
450
|
role: r.role,
|
|
468
451
|
team: r.team,
|
|
469
452
|
source: r.source,
|
|
470
|
-
created_at:
|
|
471
|
-
updated_at:
|
|
453
|
+
created_at: isoString(r.created_at),
|
|
454
|
+
updated_at: isoString(r.updated_at),
|
|
472
455
|
};
|
|
473
456
|
}
|
|
474
457
|
function toSessionRow(r) {
|
|
475
|
-
const toIso = (v) => (typeof v === "string" ? v : v.toISOString());
|
|
476
458
|
return {
|
|
477
459
|
id: r.id,
|
|
478
460
|
...(r.title ? { title: r.title } : {}),
|
|
479
461
|
...(r.channel ? { channel: r.channel } : {}),
|
|
480
462
|
participants: r.participants,
|
|
481
463
|
viewable_by: r.viewable_by,
|
|
482
|
-
createdAt:
|
|
483
|
-
updatedAt:
|
|
464
|
+
createdAt: isoString(r.created_at),
|
|
465
|
+
updatedAt: isoString(r.updated_at),
|
|
484
466
|
...(r.status === "active" || r.status === "idle" || r.status === "completed"
|
|
485
467
|
? { status: r.status }
|
|
486
468
|
: {}),
|
|
@@ -495,24 +477,19 @@ function toSessionRow(r) {
|
|
|
495
477
|
};
|
|
496
478
|
}
|
|
497
479
|
function toAliasInfo(r) {
|
|
498
|
-
const toIso = (v) => (typeof v === "string" ? v : v.toISOString());
|
|
499
480
|
// last_used is a DATE column; postgres-js returns it as a Date at UTC
|
|
500
481
|
// midnight. Render as plain YYYY-MM-DD to match the wire type.
|
|
501
|
-
const lastUsed = typeof r.last_used === "string"
|
|
502
|
-
? r.last_used.slice(0, 10)
|
|
503
|
-
: r.last_used.toISOString().slice(0, 10);
|
|
504
482
|
return {
|
|
505
483
|
person_id: r.person_id,
|
|
506
484
|
name: r.name,
|
|
507
485
|
caller: r.caller,
|
|
508
486
|
usage_count: r.usage_count,
|
|
509
|
-
last_used:
|
|
510
|
-
created_at:
|
|
511
|
-
updated_at:
|
|
487
|
+
last_used: isoString(r.last_used).slice(0, 10),
|
|
488
|
+
created_at: isoString(r.created_at),
|
|
489
|
+
updated_at: isoString(r.updated_at),
|
|
512
490
|
};
|
|
513
491
|
}
|
|
514
492
|
function toIdentityInfo(r) {
|
|
515
|
-
const toIso = (v) => (typeof v === "string" ? v : v.toISOString());
|
|
516
493
|
// linked_at is now TIMESTAMPTZ (post-migration 0004). Emit full ISO.
|
|
517
494
|
return {
|
|
518
495
|
ref: r.ref,
|
|
@@ -523,9 +500,9 @@ function toIdentityInfo(r) {
|
|
|
523
500
|
source: r.source,
|
|
524
501
|
is_primary: r.is_primary,
|
|
525
502
|
picture: r.picture,
|
|
526
|
-
linked_at:
|
|
527
|
-
unlinked_at: r.unlinked_at === null ? null :
|
|
528
|
-
created_at:
|
|
529
|
-
updated_at:
|
|
503
|
+
linked_at: isoString(r.linked_at),
|
|
504
|
+
unlinked_at: r.unlinked_at === null ? null : isoString(r.unlinked_at),
|
|
505
|
+
created_at: isoString(r.created_at),
|
|
506
|
+
updated_at: isoString(r.updated_at),
|
|
530
507
|
};
|
|
531
508
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** Postgres drivers return timestamp columns as either string or Date depending
|
|
2
|
+
* on the type parser. Normalise to ISO-8601 string for the wire format. */
|
|
3
|
+
export declare function isoString(v: string | Date): string;
|
|
4
|
+
/**
|
|
5
|
+
* Apply a partial patch with the SDK's three-state semantics:
|
|
6
|
+
* - `undefined` → keep `existing[key]`
|
|
7
|
+
* - `null` → clear (set to `clearTo`, default `null`)
|
|
8
|
+
* - value → set
|
|
9
|
+
*
|
|
10
|
+
* Used by the in-memory adapter to mirror the postgres adapter's
|
|
11
|
+
* per-column CASE WHEN behaviour without hand-writing the if/else
|
|
12
|
+
* tree for every field. Returns a new object — never mutates.
|
|
13
|
+
*/
|
|
14
|
+
export declare function applyPartial<E, P>(existing: E, patch: P,
|
|
15
|
+
/** Default value when a key is explicitly cleared (`null` in the patch). */
|
|
16
|
+
clearTo?: Partial<{
|
|
17
|
+
[K in keyof P]: unknown;
|
|
18
|
+
}>): E;
|
|
19
|
+
/**
|
|
20
|
+
* Per-field patch helper for postgres CASE WHEN. Returns the
|
|
21
|
+
* `{provided, value}` tuple the SQL template uses; `clearTo` plugs in
|
|
22
|
+
* when the patch explicitly cleared the field with `null`.
|
|
23
|
+
*/
|
|
24
|
+
export declare function pickPatch<P, K extends keyof P>(patch: P, key: K, clearTo?: NonNullable<P[K]> | null): {
|
|
25
|
+
provided: boolean;
|
|
26
|
+
value: NonNullable<P[K]> | null;
|
|
27
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** Postgres drivers return timestamp columns as either string or Date depending
|
|
2
|
+
* on the type parser. Normalise to ISO-8601 string for the wire format. */
|
|
3
|
+
export function isoString(v) {
|
|
4
|
+
return typeof v === "string" ? v : v.toISOString();
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Apply a partial patch with the SDK's three-state semantics:
|
|
8
|
+
* - `undefined` → keep `existing[key]`
|
|
9
|
+
* - `null` → clear (set to `clearTo`, default `null`)
|
|
10
|
+
* - value → set
|
|
11
|
+
*
|
|
12
|
+
* Used by the in-memory adapter to mirror the postgres adapter's
|
|
13
|
+
* per-column CASE WHEN behaviour without hand-writing the if/else
|
|
14
|
+
* tree for every field. Returns a new object — never mutates.
|
|
15
|
+
*/
|
|
16
|
+
export function applyPartial(existing, patch,
|
|
17
|
+
/** Default value when a key is explicitly cleared (`null` in the patch). */
|
|
18
|
+
clearTo = {}) {
|
|
19
|
+
const next = { ...existing };
|
|
20
|
+
for (const key of Object.keys(patch)) {
|
|
21
|
+
const value = patch[key];
|
|
22
|
+
if (value === undefined)
|
|
23
|
+
continue;
|
|
24
|
+
if (value === null) {
|
|
25
|
+
const fallback = clearTo[key];
|
|
26
|
+
if (fallback === undefined)
|
|
27
|
+
delete next[key];
|
|
28
|
+
else
|
|
29
|
+
next[key] = fallback;
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
next[key] = value;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return next;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Per-field patch helper for postgres CASE WHEN. Returns the
|
|
39
|
+
* `{provided, value}` tuple the SQL template uses; `clearTo` plugs in
|
|
40
|
+
* when the patch explicitly cleared the field with `null`.
|
|
41
|
+
*/
|
|
42
|
+
export function pickPatch(patch, key, clearTo = null) {
|
|
43
|
+
const provided = patch[key] !== undefined;
|
|
44
|
+
const raw = patch[key];
|
|
45
|
+
const value = raw == null ? clearTo : raw;
|
|
46
|
+
return { provided, value };
|
|
47
|
+
}
|