@shadowforge0/aquifer-memory 1.3.0 → 1.5.8

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.
@@ -0,0 +1,483 @@
1
+ 'use strict';
2
+
3
+ // aquifer.entityState.* — temporal state-change tracking on entities.
4
+ //
5
+ // One row per (entity, attribute) value valid over [valid_from, valid_to).
6
+ // Partial UNIQUE on (tenant, agent, entity, attribute) WHERE valid_to IS NULL
7
+ // enforces at-most-one-current.
8
+ //
9
+ // Out-of-order backfill is supported: applying a change with valid_from < the
10
+ // current row's valid_from inserts a closed-interval historical row instead
11
+ // of overwriting current. Same-value replays are no-ops (return action='noop_same_value').
12
+ //
13
+ // Source conflicts (current row source != incoming source AND values differ)
14
+ // return AQ_CONFLICT — the DB layer never assumes priority between manual /
15
+ // infra / llm; callers decide.
16
+
17
+ const crypto = require('crypto');
18
+ const { ok, err } = require('./errors');
19
+
20
+ const VALID_SOURCES = new Set(['llm', 'manual', 'infra']);
21
+ const ATTRIBUTE_RE = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$/;
22
+
23
+ function canonicalJson(value) {
24
+ // Stable JSON for idempotency hashing — sort object keys recursively.
25
+ if (value === null || typeof value !== 'object') return JSON.stringify(value);
26
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
27
+ const keys = Object.keys(value).sort();
28
+ return `{${keys.map(k => `${JSON.stringify(k)}:${canonicalJson(value[k])}`).join(',')}}`;
29
+ }
30
+
31
+ function defaultIdempotencyKey({ tenantId, agentId, entityId, attribute, value, validFrom, source, evidenceSessionId }) {
32
+ return crypto.createHash('sha256').update([
33
+ tenantId, agentId, String(entityId), attribute, canonicalJson(value),
34
+ new Date(validFrom).toISOString(), source, evidenceSessionId || '',
35
+ ].join('|')).digest('hex');
36
+ }
37
+
38
+ function toIsoOrNull(v) {
39
+ if (v === null || v === undefined) return null;
40
+ const d = v instanceof Date ? v : new Date(v);
41
+ return Number.isFinite(d.getTime()) ? d.toISOString() : null;
42
+ }
43
+
44
+ function mapRow(row) {
45
+ if (!row) return null;
46
+ return {
47
+ stateId: Number(row.id),
48
+ tenantId: row.tenant_id,
49
+ agentId: row.agent_id,
50
+ entityId: Number(row.entity_id),
51
+ sessionRowId: (row.session_row_id !== null && row.session_row_id !== undefined) ? Number(row.session_row_id) : null,
52
+ evidenceSessionId: row.evidence_session_id || null,
53
+ attribute: row.attribute,
54
+ value: row.value,
55
+ validFrom: row.valid_from,
56
+ validTo: row.valid_to,
57
+ evidenceText: row.evidence_text || '',
58
+ confidence: (row.confidence !== null && row.confidence !== undefined) ? Number(row.confidence) : null,
59
+ source: row.source,
60
+ idempotencyKey: row.idempotency_key || null,
61
+ supersedesStateId: (row.supersedes_state_id !== null && row.supersedes_state_id !== undefined) ? Number(row.supersedes_state_id) : null,
62
+ createdAt: row.created_at,
63
+ };
64
+ }
65
+
66
+ function validateChange(change, idx) {
67
+ if (!change || typeof change !== 'object') {
68
+ return `changes[${idx}] is not an object`;
69
+ }
70
+ if (change.entityId === null || change.entityId === undefined || !Number.isInteger(Number(change.entityId))) {
71
+ return `changes[${idx}].entityId is required (integer)`;
72
+ }
73
+ if (typeof change.attribute !== 'string' || !ATTRIBUTE_RE.test(change.attribute)) {
74
+ return `changes[${idx}].attribute must match /^[a-z][a-z0-9_]*(\\.[a-z][a-z0-9_]*)*$/ (got: ${JSON.stringify(change.attribute)})`;
75
+ }
76
+ if (change.value === undefined) {
77
+ return `changes[${idx}].value is required (use null for explicit null, undefined is forbidden)`;
78
+ }
79
+ const validFromIso = toIsoOrNull(change.validFrom);
80
+ if (!validFromIso) {
81
+ return `changes[${idx}].validFrom must parse to a valid timestamp`;
82
+ }
83
+ if (change.confidence !== null && change.confidence !== undefined) {
84
+ const c = Number(change.confidence);
85
+ if (!Number.isFinite(c) || c < 0 || c > 1) {
86
+ return `changes[${idx}].confidence must be in [0,1]`;
87
+ }
88
+ }
89
+ if (change.source !== null && change.source !== undefined && !VALID_SOURCES.has(change.source)) {
90
+ return `changes[${idx}].source must be one of llm|manual|infra`;
91
+ }
92
+ return null;
93
+ }
94
+
95
+ // Apply a single change against a transaction client. Caller MUST be inside
96
+ // a transaction. Returns { action, row } on success. On AQ_CONFLICT it throws
97
+ // a tagged Error — the outer applyChanges() catches this (see err.code ===
98
+ // 'AQ_CONFLICT') and returns the err() envelope, keeping the public surface
99
+ // pure-envelope while letting tx-level callers still use try/catch. DB errors
100
+ // (syntax, permission, etc.) propagate as real exceptions so savepoint logic
101
+ // in aquifer.enrich() can rollback cleanly.
102
+ async function applyOneChange(client, change, ctx) {
103
+ const tenantId = change.tenantId || ctx.defaultTenantId || 'default';
104
+ const agentId = change.agentId || ctx.agentId || 'main';
105
+ const entityId = Number(change.entityId);
106
+ const attribute = change.attribute;
107
+ const value = change.value;
108
+ const validFrom = new Date(change.validFrom).toISOString();
109
+ const evidenceText = change.evidenceText || '';
110
+ const confidence = (change.confidence !== null && change.confidence !== undefined) ? Number(change.confidence) : (ctx.defaultConfidence ?? 0.7);
111
+ const source = change.source || 'llm';
112
+ const evidenceSessionId = change.evidenceSessionId || null;
113
+ const sessionRowId = (change.sessionRowId !== null && change.sessionRowId !== undefined) ? Number(change.sessionRowId) : (ctx.sessionRowId ?? null);
114
+ const idempotencyKey = change.idempotencyKey || defaultIdempotencyKey({
115
+ tenantId, agentId, entityId, attribute, value, validFrom, source, evidenceSessionId,
116
+ });
117
+
118
+ const schema = ctx.schema;
119
+ const tbl = `${schema}.entity_state_history`;
120
+
121
+ // Idempotency preflight: if a row with this key already exists, no-op.
122
+ const idemRow = await client.query(
123
+ `SELECT * FROM ${tbl} WHERE idempotency_key = $1 LIMIT 1`,
124
+ [idempotencyKey]
125
+ );
126
+ if (idemRow.rowCount > 0) {
127
+ return { action: 'noop_idempotent', row: mapRow(idemRow.rows[0]) };
128
+ }
129
+
130
+ // Lock the current open row (if any) for this (tenant, agent, entity, attribute).
131
+ const currentRes = await client.query(
132
+ `SELECT * FROM ${tbl}
133
+ WHERE tenant_id = $1 AND agent_id = $2 AND entity_id = $3 AND attribute = $4 AND valid_to IS NULL
134
+ FOR UPDATE`,
135
+ [tenantId, agentId, entityId, attribute]
136
+ );
137
+ const current = currentRes.rows[0] || null;
138
+
139
+ // Out-of-order backfill: incoming validFrom is older than current.validFrom →
140
+ // insert a closed-interval historical row [validFrom, predecessorSuccessor).
141
+ // Must check overlap with existing historical rows so the timeline stays
142
+ // non-overlapping (temporal integrity).
143
+ if (current && new Date(validFrom).getTime() < new Date(current.valid_from).getTime()) {
144
+ // Find the nearest neighbours on either side of validFrom.
145
+ const neighbourRes = await client.query(
146
+ `SELECT id, value, valid_from, valid_to, source
147
+ FROM ${tbl}
148
+ WHERE tenant_id = $1 AND agent_id = $2 AND entity_id = $3 AND attribute = $4
149
+ AND valid_from <= $5
150
+ ORDER BY valid_from DESC LIMIT 1`,
151
+ [tenantId, agentId, entityId, attribute, validFrom]
152
+ );
153
+ const predecessor = neighbourRes.rows[0] || null;
154
+ // Exact-timestamp collision on an older row → conflict (can't create
155
+ // duplicate interval start).
156
+ if (predecessor && new Date(predecessor.valid_from).getTime() === new Date(validFrom).getTime()) {
157
+ const conflictErr = new Error(
158
+ `entity_state_history: equal-timestamp historical conflict on (entity=${entityId}, attribute=${attribute}, valid_from=${validFrom}) — predecessor row #${predecessor.id} already has this start`
159
+ );
160
+ conflictErr.code = 'AQ_CONFLICT';
161
+ throw conflictErr;
162
+ }
163
+ // If predecessor has an open-ended interval (valid_to NULL) or a valid_to
164
+ // that extends past validFrom, we'd overlap — refuse.
165
+ if (predecessor) {
166
+ const predEnd = (predecessor.valid_to === null || predecessor.valid_to === undefined)
167
+ ? Infinity : new Date(predecessor.valid_to).getTime();
168
+ if (predEnd > new Date(validFrom).getTime()) {
169
+ const conflictErr = new Error(
170
+ `entity_state_history: backfill overlaps predecessor row #${predecessor.id} [${predecessor.valid_from}, ${predecessor.valid_to ?? 'open'}) — incoming valid_from ${validFrom} falls inside`
171
+ );
172
+ conflictErr.code = 'AQ_CONFLICT';
173
+ throw conflictErr;
174
+ }
175
+ }
176
+ // Successor = next row with valid_from > validFrom; the new historical
177
+ // interval closes at that successor's valid_from (not current's, which
178
+ // may be further in the future than the nearest successor).
179
+ const successorRes = await client.query(
180
+ `SELECT id, valid_from
181
+ FROM ${tbl}
182
+ WHERE tenant_id = $1 AND agent_id = $2 AND entity_id = $3 AND attribute = $4
183
+ AND valid_from > $5
184
+ ORDER BY valid_from ASC LIMIT 1`,
185
+ [tenantId, agentId, entityId, attribute, validFrom]
186
+ );
187
+ const successor = successorRes.rows[0]; // guaranteed to exist — `current` itself qualifies
188
+ const validTo = successor ? successor.valid_from : current.valid_from;
189
+
190
+ const inserted = await client.query(
191
+ `INSERT INTO ${tbl}
192
+ (tenant_id, agent_id, entity_id, session_row_id, evidence_session_id,
193
+ attribute, value, valid_from, valid_to, evidence_text, confidence, source,
194
+ idempotency_key, supersedes_state_id)
195
+ VALUES ($1,$2,$3,$4,$5, $6,$7::jsonb,$8,$9, $10,$11,$12, $13,NULL)
196
+ RETURNING *`,
197
+ [tenantId, agentId, entityId, sessionRowId, evidenceSessionId,
198
+ attribute, JSON.stringify(value), validFrom, validTo,
199
+ evidenceText, confidence, source, idempotencyKey]
200
+ );
201
+ return { action: 'inserted_historical', row: mapRow(inserted.rows[0]) };
202
+ }
203
+
204
+ // Same value as current → noop. (Optionally bump last_seen via separate API,
205
+ // but state_history is append-only by design.)
206
+ if (current && canonicalJson(current.value) === canonicalJson(value)) {
207
+ return { action: 'noop_same_value', row: mapRow(current) };
208
+ }
209
+
210
+ // Source-conflict guard: current row written by a different source, values
211
+ // differ. Don't auto-override; bubble up so caller decides.
212
+ if (current && current.source !== source) {
213
+ const conflictErr = new Error(
214
+ `entity_state_history: source conflict on (entity=${entityId}, attribute=${attribute}): current source=${current.source} value=${JSON.stringify(current.value)}, incoming source=${source} value=${JSON.stringify(value)}`
215
+ );
216
+ conflictErr.code = 'AQ_CONFLICT';
217
+ throw conflictErr;
218
+ }
219
+
220
+ // Equal-timestamp different-value → conflict (history would be ambiguous).
221
+ if (current && new Date(current.valid_from).getTime() === new Date(validFrom).getTime()) {
222
+ const conflictErr = new Error(
223
+ `entity_state_history: equal-timestamp conflict on (entity=${entityId}, attribute=${attribute}, valid_from=${validFrom}) — value change must advance time`
224
+ );
225
+ conflictErr.code = 'AQ_CONFLICT';
226
+ throw conflictErr;
227
+ }
228
+
229
+ // Forward-in-time supersede: close current, insert new current.
230
+ let supersededId = null;
231
+ if (current) {
232
+ await client.query(
233
+ `UPDATE ${tbl} SET valid_to = $1 WHERE id = $2`,
234
+ [validFrom, current.id]
235
+ );
236
+ supersededId = current.id;
237
+ }
238
+
239
+ const inserted = await client.query(
240
+ `INSERT INTO ${tbl}
241
+ (tenant_id, agent_id, entity_id, session_row_id, evidence_session_id,
242
+ attribute, value, valid_from, evidence_text, confidence, source,
243
+ idempotency_key, supersedes_state_id)
244
+ VALUES ($1,$2,$3,$4,$5, $6,$7::jsonb,$8, $9,$10,$11, $12,$13)
245
+ RETURNING *`,
246
+ [tenantId, agentId, entityId, sessionRowId, evidenceSessionId,
247
+ attribute, JSON.stringify(value), validFrom,
248
+ evidenceText, confidence, source, idempotencyKey, supersededId]
249
+ );
250
+
251
+ return {
252
+ action: supersededId ? 'closed_and_inserted' : 'inserted_current',
253
+ row: mapRow(inserted.rows[0]),
254
+ };
255
+ }
256
+
257
+ function createEntityState({ pool, schema, defaultTenantId }) {
258
+ if (!pool) throw new Error('createEntityState: pool is required');
259
+ if (!schema) throw new Error('createEntityState: schema is required');
260
+
261
+ // ---------------------------------------------------------------------------
262
+ // applyChanges (tx-aware, caller passes client)
263
+ // ---------------------------------------------------------------------------
264
+ async function applyChanges(client, input = {}) {
265
+ try {
266
+ if (!client || typeof client.query !== 'function') {
267
+ return err('AQ_INVALID_INPUT', 'applyChanges requires a tx client');
268
+ }
269
+ const changes = Array.isArray(input.changes) ? input.changes : null;
270
+ if (!changes) return err('AQ_INVALID_INPUT', 'changes must be an array');
271
+
272
+ // Validate up-front (cheap, fail-fast before any DB work).
273
+ for (let i = 0; i < changes.length; i++) {
274
+ const msg = validateChange(changes[i], i);
275
+ if (msg) return err('AQ_INVALID_INPUT', msg);
276
+ }
277
+
278
+ // Sort changes by valid_from ASC so within a single batch the supersede
279
+ // chain is correctly built (older first, current last).
280
+ const sorted = changes.slice().sort((a, b) =>
281
+ new Date(a.validFrom).getTime() - new Date(b.validFrom).getTime()
282
+ );
283
+
284
+ const ctx = {
285
+ schema,
286
+ defaultTenantId,
287
+ agentId: input.agentId,
288
+ sessionRowId: input.sessionRowId,
289
+ defaultConfidence: input.defaultConfidence,
290
+ };
291
+
292
+ const results = [];
293
+ for (const change of sorted) {
294
+ try {
295
+ const r = await applyOneChange(client, change, ctx);
296
+ results.push(r);
297
+ } catch (e) {
298
+ if (e.code === 'AQ_CONFLICT') return err('AQ_CONFLICT', e.message);
299
+ throw e;
300
+ }
301
+ }
302
+ return ok({ applied: results });
303
+ } catch (e) {
304
+ return err('AQ_INTERNAL', e.message);
305
+ }
306
+ }
307
+
308
+ // ---------------------------------------------------------------------------
309
+ // applyChangesStandalone — convenience wrapper; opens its own savepointed tx.
310
+ // ---------------------------------------------------------------------------
311
+ async function applyChangesStandalone(input = {}) {
312
+ const client = await pool.connect();
313
+ try {
314
+ await client.query('BEGIN');
315
+ const result = await applyChanges(client, input);
316
+ if (result.ok) await client.query('COMMIT');
317
+ else await client.query('ROLLBACK');
318
+ return result;
319
+ } catch (e) {
320
+ try { await client.query('ROLLBACK'); } catch { /* ignore */ }
321
+ return err('AQ_INTERNAL', e.message);
322
+ } finally {
323
+ client.release();
324
+ }
325
+ }
326
+
327
+ // ---------------------------------------------------------------------------
328
+ // resolveEntity — accept entityId or entityName, return entityId.
329
+ // ---------------------------------------------------------------------------
330
+ async function resolveEntity({ entityId, entityName, tenantId, agentId: _agentId, entityScope }) {
331
+ if (entityId !== null && entityId !== undefined) {
332
+ // When caller passed entityScope, require the looked-up entity to
333
+ // match it — otherwise the id can be used to read cross-scope state.
334
+ const params = [Number(entityId), tenantId || defaultTenantId || 'default'];
335
+ let scopeClause = '';
336
+ if (entityScope) {
337
+ params.push(entityScope);
338
+ scopeClause = `AND entity_scope = $${params.length}`;
339
+ }
340
+ const r = await pool.query(
341
+ `SELECT id, name FROM ${schema}.entities
342
+ WHERE id = $1 AND tenant_id = $2 ${scopeClause} LIMIT 1`,
343
+ params
344
+ );
345
+ if (r.rowCount === 0) return null;
346
+ return { entityId: Number(r.rows[0].id), entityName: r.rows[0].name };
347
+ }
348
+ if (entityName) {
349
+ const normalized = String(entityName).toLowerCase().normalize('NFKC').trim();
350
+ const r = await pool.query(
351
+ `SELECT id, name FROM ${schema}.entities
352
+ WHERE tenant_id = $1 AND normalized_name = $2
353
+ AND (entity_scope = $3 OR $3 IS NULL)
354
+ ORDER BY id ASC LIMIT 1`,
355
+ [tenantId || defaultTenantId || 'default', normalized, entityScope || null]
356
+ );
357
+ if (r.rowCount === 0) return null;
358
+ return { entityId: Number(r.rows[0].id), entityName: r.rows[0].name };
359
+ }
360
+ return null;
361
+ }
362
+
363
+ // ---------------------------------------------------------------------------
364
+ // getEntityCurrentState
365
+ // ---------------------------------------------------------------------------
366
+ async function getEntityCurrentState(input = {}) {
367
+ try {
368
+ const tenantId = input.tenantId || defaultTenantId || 'default';
369
+ const agentId = input.agentId;
370
+ if (!agentId) return err('AQ_INVALID_INPUT', 'agentId is required');
371
+ if ((input.entityId === null || input.entityId === undefined) && !input.entityName) {
372
+ return err('AQ_INVALID_INPUT', 'entityId or entityName is required');
373
+ }
374
+ const minConfidence = (input.minConfidence !== null && input.minConfidence !== undefined) ? Number(input.minConfidence) : 0;
375
+ const attributes = Array.isArray(input.attributes) && input.attributes.length > 0
376
+ ? input.attributes : null;
377
+
378
+ const ent = await resolveEntity({
379
+ entityId: input.entityId,
380
+ entityName: input.entityName,
381
+ tenantId,
382
+ agentId,
383
+ entityScope: input.entityScope,
384
+ });
385
+ if (!ent) return err('AQ_NOT_FOUND', `entity not found (${input.entityId ?? input.entityName})`);
386
+
387
+ const params = [tenantId, agentId, ent.entityId, minConfidence];
388
+ let attrClause = '';
389
+ if (attributes) {
390
+ params.push(attributes);
391
+ attrClause = `AND attribute = ANY($${params.length})`;
392
+ }
393
+ const r = await pool.query(
394
+ `SELECT * FROM ${schema}.entity_state_history
395
+ WHERE tenant_id = $1 AND agent_id = $2 AND entity_id = $3
396
+ AND valid_to IS NULL
397
+ AND confidence >= $4
398
+ ${attrClause}
399
+ ORDER BY attribute ASC`,
400
+ params
401
+ );
402
+ return ok({
403
+ entityId: ent.entityId,
404
+ entityName: ent.entityName,
405
+ states: r.rows.map(mapRow),
406
+ });
407
+ } catch (e) {
408
+ return err('AQ_INTERNAL', e.message);
409
+ }
410
+ }
411
+
412
+ // ---------------------------------------------------------------------------
413
+ // getEntityStateHistory
414
+ // ---------------------------------------------------------------------------
415
+ async function getEntityStateHistory(input = {}) {
416
+ try {
417
+ const tenantId = input.tenantId || defaultTenantId || 'default';
418
+ const agentId = input.agentId;
419
+ if (!agentId) return err('AQ_INVALID_INPUT', 'agentId is required');
420
+ if ((input.entityId === null || input.entityId === undefined) && !input.entityName) {
421
+ return err('AQ_INVALID_INPUT', 'entityId or entityName is required');
422
+ }
423
+ const attribute = input.attribute || null; // optional: full entity history if omitted
424
+ const limit = Math.max(1, Math.min(200, Number(input.limit) || 50));
425
+ const minConfidence = (input.minConfidence !== null && input.minConfidence !== undefined) ? Number(input.minConfidence) : 0;
426
+ const before = input.before ? toIsoOrNull(input.before) : null;
427
+
428
+ const ent = await resolveEntity({
429
+ entityId: input.entityId,
430
+ entityName: input.entityName,
431
+ tenantId,
432
+ agentId,
433
+ entityScope: input.entityScope,
434
+ });
435
+ if (!ent) return err('AQ_NOT_FOUND', `entity not found (${input.entityId ?? input.entityName})`);
436
+
437
+ const where = [
438
+ 'tenant_id = $1', 'agent_id = $2', 'entity_id = $3', 'confidence >= $4',
439
+ ];
440
+ const params = [tenantId, agentId, ent.entityId, minConfidence];
441
+ if (attribute) {
442
+ params.push(attribute);
443
+ where.push(`attribute = $${params.length}`);
444
+ }
445
+ if (before) {
446
+ params.push(before);
447
+ where.push(`valid_from < $${params.length}`);
448
+ }
449
+ params.push(limit);
450
+
451
+ const r = await pool.query(
452
+ `SELECT * FROM ${schema}.entity_state_history
453
+ WHERE ${where.join(' AND ')}
454
+ ORDER BY valid_from DESC, id DESC
455
+ LIMIT $${params.length}`,
456
+ params
457
+ );
458
+ return ok({
459
+ entityId: ent.entityId,
460
+ entityName: ent.entityName,
461
+ rows: r.rows.map(mapRow),
462
+ });
463
+ } catch (e) {
464
+ return err('AQ_INTERNAL', e.message);
465
+ }
466
+ }
467
+
468
+ return {
469
+ applyChanges,
470
+ applyChangesStandalone,
471
+ getEntityCurrentState,
472
+ getEntityStateHistory,
473
+ // expose for testing
474
+ _internal: { defaultIdempotencyKey, canonicalJson, validateChange, applyOneChange, resolveEntity },
475
+ };
476
+ }
477
+
478
+ module.exports = {
479
+ createEntityState,
480
+ defaultIdempotencyKey,
481
+ canonicalJson,
482
+ validateChange,
483
+ };