@eidentic/postgres 0.1.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/LICENSE +201 -0
- package/README.md +42 -0
- package/dist/index.cjs +788 -0
- package/dist/index.d.cts +126 -0
- package/dist/index.d.ts +126 -0
- package/dist/index.js +767 -0
- package/package.json +68 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,767 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import {
|
|
3
|
+
StoreConflictError,
|
|
4
|
+
scopeKey,
|
|
5
|
+
tokenize
|
|
6
|
+
} from "@eidentic/types";
|
|
7
|
+
|
|
8
|
+
// src/migrations.ts
|
|
9
|
+
var MIGRATIONS = [
|
|
10
|
+
{
|
|
11
|
+
version: 1,
|
|
12
|
+
sql: `
|
|
13
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
14
|
+
id TEXT PRIMARY KEY,
|
|
15
|
+
agent_id TEXT NOT NULL,
|
|
16
|
+
created_at TEXT NOT NULL
|
|
17
|
+
);
|
|
18
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
19
|
+
id TEXT PRIMARY KEY,
|
|
20
|
+
session_id TEXT NOT NULL REFERENCES sessions(id),
|
|
21
|
+
seq INTEGER NOT NULL,
|
|
22
|
+
kind TEXT NOT NULL,
|
|
23
|
+
schema_version INTEGER NOT NULL,
|
|
24
|
+
payload TEXT NOT NULL,
|
|
25
|
+
meta TEXT,
|
|
26
|
+
created_at TEXT NOT NULL,
|
|
27
|
+
UNIQUE (session_id, seq)
|
|
28
|
+
);
|
|
29
|
+
CREATE INDEX IF NOT EXISTS idx_events_session ON events(session_id, seq);
|
|
30
|
+
CREATE TABLE IF NOT EXISTS blocks (
|
|
31
|
+
scope_key TEXT NOT NULL,
|
|
32
|
+
label TEXT NOT NULL,
|
|
33
|
+
value TEXT NOT NULL,
|
|
34
|
+
version INTEGER NOT NULL,
|
|
35
|
+
updated_at TEXT NOT NULL,
|
|
36
|
+
PRIMARY KEY (scope_key, label)
|
|
37
|
+
);
|
|
38
|
+
`
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
version: 2,
|
|
42
|
+
sql: `
|
|
43
|
+
CREATE TABLE IF NOT EXISTS memories (
|
|
44
|
+
scope_key TEXT NOT NULL,
|
|
45
|
+
ext_id TEXT NOT NULL,
|
|
46
|
+
text TEXT NOT NULL,
|
|
47
|
+
tsv tsvector GENERATED ALWAYS AS (to_tsvector('english', text)) STORED,
|
|
48
|
+
PRIMARY KEY (scope_key, ext_id)
|
|
49
|
+
);
|
|
50
|
+
CREATE INDEX IF NOT EXISTS idx_memories_gin ON memories USING GIN (tsv);
|
|
51
|
+
CREATE INDEX IF NOT EXISTS idx_memories_scope ON memories (scope_key);
|
|
52
|
+
`
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
version: 3,
|
|
56
|
+
sql: `
|
|
57
|
+
CREATE TABLE IF NOT EXISTS block_history (
|
|
58
|
+
scope_key TEXT NOT NULL,
|
|
59
|
+
label TEXT NOT NULL,
|
|
60
|
+
version INTEGER NOT NULL,
|
|
61
|
+
value TEXT NOT NULL,
|
|
62
|
+
updated_at TEXT NOT NULL,
|
|
63
|
+
PRIMARY KEY (scope_key, label, version)
|
|
64
|
+
);
|
|
65
|
+
`
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
version: 4,
|
|
69
|
+
sql: `
|
|
70
|
+
CREATE TABLE IF NOT EXISTS facts (
|
|
71
|
+
id TEXT PRIMARY KEY,
|
|
72
|
+
scope_key TEXT NOT NULL,
|
|
73
|
+
subject TEXT NOT NULL,
|
|
74
|
+
predicate TEXT NOT NULL,
|
|
75
|
+
object TEXT NOT NULL,
|
|
76
|
+
object_kind TEXT NOT NULL,
|
|
77
|
+
valid_from TEXT NOT NULL,
|
|
78
|
+
valid_until TEXT,
|
|
79
|
+
confidence REAL NOT NULL,
|
|
80
|
+
source TEXT,
|
|
81
|
+
expires_at TEXT
|
|
82
|
+
);
|
|
83
|
+
CREATE INDEX IF NOT EXISTS idx_facts_spo ON facts (scope_key, subject, predicate);
|
|
84
|
+
CREATE INDEX IF NOT EXISTS idx_facts_scope_active ON facts (scope_key, valid_until);
|
|
85
|
+
CREATE INDEX IF NOT EXISTS idx_facts_expires ON facts (scope_key, expires_at);
|
|
86
|
+
`
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
version: 5,
|
|
90
|
+
sql: `
|
|
91
|
+
CREATE TABLE IF NOT EXISTS checkpoints (
|
|
92
|
+
session_id TEXT NOT NULL,
|
|
93
|
+
seq INTEGER NOT NULL,
|
|
94
|
+
hash TEXT NOT NULL,
|
|
95
|
+
created_at TEXT NOT NULL,
|
|
96
|
+
PRIMARY KEY (session_id, seq)
|
|
97
|
+
);
|
|
98
|
+
CREATE TABLE IF NOT EXISTS idempotency_keys (
|
|
99
|
+
key TEXT PRIMARY KEY,
|
|
100
|
+
args_hash TEXT NOT NULL,
|
|
101
|
+
status TEXT NOT NULL,
|
|
102
|
+
result TEXT,
|
|
103
|
+
created_at TEXT NOT NULL
|
|
104
|
+
);
|
|
105
|
+
`
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
version: 6,
|
|
109
|
+
sql: `
|
|
110
|
+
CREATE TABLE IF NOT EXISTS suspension_decisions (
|
|
111
|
+
session_id TEXT NOT NULL,
|
|
112
|
+
call_id TEXT NOT NULL,
|
|
113
|
+
decision TEXT NOT NULL,
|
|
114
|
+
created_at TEXT NOT NULL,
|
|
115
|
+
PRIMARY KEY (session_id, call_id)
|
|
116
|
+
);
|
|
117
|
+
`
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
version: 7,
|
|
121
|
+
sql: `
|
|
122
|
+
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS user_id TEXT;
|
|
123
|
+
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS org_id TEXT;
|
|
124
|
+
`
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
version: 8,
|
|
128
|
+
// Unlike SQLite FTS5 virtual tables, Postgres memories is a plain table.
|
|
129
|
+
// We can add columns directly. ingested_at stores epoch-ms; metadata is JSONB.
|
|
130
|
+
// Old rows get NULL for both columns — the memory layer falls back gracefully
|
|
131
|
+
// (similarity-only ranking, no metadata) for entries ingested before this migration.
|
|
132
|
+
sql: `
|
|
133
|
+
ALTER TABLE memories ADD COLUMN IF NOT EXISTS ingested_at BIGINT;
|
|
134
|
+
ALTER TABLE memories ADD COLUMN IF NOT EXISTS metadata JSONB;
|
|
135
|
+
`
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
version: 9,
|
|
139
|
+
// State-transition + corroboration tiers. `supersedes` links a fact to the prior fact it
|
|
140
|
+
// replaced on contradiction-invalidation (NULL for the first version in a chain).
|
|
141
|
+
// `last_corroborated_at` (epoch-ms) records the last re-confirmation; defaults to validFrom.
|
|
142
|
+
sql: `
|
|
143
|
+
ALTER TABLE facts ADD COLUMN IF NOT EXISTS supersedes TEXT;
|
|
144
|
+
ALTER TABLE facts ADD COLUMN IF NOT EXISTS last_corroborated_at BIGINT;
|
|
145
|
+
`
|
|
146
|
+
}
|
|
147
|
+
];
|
|
148
|
+
async function runMigrations(client) {
|
|
149
|
+
await client.query(`
|
|
150
|
+
CREATE TABLE IF NOT EXISTS _eidentic_migrations (
|
|
151
|
+
version INTEGER PRIMARY KEY
|
|
152
|
+
)
|
|
153
|
+
`);
|
|
154
|
+
const { rows } = await client.query(`SELECT version FROM _eidentic_migrations`);
|
|
155
|
+
const applied = new Set(rows.map((r) => r.version));
|
|
156
|
+
for (const m of MIGRATIONS) {
|
|
157
|
+
if (!applied.has(m.version)) {
|
|
158
|
+
await client.query("BEGIN");
|
|
159
|
+
try {
|
|
160
|
+
const stmts = m.sql.split(";").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
161
|
+
for (const stmt of stmts) {
|
|
162
|
+
await client.query(stmt);
|
|
163
|
+
}
|
|
164
|
+
await client.query(`INSERT INTO _eidentic_migrations (version) VALUES ($1)`, [m.version]);
|
|
165
|
+
await client.query("COMMIT");
|
|
166
|
+
} catch (err) {
|
|
167
|
+
await client.query("ROLLBACK");
|
|
168
|
+
throw err;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// src/index.ts
|
|
175
|
+
function str(v) {
|
|
176
|
+
if (typeof v === "string") return v;
|
|
177
|
+
if (v === null || v === void 0) return "";
|
|
178
|
+
return String(v);
|
|
179
|
+
}
|
|
180
|
+
function num(v) {
|
|
181
|
+
return Number(v);
|
|
182
|
+
}
|
|
183
|
+
function strNull(v) {
|
|
184
|
+
if (v === null || v === void 0) return null;
|
|
185
|
+
return String(v);
|
|
186
|
+
}
|
|
187
|
+
function ftsOr(text) {
|
|
188
|
+
const tokens = tokenize(text);
|
|
189
|
+
if (tokens.length === 0) return null;
|
|
190
|
+
return tokens.join(" or ");
|
|
191
|
+
}
|
|
192
|
+
var PostgresStore = class _PostgresStore {
|
|
193
|
+
/**
|
|
194
|
+
* Construct a PostgresStore wrapping an injected `PgClient`.
|
|
195
|
+
*
|
|
196
|
+
* Pass a `pg.Pool` or a `@electric-sql/pglite` instance as `client`.
|
|
197
|
+
* Note: when using `pg.Pool`, pass a dedicated client or pool — the store
|
|
198
|
+
* issues its own BEGIN/COMMIT on the connection for multi-statement atomicity.
|
|
199
|
+
* For pglite (single-connection WASM), BEGIN/COMMIT on the same client is fine.
|
|
200
|
+
*
|
|
201
|
+
* Call `await store.migrate()` before use, or use `PostgresStore.create()`
|
|
202
|
+
* which runs migrations automatically.
|
|
203
|
+
*/
|
|
204
|
+
constructor(client, opts) {
|
|
205
|
+
this.client = client;
|
|
206
|
+
this.newFactId = opts?.newId ?? (() => `fact_${Date.now().toString(36)}_${(this.factIdCounter++).toString(36)}`);
|
|
207
|
+
this.graphNow = opts?.now ?? (() => (/* @__PURE__ */ new Date()).toISOString());
|
|
208
|
+
}
|
|
209
|
+
client;
|
|
210
|
+
factIdCounter = 0;
|
|
211
|
+
newFactId;
|
|
212
|
+
graphNow;
|
|
213
|
+
/**
|
|
214
|
+
* Create a PostgresStore and run migrations in one step.
|
|
215
|
+
* Convenience wrapper for `new PostgresStore(client)` + `migrate()`.
|
|
216
|
+
*/
|
|
217
|
+
static async create(opts) {
|
|
218
|
+
const store = new _PostgresStore(opts.client, { newId: opts.newId, now: opts.now });
|
|
219
|
+
await runMigrations(opts.client);
|
|
220
|
+
return store;
|
|
221
|
+
}
|
|
222
|
+
async migrate() {
|
|
223
|
+
await runMigrations(this.client);
|
|
224
|
+
}
|
|
225
|
+
async close() {
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Run `fn` inside a single Postgres transaction.
|
|
229
|
+
*
|
|
230
|
+
* When `this.client` is a `pg.Pool` (has a `connect()` method), we check out
|
|
231
|
+
* a dedicated connection so that BEGIN / body / COMMIT all execute on the same
|
|
232
|
+
* underlying socket — Pool.query() would otherwise dispatch each call to a
|
|
233
|
+
* different pooled connection, destroying atomicity.
|
|
234
|
+
*
|
|
235
|
+
* When `this.client` is pglite (no `connect()`), the client IS the connection;
|
|
236
|
+
* we issue BEGIN/COMMIT directly on it, matching the previous behaviour.
|
|
237
|
+
*/
|
|
238
|
+
async withTransaction(fn) {
|
|
239
|
+
if (typeof this.client.connect === "function") {
|
|
240
|
+
const c = await this.client.connect();
|
|
241
|
+
try {
|
|
242
|
+
await c.query("BEGIN");
|
|
243
|
+
try {
|
|
244
|
+
const result = await fn(c);
|
|
245
|
+
await c.query("COMMIT");
|
|
246
|
+
return result;
|
|
247
|
+
} catch (err) {
|
|
248
|
+
try {
|
|
249
|
+
await c.query("ROLLBACK");
|
|
250
|
+
} catch {
|
|
251
|
+
}
|
|
252
|
+
throw err;
|
|
253
|
+
}
|
|
254
|
+
} finally {
|
|
255
|
+
c.release();
|
|
256
|
+
}
|
|
257
|
+
} else {
|
|
258
|
+
await this.client.query("BEGIN");
|
|
259
|
+
try {
|
|
260
|
+
const result = await fn(this.client);
|
|
261
|
+
await this.client.query("COMMIT");
|
|
262
|
+
return result;
|
|
263
|
+
} catch (err) {
|
|
264
|
+
try {
|
|
265
|
+
await this.client.query("ROLLBACK");
|
|
266
|
+
} catch {
|
|
267
|
+
}
|
|
268
|
+
throw err;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
// ── Sessions ──────────────────────────────────────────────────────────────
|
|
273
|
+
async createSession(s) {
|
|
274
|
+
await this.client.query(
|
|
275
|
+
`INSERT INTO sessions (id, agent_id, created_at, user_id, org_id) VALUES ($1, $2, $3, $4, $5)`,
|
|
276
|
+
[s.id, s.agentId, s.createdAt, s.userId ?? null, s.orgId ?? null]
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
async getSession(id) {
|
|
280
|
+
const { rows } = await this.client.query(
|
|
281
|
+
`SELECT id, agent_id, created_at, user_id, org_id FROM sessions WHERE id = $1`,
|
|
282
|
+
[id]
|
|
283
|
+
);
|
|
284
|
+
const row = rows[0];
|
|
285
|
+
if (!row) return null;
|
|
286
|
+
const userId = strNull(row.user_id);
|
|
287
|
+
const orgId = strNull(row.org_id);
|
|
288
|
+
return {
|
|
289
|
+
id: str(row.id),
|
|
290
|
+
agentId: str(row.agent_id),
|
|
291
|
+
createdAt: str(row.created_at),
|
|
292
|
+
...userId !== null ? { userId } : {},
|
|
293
|
+
...orgId !== null ? { orgId } : {}
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
// ── Events ────────────────────────────────────────────────────────────────
|
|
297
|
+
async appendEvents(events) {
|
|
298
|
+
if (events.length === 0) return;
|
|
299
|
+
try {
|
|
300
|
+
await this.withTransaction(async (c) => {
|
|
301
|
+
for (const e of events) {
|
|
302
|
+
await c.query(
|
|
303
|
+
`INSERT INTO events (id, session_id, seq, kind, schema_version, payload, meta, created_at)
|
|
304
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
|
305
|
+
[
|
|
306
|
+
e.id,
|
|
307
|
+
e.sessionId,
|
|
308
|
+
e.seq,
|
|
309
|
+
e.kind,
|
|
310
|
+
e.schemaVersion,
|
|
311
|
+
JSON.stringify(e.payload),
|
|
312
|
+
e.meta !== void 0 ? JSON.stringify(e.meta) : null,
|
|
313
|
+
e.createdAt
|
|
314
|
+
]
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
} catch (err) {
|
|
319
|
+
if (err instanceof Error && /duplicate key|unique constraint|conflict/i.test(err.message)) {
|
|
320
|
+
throw new StoreConflictError(`conflict: ${err.message}`);
|
|
321
|
+
}
|
|
322
|
+
throw err;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
async readEvents(sessionId) {
|
|
326
|
+
const { rows } = await this.client.query(
|
|
327
|
+
`SELECT id, session_id, seq, kind, schema_version, payload, meta, created_at
|
|
328
|
+
FROM events WHERE session_id = $1 ORDER BY seq ASC`,
|
|
329
|
+
[sessionId]
|
|
330
|
+
);
|
|
331
|
+
return rows.map((r) => ({
|
|
332
|
+
id: str(r.id),
|
|
333
|
+
sessionId: str(r.session_id),
|
|
334
|
+
seq: num(r.seq),
|
|
335
|
+
kind: str(r.kind),
|
|
336
|
+
schemaVersion: num(r.schema_version),
|
|
337
|
+
payload: JSON.parse(str(r.payload)),
|
|
338
|
+
meta: r.meta !== null && r.meta !== void 0 ? JSON.parse(str(r.meta)) : void 0,
|
|
339
|
+
createdAt: str(r.created_at)
|
|
340
|
+
}));
|
|
341
|
+
}
|
|
342
|
+
// ── Blocks ────────────────────────────────────────────────────────────────
|
|
343
|
+
async getBlocks(scope) {
|
|
344
|
+
const { rows } = await this.client.query(
|
|
345
|
+
`SELECT label, value, version, updated_at FROM blocks WHERE scope_key = $1`,
|
|
346
|
+
[scopeKey(scope)]
|
|
347
|
+
);
|
|
348
|
+
return rows.map((r) => ({
|
|
349
|
+
label: str(r.label),
|
|
350
|
+
value: str(r.value),
|
|
351
|
+
version: num(r.version),
|
|
352
|
+
updatedAt: str(r.updated_at)
|
|
353
|
+
}));
|
|
354
|
+
}
|
|
355
|
+
async getBlock(scope, label) {
|
|
356
|
+
const { rows } = await this.client.query(
|
|
357
|
+
`SELECT label, value, version, updated_at FROM blocks WHERE scope_key = $1 AND label = $2`,
|
|
358
|
+
[scopeKey(scope), label]
|
|
359
|
+
);
|
|
360
|
+
const row = rows[0];
|
|
361
|
+
if (!row) return null;
|
|
362
|
+
return { label: str(row.label), value: str(row.value), version: num(row.version), updatedAt: str(row.updated_at) };
|
|
363
|
+
}
|
|
364
|
+
async upsertBlock(scope, block, expectVersion) {
|
|
365
|
+
const key = scopeKey(scope);
|
|
366
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
367
|
+
try {
|
|
368
|
+
return await this.withTransaction(async (c) => {
|
|
369
|
+
const { rows: existing } = await c.query(
|
|
370
|
+
`SELECT version FROM blocks WHERE scope_key = $1 AND label = $2`,
|
|
371
|
+
[key, block.label]
|
|
372
|
+
);
|
|
373
|
+
const cur = existing[0];
|
|
374
|
+
if (expectVersion !== void 0 && !cur) {
|
|
375
|
+
throw new StoreConflictError(
|
|
376
|
+
`conflict: block ${block.label} expected version ${expectVersion} but it does not exist`
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
if (expectVersion !== void 0 && cur && num(cur.version) !== expectVersion) {
|
|
380
|
+
throw new StoreConflictError(
|
|
381
|
+
`conflict: block ${block.label} version ${num(cur.version)} != expected ${expectVersion}`
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
const version = cur ? num(cur.version) + 1 : 0;
|
|
385
|
+
return await this.writeBlock(c, key, block.label, block.value, version, now);
|
|
386
|
+
});
|
|
387
|
+
} catch (err) {
|
|
388
|
+
throw err;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
async appendBlock(scope, label, text) {
|
|
392
|
+
const key = scopeKey(scope);
|
|
393
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
394
|
+
return this.withTransaction(async (c) => {
|
|
395
|
+
const { rows: existing } = await c.query(
|
|
396
|
+
`SELECT value, version FROM blocks WHERE scope_key = $1 AND label = $2`,
|
|
397
|
+
[key, label]
|
|
398
|
+
);
|
|
399
|
+
const cur = existing[0];
|
|
400
|
+
const value = (cur ? str(cur.value) : "") + text;
|
|
401
|
+
const version = cur ? num(cur.version) + 1 : 0;
|
|
402
|
+
return this.writeBlock(c, key, label, value, version, now);
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
async getBlockHistory(scope, label) {
|
|
406
|
+
const { rows } = await this.client.query(
|
|
407
|
+
`SELECT label, value, version, updated_at FROM block_history
|
|
408
|
+
WHERE scope_key = $1 AND label = $2 ORDER BY version ASC`,
|
|
409
|
+
[scopeKey(scope), label]
|
|
410
|
+
);
|
|
411
|
+
return rows.map((r) => ({
|
|
412
|
+
label: str(r.label),
|
|
413
|
+
value: str(r.value),
|
|
414
|
+
version: num(r.version),
|
|
415
|
+
updatedAt: str(r.updated_at)
|
|
416
|
+
}));
|
|
417
|
+
}
|
|
418
|
+
async writeBlock(c, scopeKeyStr, label, value, version, now) {
|
|
419
|
+
await c.query(
|
|
420
|
+
`INSERT INTO blocks (scope_key, label, value, version, updated_at) VALUES ($1, $2, $3, $4, $5)
|
|
421
|
+
ON CONFLICT (scope_key, label) DO UPDATE SET value = EXCLUDED.value, version = EXCLUDED.version, updated_at = EXCLUDED.updated_at`,
|
|
422
|
+
[scopeKeyStr, label, value, version, now]
|
|
423
|
+
);
|
|
424
|
+
await c.query(
|
|
425
|
+
`INSERT INTO block_history (scope_key, label, version, value, updated_at) VALUES ($1, $2, $3, $4, $5)
|
|
426
|
+
ON CONFLICT (scope_key, label, version) DO UPDATE SET value = EXCLUDED.value, updated_at = EXCLUDED.updated_at`,
|
|
427
|
+
[scopeKeyStr, label, version, value, now]
|
|
428
|
+
);
|
|
429
|
+
return { label, value, version, updatedAt: now };
|
|
430
|
+
}
|
|
431
|
+
// ── Memory (FTS via tsvector + ts_rank) ────────────────────────────────────
|
|
432
|
+
async indexMemory(entries) {
|
|
433
|
+
if (entries.length === 0) return;
|
|
434
|
+
await this.withTransaction(async (c) => {
|
|
435
|
+
for (const e of entries) {
|
|
436
|
+
await c.query(
|
|
437
|
+
`INSERT INTO memories (scope_key, ext_id, text, ingested_at, metadata) VALUES ($1, $2, $3, $4, $5)
|
|
438
|
+
ON CONFLICT (scope_key, ext_id) DO UPDATE SET
|
|
439
|
+
text = EXCLUDED.text,
|
|
440
|
+
ingested_at = COALESCE(EXCLUDED.ingested_at, memories.ingested_at),
|
|
441
|
+
metadata = COALESCE(EXCLUDED.metadata, memories.metadata)`,
|
|
442
|
+
[
|
|
443
|
+
scopeKey(e.scope),
|
|
444
|
+
e.id,
|
|
445
|
+
e.text,
|
|
446
|
+
e.ingestedAt ?? null,
|
|
447
|
+
e.metadata !== void 0 ? JSON.stringify(e.metadata) : null
|
|
448
|
+
]
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
async searchMemory(scope, query, topK) {
|
|
454
|
+
const tsExpr = ftsOr(query);
|
|
455
|
+
if (!tsExpr) return [];
|
|
456
|
+
const { rows } = await this.client.query(
|
|
457
|
+
`SELECT ext_id AS id, text, ts_rank(tsv, websearch_to_tsquery('english', $1)) AS score,
|
|
458
|
+
ingested_at, metadata
|
|
459
|
+
FROM memories
|
|
460
|
+
WHERE scope_key = $2 AND tsv @@ websearch_to_tsquery('english', $1)
|
|
461
|
+
ORDER BY score DESC
|
|
462
|
+
LIMIT $3`,
|
|
463
|
+
[tsExpr, scopeKey(scope), topK]
|
|
464
|
+
);
|
|
465
|
+
return rows.map((r) => ({
|
|
466
|
+
id: str(r.id),
|
|
467
|
+
text: str(r.text),
|
|
468
|
+
score: num(r.score),
|
|
469
|
+
...r.ingested_at !== null && r.ingested_at !== void 0 ? { ingestedAt: num(r.ingested_at) } : {},
|
|
470
|
+
...r.metadata !== null && r.metadata !== void 0 ? { metadata: typeof r.metadata === "object" ? r.metadata : JSON.parse(str(r.metadata)) } : {}
|
|
471
|
+
})).filter((r) => r.score > 0);
|
|
472
|
+
}
|
|
473
|
+
// ── Graph (Facts) ──────────────────────────────────────────────────────────
|
|
474
|
+
rowToFact(r) {
|
|
475
|
+
const lastCorr = r.last_corroborated_at !== null && r.last_corroborated_at !== void 0 ? Number(r.last_corroborated_at) : null;
|
|
476
|
+
return {
|
|
477
|
+
id: str(r.id),
|
|
478
|
+
subject: str(r.subject),
|
|
479
|
+
predicate: str(r.predicate),
|
|
480
|
+
object: str(r.object),
|
|
481
|
+
objectKind: str(r.object_kind),
|
|
482
|
+
validFrom: str(r.valid_from),
|
|
483
|
+
...r.valid_until !== null && r.valid_until !== void 0 ? { validUntil: str(r.valid_until) } : {},
|
|
484
|
+
confidence: num(r.confidence),
|
|
485
|
+
...r.source !== null && r.source !== void 0 ? { source: str(r.source) } : {},
|
|
486
|
+
...r.expires_at !== null && r.expires_at !== void 0 ? { expiresAt: str(r.expires_at) } : {},
|
|
487
|
+
...r.supersedes !== null && r.supersedes !== void 0 ? { supersedes: str(r.supersedes) } : {},
|
|
488
|
+
...lastCorr !== null && !Number.isNaN(lastCorr) ? { lastCorroboratedAt: lastCorr } : {}
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
async assertFact(scope, input) {
|
|
492
|
+
const key = scopeKey(scope);
|
|
493
|
+
const validFrom = input.validFrom ?? this.graphNow();
|
|
494
|
+
const objectKind = input.objectKind ?? "literal";
|
|
495
|
+
const confidence = input.confidence ?? 1;
|
|
496
|
+
const source = input.source ?? null;
|
|
497
|
+
const id = this.newFactId();
|
|
498
|
+
const expiresAt = input.ttlMs !== void 0 ? new Date(new Date(validFrom).getTime() + input.ttlMs).toISOString() : input.expiresAt ?? null;
|
|
499
|
+
const lastCorroboratedRaw = input.lastCorroboratedAt ?? Date.parse(validFrom);
|
|
500
|
+
const lastCorroboratedAt = Number.isNaN(lastCorroboratedRaw) ? null : lastCorroboratedRaw;
|
|
501
|
+
return this.withTransaction(async (c) => {
|
|
502
|
+
const { rows: current } = await c.query(
|
|
503
|
+
`SELECT id, subject, predicate, object, object_kind, valid_from, valid_until, confidence, source, expires_at, supersedes, last_corroborated_at
|
|
504
|
+
FROM facts WHERE scope_key = $1 AND subject = $2 AND predicate = $3 AND valid_until IS NULL`,
|
|
505
|
+
[key, input.subject, input.predicate]
|
|
506
|
+
);
|
|
507
|
+
const same = current.find((r) => str(r.object) === input.object);
|
|
508
|
+
if (same) {
|
|
509
|
+
return { asserted: this.rowToFact(same), invalidated: [] };
|
|
510
|
+
}
|
|
511
|
+
for (const r of current) {
|
|
512
|
+
if (validFrom < str(r.valid_from)) {
|
|
513
|
+
throw new Error(
|
|
514
|
+
`temporal order violation: validFrom '${validFrom}' is earlier than the current fact's validFrom '${str(r.valid_from)}'`
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
const invalidated = [];
|
|
519
|
+
for (const r of current) {
|
|
520
|
+
await c.query(`UPDATE facts SET valid_until = $1 WHERE id = $2`, [validFrom, str(r.id)]);
|
|
521
|
+
invalidated.push(this.rowToFact({ ...r, valid_until: validFrom }));
|
|
522
|
+
}
|
|
523
|
+
const supersedes = current.length === 1 ? str(current[0].id) : null;
|
|
524
|
+
await c.query(
|
|
525
|
+
`INSERT INTO facts (id, scope_key, subject, predicate, object, object_kind, valid_from, valid_until, confidence, source, expires_at, supersedes, last_corroborated_at)
|
|
526
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, NULL, $8, $9, $10, $11, $12)`,
|
|
527
|
+
[id, key, input.subject, input.predicate, input.object, objectKind, validFrom, confidence, source, expiresAt, supersedes, lastCorroboratedAt]
|
|
528
|
+
);
|
|
529
|
+
const asserted = {
|
|
530
|
+
id,
|
|
531
|
+
subject: input.subject,
|
|
532
|
+
predicate: input.predicate,
|
|
533
|
+
object: input.object,
|
|
534
|
+
objectKind,
|
|
535
|
+
validFrom,
|
|
536
|
+
confidence,
|
|
537
|
+
...source !== null ? { source } : {},
|
|
538
|
+
...expiresAt !== null ? { expiresAt } : {},
|
|
539
|
+
...supersedes !== null ? { supersedes } : {},
|
|
540
|
+
...lastCorroboratedAt !== null ? { lastCorroboratedAt } : {}
|
|
541
|
+
};
|
|
542
|
+
return { asserted, invalidated };
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
async factHistory(scope, subject, predicate) {
|
|
546
|
+
return this.queryFacts({ scope, subject, predicate, includeInvalidated: true });
|
|
547
|
+
}
|
|
548
|
+
async corroborate(scope, factId, at) {
|
|
549
|
+
const ts = at ?? Date.now();
|
|
550
|
+
const { rows } = await this.client.query(
|
|
551
|
+
`UPDATE facts SET last_corroborated_at = $1 WHERE scope_key = $2 AND id = $3 AND valid_until IS NULL RETURNING id`,
|
|
552
|
+
[ts, scopeKey(scope), factId]
|
|
553
|
+
);
|
|
554
|
+
return rows.length;
|
|
555
|
+
}
|
|
556
|
+
async expireFacts(scope, ids, at) {
|
|
557
|
+
if (ids.length === 0) return 0;
|
|
558
|
+
const placeholders = ids.map((_, i) => `$${i + 3}`).join(", ");
|
|
559
|
+
const { rows } = await this.client.query(
|
|
560
|
+
`UPDATE facts SET valid_until = $1 WHERE scope_key = $2 AND valid_until IS NULL AND id IN (${placeholders}) RETURNING id`,
|
|
561
|
+
[at, scopeKey(scope), ...ids]
|
|
562
|
+
);
|
|
563
|
+
return rows.length;
|
|
564
|
+
}
|
|
565
|
+
async queryFacts(query) {
|
|
566
|
+
const key = scopeKey(query.scope);
|
|
567
|
+
const conditions = ["scope_key = $1"];
|
|
568
|
+
const params = [key];
|
|
569
|
+
let idx = 2;
|
|
570
|
+
if (query.subject !== void 0) {
|
|
571
|
+
conditions.push(`subject = $${idx++}`);
|
|
572
|
+
params.push(query.subject);
|
|
573
|
+
}
|
|
574
|
+
if (query.predicate !== void 0) {
|
|
575
|
+
conditions.push(`predicate = $${idx++}`);
|
|
576
|
+
params.push(query.predicate);
|
|
577
|
+
}
|
|
578
|
+
if (query.object !== void 0) {
|
|
579
|
+
conditions.push(`object = $${idx++}`);
|
|
580
|
+
params.push(query.object);
|
|
581
|
+
}
|
|
582
|
+
if (query.validAt !== void 0) {
|
|
583
|
+
conditions.push(`valid_from <= $${idx} AND (valid_until IS NULL OR valid_until > $${idx})`);
|
|
584
|
+
params.push(query.validAt);
|
|
585
|
+
idx++;
|
|
586
|
+
} else if (!query.includeInvalidated) {
|
|
587
|
+
conditions.push("valid_until IS NULL");
|
|
588
|
+
}
|
|
589
|
+
let limitClause = "";
|
|
590
|
+
if (query.limit !== void 0) {
|
|
591
|
+
const safeLimit = Number.isInteger(query.limit) && query.limit >= 0 ? query.limit : 100;
|
|
592
|
+
limitClause = ` LIMIT $${idx}`;
|
|
593
|
+
params.push(safeLimit);
|
|
594
|
+
idx++;
|
|
595
|
+
}
|
|
596
|
+
const { rows } = await this.client.query(
|
|
597
|
+
`SELECT id, subject, predicate, object, object_kind, valid_from, valid_until, confidence, source, expires_at, supersedes, last_corroborated_at
|
|
598
|
+
FROM facts WHERE ${conditions.join(" AND ")} ORDER BY valid_from ASC${limitClause}`,
|
|
599
|
+
params
|
|
600
|
+
);
|
|
601
|
+
return rows.map((r) => this.rowToFact(r));
|
|
602
|
+
}
|
|
603
|
+
async sweepExpired(scope, now) {
|
|
604
|
+
const { rows } = await this.client.query(
|
|
605
|
+
`UPDATE facts SET valid_until = $1
|
|
606
|
+
WHERE scope_key = $2 AND valid_until IS NULL AND expires_at IS NOT NULL AND expires_at <= $1
|
|
607
|
+
RETURNING id`,
|
|
608
|
+
[now, scopeKey(scope)]
|
|
609
|
+
);
|
|
610
|
+
return rows.length;
|
|
611
|
+
}
|
|
612
|
+
async listSessions(opts) {
|
|
613
|
+
const conditions = [];
|
|
614
|
+
const params = [];
|
|
615
|
+
let idx = 1;
|
|
616
|
+
if (opts?.agentId !== void 0) {
|
|
617
|
+
conditions.push(`agent_id = $${idx++}`);
|
|
618
|
+
params.push(opts.agentId);
|
|
619
|
+
}
|
|
620
|
+
if (opts?.userId !== void 0) {
|
|
621
|
+
conditions.push(`user_id = $${idx++}`);
|
|
622
|
+
params.push(opts.userId);
|
|
623
|
+
}
|
|
624
|
+
if (opts?.orgId !== void 0) {
|
|
625
|
+
conditions.push(`org_id = $${idx++}`);
|
|
626
|
+
params.push(opts.orgId);
|
|
627
|
+
}
|
|
628
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
629
|
+
let limitClause = "";
|
|
630
|
+
if (opts?.limit !== void 0) {
|
|
631
|
+
const safeLimit = Number.isInteger(opts.limit) && opts.limit >= 0 ? opts.limit : 100;
|
|
632
|
+
limitClause = ` LIMIT $${idx}`;
|
|
633
|
+
params.push(safeLimit);
|
|
634
|
+
}
|
|
635
|
+
const { rows } = await this.client.query(
|
|
636
|
+
`SELECT id, agent_id, created_at, user_id, org_id FROM sessions ${where} ORDER BY created_at DESC${limitClause}`,
|
|
637
|
+
params
|
|
638
|
+
);
|
|
639
|
+
return rows.map((r) => {
|
|
640
|
+
const userId = strNull(r.user_id);
|
|
641
|
+
const orgId = strNull(r.org_id);
|
|
642
|
+
return {
|
|
643
|
+
id: str(r.id),
|
|
644
|
+
agentId: str(r.agent_id),
|
|
645
|
+
createdAt: str(r.created_at),
|
|
646
|
+
...userId !== null ? { userId } : {},
|
|
647
|
+
...orgId !== null ? { orgId } : {}
|
|
648
|
+
};
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
async listBlocks(scope) {
|
|
652
|
+
const { rows } = await this.client.query(
|
|
653
|
+
`SELECT label, value, version, updated_at FROM blocks WHERE scope_key = $1`,
|
|
654
|
+
[scopeKey(scope)]
|
|
655
|
+
);
|
|
656
|
+
return rows.map((r) => ({
|
|
657
|
+
label: str(r.label),
|
|
658
|
+
value: str(r.value),
|
|
659
|
+
version: num(r.version),
|
|
660
|
+
updatedAt: str(r.updated_at)
|
|
661
|
+
}));
|
|
662
|
+
}
|
|
663
|
+
async eraseScope(scope) {
|
|
664
|
+
const key = scopeKey(scope);
|
|
665
|
+
const agentId = "agentId" in scope ? scope.agentId : null;
|
|
666
|
+
return this.withTransaction(async (c) => {
|
|
667
|
+
let deleted = 0;
|
|
668
|
+
const { rows: f } = await c.query(`DELETE FROM facts WHERE scope_key = $1 RETURNING id`, [key]);
|
|
669
|
+
deleted += f.length;
|
|
670
|
+
const { rows: m } = await c.query(`DELETE FROM memories WHERE scope_key = $1 RETURNING ext_id`, [key]);
|
|
671
|
+
deleted += m.length;
|
|
672
|
+
const { rows: bh } = await c.query(`DELETE FROM block_history WHERE scope_key = $1 RETURNING label`, [key]);
|
|
673
|
+
deleted += bh.length;
|
|
674
|
+
const { rows: b } = await c.query(`DELETE FROM blocks WHERE scope_key = $1 RETURNING label`, [key]);
|
|
675
|
+
deleted += b.length;
|
|
676
|
+
if (agentId !== null) {
|
|
677
|
+
const { rows: sessions } = await c.query(`SELECT id FROM sessions WHERE agent_id = $1`, [agentId]);
|
|
678
|
+
for (const sess of sessions) {
|
|
679
|
+
const { rows: ev } = await c.query(`DELETE FROM events WHERE session_id = $1 RETURNING id`, [str(sess.id)]);
|
|
680
|
+
deleted += ev.length;
|
|
681
|
+
}
|
|
682
|
+
const { rows: s } = await c.query(`DELETE FROM sessions WHERE agent_id = $1 RETURNING id`, [agentId]);
|
|
683
|
+
deleted += s.length;
|
|
684
|
+
}
|
|
685
|
+
return { deleted };
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
// ── Durable (Checkpoints + Idempotency + Suspension) ──────────────────────
|
|
689
|
+
async writeCheckpoint(sessionId, seq, hash) {
|
|
690
|
+
await this.client.query(
|
|
691
|
+
`INSERT INTO checkpoints (session_id, seq, hash, created_at) VALUES ($1, $2, $3, $4)
|
|
692
|
+
ON CONFLICT (session_id, seq) DO UPDATE SET hash = EXCLUDED.hash, created_at = EXCLUDED.created_at`,
|
|
693
|
+
[sessionId, seq, hash, this.graphNow()]
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
async lastCheckpoint(sessionId) {
|
|
697
|
+
const { rows } = await this.client.query(
|
|
698
|
+
`SELECT session_id, seq, hash, created_at FROM checkpoints
|
|
699
|
+
WHERE session_id = $1 ORDER BY seq DESC LIMIT 1`,
|
|
700
|
+
[sessionId]
|
|
701
|
+
);
|
|
702
|
+
const row = rows[0];
|
|
703
|
+
if (!row) return null;
|
|
704
|
+
return {
|
|
705
|
+
sessionId: str(row.session_id),
|
|
706
|
+
seq: num(row.seq),
|
|
707
|
+
hash: str(row.hash),
|
|
708
|
+
createdAt: str(row.created_at)
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
async recordIntent(key, argsHash) {
|
|
712
|
+
await this.client.query(
|
|
713
|
+
`INSERT INTO idempotency_keys (key, args_hash, status, result, created_at) VALUES ($1, $2, 'intent', NULL, $3)
|
|
714
|
+
ON CONFLICT (key) DO NOTHING`,
|
|
715
|
+
[key, argsHash, this.graphNow()]
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
async recordCompletion(key, result) {
|
|
719
|
+
const serialized = JSON.stringify(result ?? null);
|
|
720
|
+
await this.withTransaction(async (c) => {
|
|
721
|
+
await c.query(
|
|
722
|
+
`INSERT INTO idempotency_keys (key, args_hash, status, result, created_at) VALUES ($1, '', 'intent', NULL, $2)
|
|
723
|
+
ON CONFLICT (key) DO NOTHING`,
|
|
724
|
+
[key, this.graphNow()]
|
|
725
|
+
);
|
|
726
|
+
await c.query(
|
|
727
|
+
`UPDATE idempotency_keys SET status = 'applied', result = $1 WHERE key = $2`,
|
|
728
|
+
[serialized, key]
|
|
729
|
+
);
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
async getIdempotency(key) {
|
|
733
|
+
const { rows } = await this.client.query(
|
|
734
|
+
`SELECT key, args_hash, status, result, created_at FROM idempotency_keys WHERE key = $1`,
|
|
735
|
+
[key]
|
|
736
|
+
);
|
|
737
|
+
const row = rows[0];
|
|
738
|
+
if (!row) return null;
|
|
739
|
+
return {
|
|
740
|
+
key: str(row.key),
|
|
741
|
+
argsHash: str(row.args_hash),
|
|
742
|
+
status: str(row.status),
|
|
743
|
+
...row.result !== null && row.result !== void 0 ? { result: JSON.parse(str(row.result)) } : {},
|
|
744
|
+
createdAt: str(row.created_at)
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
async recordDecision(sessionId, callId, decision) {
|
|
748
|
+
const serialized = JSON.stringify(decision);
|
|
749
|
+
await this.client.query(
|
|
750
|
+
`INSERT INTO suspension_decisions (session_id, call_id, decision, created_at) VALUES ($1, $2, $3, $4)
|
|
751
|
+
ON CONFLICT (session_id, call_id) DO UPDATE SET decision = EXCLUDED.decision, created_at = EXCLUDED.created_at`,
|
|
752
|
+
[sessionId, callId, serialized, this.graphNow()]
|
|
753
|
+
);
|
|
754
|
+
}
|
|
755
|
+
async getDecision(sessionId, callId) {
|
|
756
|
+
const { rows } = await this.client.query(
|
|
757
|
+
`SELECT decision FROM suspension_decisions WHERE session_id = $1 AND call_id = $2`,
|
|
758
|
+
[sessionId, callId]
|
|
759
|
+
);
|
|
760
|
+
const row = rows[0];
|
|
761
|
+
if (!row) return null;
|
|
762
|
+
return JSON.parse(str(row.decision));
|
|
763
|
+
}
|
|
764
|
+
};
|
|
765
|
+
export {
|
|
766
|
+
PostgresStore
|
|
767
|
+
};
|