@shadowforge0/aquifer-memory 0.2.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.
@@ -0,0 +1,634 @@
1
+ 'use strict';
2
+
3
+ const { Pool } = require('pg');
4
+ const path = require('path');
5
+ const fs = require('fs');
6
+
7
+ const storage = require('./storage');
8
+ const entity = require('./entity');
9
+ const { hybridRank } = require('./hybrid-rank');
10
+ const { summarize } = require('../pipeline/summarize');
11
+ const { extractEntities } = require('../pipeline/extract-entities');
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Schema name validation
15
+ // ---------------------------------------------------------------------------
16
+
17
+ const SCHEMA_RE = /^[a-zA-Z_]\w{0,62}$/;
18
+
19
+ function validateSchema(schema) {
20
+ if (!SCHEMA_RE.test(schema)) {
21
+ throw new Error(`Invalid schema name: "${schema}". Must match /^[a-zA-Z_]\\w{0,62}$/`);
22
+ }
23
+ }
24
+
25
+ // C1 fix: quote identifiers to handle reserved words safely
26
+ function qi(identifier) { return `"${identifier}"`; }
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // SQL file loader — replaces ${schema} placeholders
30
+ // ---------------------------------------------------------------------------
31
+
32
+ function loadSql(filename, schema) {
33
+ const filePath = path.join(__dirname, '..', 'schema', filename);
34
+ const raw = fs.readFileSync(filePath, 'utf8');
35
+ // C1: use quoted identifier for safety
36
+ return raw.replace(/\$\{schema\}/g, qi(schema));
37
+ }
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // createAquifer
41
+ // ---------------------------------------------------------------------------
42
+
43
+ function createAquifer(config) {
44
+ if (!config || !config.db) {
45
+ throw new Error('config.db (pg.Pool or connection string) is required');
46
+ }
47
+
48
+ const schema = config.schema || 'aquifer';
49
+ validateSchema(schema);
50
+
51
+ if (config.tenantId === '') throw new Error('config.tenantId must not be empty');
52
+ const tenantId = config.tenantId || 'default';
53
+
54
+ // Pool management
55
+ let pool;
56
+ let ownsPool = false;
57
+ if (typeof config.db === 'string') {
58
+ pool = new Pool({ connectionString: config.db });
59
+ ownsPool = true;
60
+ } else {
61
+ pool = config.db;
62
+ }
63
+
64
+ // Embed config (lazy — only required for recall/enrich)
65
+ const embedFn = config.embed && typeof config.embed.fn === 'function' ? config.embed.fn : null;
66
+ let embedDim = config.embed ? (config.embed.dim || null) : null;
67
+
68
+ function requireEmbed(op) {
69
+ if (!embedFn) throw new Error(`Aquifer.${op}() requires config.embed.fn (async (texts) => number[][])`);
70
+ }
71
+
72
+ // LLM config (optional — only needed for enrich with built-in summarize)
73
+ const llmFn = config.llm && typeof config.llm.fn === 'function' ? config.llm.fn : null;
74
+
75
+ // Summarize config
76
+ const summarizePromptFn = config.summarize && config.summarize.prompt ? config.summarize.prompt : null;
77
+
78
+ // Entity config
79
+ let entitiesEnabled = config.entities && config.entities.enabled === true;
80
+ const mergeCall = config.entities && config.entities.mergeCall !== undefined ? config.entities.mergeCall : true;
81
+ const entityPromptFn = config.entities && config.entities.prompt ? config.entities.prompt : null;
82
+
83
+ // Rank weights
84
+ const rankWeights = {
85
+ rrf: 0.65,
86
+ timeDecay: 0.25,
87
+ access: 0.10,
88
+ entityBoost: 0.18,
89
+ ...(config.rank || {}),
90
+ };
91
+
92
+ // Source registry (in-memory)
93
+ const sources = new Map();
94
+
95
+ // Track if migrate was called
96
+ let migrated = false;
97
+
98
+ // --- Helper: embed search on summaries ---
99
+ async function embeddingSearchSummaries(queryVec, opts) {
100
+ const { agentId, source, dateFrom, dateTo, limit = 20 } = opts;
101
+ const where = [`s.tenant_id = $1`];
102
+ const params = [tenantId];
103
+
104
+ params.push(`[${queryVec.join(',')}]`);
105
+ const vecPos = params.length;
106
+
107
+ if (dateFrom) {
108
+ params.push(dateFrom);
109
+ where.push(`($${params.length}::date IS NULL OR s.started_at::date >= $${params.length}::date)`);
110
+ }
111
+ if (dateTo) {
112
+ params.push(dateTo);
113
+ where.push(`($${params.length}::date IS NULL OR s.started_at::date <= $${params.length}::date)`);
114
+ }
115
+ if (agentId) {
116
+ params.push(agentId);
117
+ where.push(`s.agent_id = $${params.length}`);
118
+ }
119
+ if (source) {
120
+ params.push(source);
121
+ where.push(`s.source = $${params.length}`);
122
+ }
123
+
124
+ params.push(limit);
125
+
126
+ const result = await pool.query(
127
+ `SELECT
128
+ s.id, s.session_id, s.agent_id, s.source, s.started_at, s.last_message_at,
129
+ ss.summary_text, ss.structured_summary, ss.access_count, ss.last_accessed_at,
130
+ (ss.embedding <=> $${vecPos}::vector) AS distance
131
+ FROM ${qi(schema)}.session_summaries ss
132
+ JOIN ${qi(schema)}.sessions s ON s.id = ss.session_row_id
133
+ WHERE ss.embedding IS NOT NULL
134
+ AND ${where.join(' AND ')}
135
+ ORDER BY distance ASC
136
+ LIMIT $${params.length}`,
137
+ params
138
+ );
139
+
140
+ return result.rows;
141
+ }
142
+
143
+ // =========================================================================
144
+ // Public API
145
+ // =========================================================================
146
+
147
+ const aquifer = {
148
+ // --- lifecycle ---
149
+
150
+ async migrate() {
151
+ // 1. Run base DDL
152
+ const baseSql = loadSql('001-base.sql', schema);
153
+ await pool.query(baseSql);
154
+
155
+ // 2. If entities enabled, run entity DDL
156
+ if (entitiesEnabled) {
157
+ const entitySql = loadSql('002-entities.sql', schema);
158
+ await pool.query(entitySql);
159
+ }
160
+
161
+ migrated = true;
162
+ },
163
+
164
+ async close() {
165
+ if (ownsPool) {
166
+ await pool.end();
167
+ }
168
+ },
169
+
170
+ // --- source registration ---
171
+
172
+ registerSource(name, opts = {}) {
173
+ sources.set(name, {
174
+ type: opts.type || 'custom',
175
+ search: opts.search || null,
176
+ weight: opts.weight !== null && opts.weight !== undefined ? opts.weight : 1.0,
177
+ });
178
+ },
179
+
180
+ async enableEntities() {
181
+ entitiesEnabled = true;
182
+ // M4: if already migrated, run entity DDL now
183
+ if (migrated) {
184
+ const entitySql = loadSql('002-entities.sql', schema);
185
+ await pool.query(entitySql);
186
+ }
187
+ },
188
+
189
+ // --- write path ---
190
+
191
+ async commit(sessionId, messages, opts = {}) {
192
+ if (!sessionId) throw new Error('sessionId is required');
193
+ if (!messages || !Array.isArray(messages)) throw new Error('messages must be an array');
194
+
195
+ const agentId = opts.agentId || 'agent';
196
+ const source = opts.source || 'api';
197
+
198
+ // Count messages
199
+ let msgCount = messages.length;
200
+ let userCount = 0;
201
+ let assistantCount = 0;
202
+ for (const m of messages) {
203
+ if (m.role === 'user') userCount++;
204
+ else if (m.role === 'assistant') assistantCount++;
205
+ }
206
+
207
+ // rawMessages: pass through a pre-built messages payload without wrapping
208
+ const messagesPayload = opts.rawMessages || { normalized: messages };
209
+
210
+ const result = await storage.upsertSession(pool, {
211
+ schema,
212
+ tenantId,
213
+ sessionId,
214
+ sessionKey: opts.sessionKey || null,
215
+ agentId,
216
+ source,
217
+ messages: messagesPayload,
218
+ msgCount,
219
+ userCount,
220
+ assistantCount,
221
+ model: opts.model || null,
222
+ tokensIn: opts.tokensIn || 0,
223
+ tokensOut: opts.tokensOut || 0,
224
+ startedAt: opts.startedAt || null,
225
+ lastMessageAt: opts.lastMessageAt || null,
226
+ });
227
+
228
+ return {
229
+ id: result.id,
230
+ sessionId: result.sessionId,
231
+ isNew: result.isNew,
232
+ };
233
+ },
234
+
235
+ // --- enrichment ---
236
+
237
+ async enrich(sessionId, opts = {}) {
238
+ const agentId = opts.agentId || 'agent';
239
+ const skipSummary = opts.skipSummary || false;
240
+ const skipTurnEmbed = opts.skipTurnEmbed || false;
241
+ const skipEntities = opts.skipEntities || false;
242
+
243
+ // Custom hooks: let callers bring their own summarize/entity pipeline
244
+ const customSummaryFn = opts.summaryFn || null; // async (messages) => { summaryText, structuredSummary, entityRaw?, extra? }
245
+ const customEntityParseFn = opts.entityParseFn || null; // (text) => [{ name, normalizedName, aliases, type }]
246
+
247
+ // 1. Optimistic lock: claim session for processing
248
+ const claimResult = await pool.query(
249
+ `UPDATE ${qi(schema)}.sessions
250
+ SET processing_status = 'processing'
251
+ WHERE session_id = $1 AND agent_id = $2 AND tenant_id = $3
252
+ AND processing_status IN ('pending', 'failed')
253
+ RETURNING *`,
254
+ [sessionId, agentId, tenantId]
255
+ );
256
+ const session = claimResult.rows[0];
257
+ if (!session) {
258
+ // Check if session exists but is already processing/succeeded
259
+ const existing = await storage.getSession(pool, sessionId, agentId, {}, { schema, tenantId });
260
+ if (!existing) throw new Error(`Session not found: ${sessionId} (agentId=${agentId})`);
261
+ if (existing.processing_status === 'processing') throw new Error(`Session ${sessionId} is already being enriched`);
262
+ if (existing.processing_status === 'succeeded') throw new Error(`Session ${sessionId} is already enriched. Re-commit to reset.`);
263
+ throw new Error(`Session ${sessionId} has unexpected status: ${existing.processing_status}`);
264
+ }
265
+
266
+ const rawMessages = session.messages;
267
+ const messages = rawMessages
268
+ ? (typeof rawMessages === 'string' ? JSON.parse(rawMessages) : rawMessages)
269
+ : null;
270
+ const normalized = messages ? (messages.normalized || messages) : [];
271
+
272
+ // 2. Extract user turns
273
+ const turns = storage.extractUserTurns(normalized);
274
+
275
+ // 3. Summarize (custom or built-in)
276
+ let summaryResult = null;
277
+ let entityRaw = null;
278
+ let extra = null;
279
+
280
+ if (!skipSummary && normalized.length > 0) {
281
+ if (customSummaryFn) {
282
+ // Custom pipeline: caller handles LLM call and parsing
283
+ summaryResult = await customSummaryFn(normalized);
284
+ if (summaryResult.entityRaw) entityRaw = summaryResult.entityRaw;
285
+ if (summaryResult.extra) extra = summaryResult.extra;
286
+ } else {
287
+ // Built-in pipeline
288
+ const doMergeEntities = entitiesEnabled && mergeCall && !skipEntities;
289
+ summaryResult = await summarize(normalized, {
290
+ llmFn,
291
+ promptFn: summarizePromptFn,
292
+ mergeEntities: doMergeEntities,
293
+ });
294
+ if (summaryResult.entityRaw) {
295
+ entityRaw = summaryResult.entityRaw;
296
+ }
297
+ }
298
+ }
299
+
300
+ // 4. Pre-compute all LLM/embed results BEFORE opening transaction
301
+ // (avoids holding pool connection during slow LLM/embed calls)
302
+ const warnings = [];
303
+ let summaryEmbedding = null;
304
+ let turnVectors = null;
305
+ let parsedEntities = [];
306
+
307
+ // 4a. Summary embedding
308
+ if (summaryResult && summaryResult.summaryText) {
309
+ try {
310
+ const embResult = await embedFn([summaryResult.summaryText]);
311
+ summaryEmbedding = embResult[0] || null;
312
+ } catch (e) { warnings.push(`summary embed failed: ${e.message}`); }
313
+ }
314
+
315
+ // 4b. Turn embeddings
316
+ if (!skipTurnEmbed && turns.length > 0) {
317
+ try {
318
+ turnVectors = await embedFn(turns.map(t => t.text));
319
+ } catch (e) { warnings.push(`turn embed failed: ${e.message}`); }
320
+ }
321
+
322
+ // 4c. Entity extraction (custom parser or built-in)
323
+ if (entitiesEnabled && !skipEntities) {
324
+ try {
325
+ if (entityRaw && customEntityParseFn) {
326
+ parsedEntities = customEntityParseFn(entityRaw);
327
+ } else if (entityRaw) {
328
+ parsedEntities = entity.parseEntityOutput(entityRaw);
329
+ } else if (llmFn && !customSummaryFn) {
330
+ parsedEntities = await extractEntities(normalized, { llmFn, promptFn: entityPromptFn });
331
+ }
332
+ } catch (e) { warnings.push(`entity extraction failed: ${e.message}`); }
333
+ }
334
+
335
+ // 5. Now open transaction — only DB writes, no external calls
336
+ const client = await pool.connect();
337
+ let turnsEmbedded = 0;
338
+ let entitiesFound = 0;
339
+
340
+ try {
341
+ await client.query('BEGIN');
342
+
343
+ // 5a. Upsert summary
344
+ if (summaryResult && summaryResult.summaryText) {
345
+ await storage.upsertSummary(client, session.id, {
346
+ schema, tenantId, agentId, sessionId,
347
+ summaryText: summaryResult.summaryText,
348
+ structuredSummary: summaryResult.structuredSummary,
349
+ model: null, sourceHash: null,
350
+ msgCount: normalized.length,
351
+ userCount: turns.length,
352
+ assistantCount: normalized.filter(m => m.role === 'assistant').length,
353
+ startedAt: session.started_at, endedAt: session.ended_at,
354
+ embedding: summaryEmbedding,
355
+ });
356
+ }
357
+
358
+ // 5b. Turn embeddings
359
+ if (turnVectors && turns.length > 0) {
360
+ try {
361
+ await storage.upsertTurnEmbeddings(client, session.id, {
362
+ schema, tenantId, sessionId, agentId,
363
+ source: session.source, turns, vectors: turnVectors,
364
+ });
365
+ turnsEmbedded = turns.length;
366
+ } catch (e) { warnings.push(`turn upsert failed: ${e.message}`); }
367
+ }
368
+
369
+ // 5c. Entity upsert chain (extraction already done in step 4c)
370
+ if (parsedEntities.length > 0) {
371
+ const entityIds = [];
372
+ for (const ent of parsedEntities) {
373
+ try {
374
+ const { id } = await entity.upsertEntity(client, {
375
+ schema,
376
+ tenantId,
377
+ name: ent.name,
378
+ normalizedName: ent.normalizedName,
379
+ aliases: ent.aliases,
380
+ type: ent.type,
381
+ agentId,
382
+ createdBy: 'aquifer',
383
+ occurredAt: session.started_at ? new Date(session.started_at).toISOString() : null,
384
+ });
385
+ entityIds.push(id);
386
+
387
+ // Upsert mention
388
+ await entity.upsertEntityMention(client, {
389
+ schema,
390
+ entityId: id,
391
+ sessionRowId: session.id,
392
+ source: session.source,
393
+ mentionText: ent.name,
394
+ occurredAt: session.started_at ? new Date(session.started_at).toISOString() : null,
395
+ });
396
+
397
+ // Upsert entity-session link
398
+ await entity.upsertEntitySession(client, {
399
+ schema,
400
+ entityId: id,
401
+ sessionRowId: session.id,
402
+ occurredAt: session.started_at ? new Date(session.started_at).toISOString() : null,
403
+ });
404
+ } catch (e) { warnings.push(`entity upsert failed: ${e.message}`); }
405
+ }
406
+
407
+ // Entity relations: all pairs
408
+ if (entityIds.length > 1) {
409
+ const pairs = [];
410
+ for (let i = 0; i < entityIds.length; i++) {
411
+ for (let j = i + 1; j < entityIds.length; j++) {
412
+ pairs.push({ srcEntityId: entityIds[i], dstEntityId: entityIds[j] });
413
+ }
414
+ }
415
+ try {
416
+ await entity.upsertEntityRelations(client, {
417
+ schema,
418
+ pairs,
419
+ occurredAt: session.started_at ? new Date(session.started_at).toISOString() : null,
420
+ });
421
+ } catch (e) { warnings.push(`entity relations failed: ${e.message}`); }
422
+ }
423
+
424
+ entitiesFound = entityIds.length;
425
+ }
426
+
427
+ // 8. Mark status + commit (M5: use 'partial' if warnings)
428
+ const finalStatus = warnings.length > 0 ? 'partial' : 'succeeded';
429
+ await storage.markStatus(client, session.id, finalStatus, warnings.length > 0 ? warnings.join('; ') : null, { schema });
430
+ await client.query('COMMIT');
431
+ } catch (err) {
432
+ await client.query('ROLLBACK').catch(() => {});
433
+ try {
434
+ await storage.markStatus(pool, session.id, 'failed', err.message, { schema });
435
+ } catch (_) { /* swallow */ }
436
+ throw err;
437
+ } finally {
438
+ client.release();
439
+ }
440
+
441
+ return {
442
+ summary: summaryResult ? summaryResult.summaryText : null,
443
+ structuredSummary: summaryResult ? summaryResult.structuredSummary : null,
444
+ turnsEmbedded,
445
+ entitiesFound,
446
+ warnings,
447
+ extra,
448
+ };
449
+ },
450
+
451
+ // --- read path ---
452
+
453
+ async recall(query, opts = {}) {
454
+ if (!query) return [];
455
+ requireEmbed('recall');
456
+
457
+ const {
458
+ agentId,
459
+ source,
460
+ dateFrom,
461
+ dateTo,
462
+ limit = 5,
463
+ weights: overrideWeights,
464
+ } = opts;
465
+
466
+ const fetchLimit = limit * 4;
467
+
468
+ // 1. Embed query
469
+ const queryVecResult = await embedFn([query]);
470
+ const queryVec = queryVecResult[0];
471
+ if (!queryVec || !queryVec.length) return []; // m3: guard empty array too
472
+
473
+ // 2. Run 3 search paths in parallel
474
+ const [ftsRows, embRows, turnResult] = await Promise.all([
475
+ storage.searchSessions(pool, query, {
476
+ schema, tenantId, agentId, source, dateFrom, dateTo, limit: fetchLimit,
477
+ }).catch(() => []),
478
+ embeddingSearchSummaries(queryVec, {
479
+ agentId, source, dateFrom, dateTo, limit: fetchLimit,
480
+ }).catch(() => []),
481
+ storage.searchTurnEmbeddings(pool, {
482
+ schema, tenantId, queryVec, dateFrom, dateTo, agentId, source, limit: fetchLimit,
483
+ }).catch(() => ({ rows: [] })),
484
+ ]);
485
+
486
+ const turnRows = turnResult.rows || [];
487
+
488
+ if (ftsRows.length === 0 && embRows.length === 0 && turnRows.length === 0) {
489
+ return [];
490
+ }
491
+
492
+ // 3. Entity boost (if enabled)
493
+ let entityScoreBySession = new Map();
494
+ if (entitiesEnabled) {
495
+ try {
496
+ const matchedEntities = await entity.searchEntities(pool, {
497
+ schema, tenantId, query, agentId, limit: 10,
498
+ });
499
+
500
+ if (matchedEntities.length > 0) {
501
+ // M1 fix: single JOIN instead of N+1
502
+ const entityIds = matchedEntities.map(e => e.id);
503
+ const esResult = await pool.query(
504
+ `SELECT es.session_row_id, s.session_id, COUNT(*) AS entity_count
505
+ FROM ${qi(schema)}.entity_sessions es
506
+ JOIN ${qi(schema)}.sessions s ON s.id = es.session_row_id
507
+ WHERE es.entity_id = ANY($1)
508
+ GROUP BY es.session_row_id, s.session_id`,
509
+ [entityIds]
510
+ );
511
+
512
+ const maxCount = Math.max(1, ...esResult.rows.map(r => parseInt(r.entity_count)));
513
+ for (const row of esResult.rows) {
514
+ entityScoreBySession.set(row.session_id, parseInt(row.entity_count) / maxCount);
515
+ }
516
+ }
517
+ } catch (_) { /* entity search failure non-fatal */ }
518
+ }
519
+
520
+ // 4. Run external source searches (parallel + timeout)
521
+ const EXTERNAL_TIMEOUT = 10000;
522
+ const externalRows = [];
523
+ const externalPromises = [];
524
+ for (const [, sourceConfig] of sources) {
525
+ if (typeof sourceConfig.search === 'function') {
526
+ const w = sourceConfig.weight !== null && sourceConfig.weight !== undefined ? sourceConfig.weight : 1.0;
527
+ externalPromises.push(
528
+ Promise.race([
529
+ sourceConfig.search(query, opts),
530
+ new Promise((_, rej) => setTimeout(() => rej(new Error('external source timeout')), EXTERNAL_TIMEOUT)),
531
+ ]).then(results => {
532
+ if (Array.isArray(results)) {
533
+ for (const r of results) {
534
+ if (r && r.session_id) externalRows.push({ ...r, _externalWeight: w });
535
+ }
536
+ }
537
+ }).catch(() => { /* external source failure/timeout non-fatal */ })
538
+ );
539
+ }
540
+ }
541
+ if (externalPromises.length > 0) await Promise.all(externalPromises);
542
+
543
+ // 5. Hybrid rank — external results as separate embedding-like signal
544
+ const mergedWeights = { ...rankWeights, ...overrideWeights };
545
+ const ranked = hybridRank(
546
+ ftsRows,
547
+ [...embRows, ...externalRows],
548
+ limit,
549
+ mergedWeights,
550
+ turnRows,
551
+ entityScoreBySession,
552
+ );
553
+
554
+ // 6. Record access
555
+ const sessionRowIds = ranked
556
+ .map(r => r.id || r.session_row_id)
557
+ .filter(Boolean);
558
+
559
+ if (sessionRowIds.length > 0) {
560
+ try {
561
+ await storage.recordAccess(pool, sessionRowIds, { schema });
562
+ } catch (_) { /* access recording non-fatal */ }
563
+ }
564
+
565
+ // 7. Format results
566
+ return ranked.map(r => ({
567
+ sessionId: r.session_id,
568
+ agentId: r.agent_id,
569
+ source: r.source,
570
+ startedAt: r.started_at,
571
+ summaryText: r.summary_text || null,
572
+ structuredSummary: r.structured_summary || null,
573
+ summarySnippet: r.summary_snippet || null,
574
+ matchedTurnText: r.matched_turn_text || null,
575
+ matchedTurnIndex: r.matched_turn_index || null,
576
+ score: r._score,
577
+ _debug: {
578
+ rrf: r._rrf,
579
+ timeDecay: r._timeDecay,
580
+ access: r._access,
581
+ entityScore: r._entityScore,
582
+ },
583
+ }));
584
+ },
585
+
586
+ // --- admin ---
587
+
588
+ async getSession(sessionId, opts = {}) {
589
+ const agentId = opts.agentId || 'agent';
590
+ return storage.getSession(pool, sessionId, agentId, opts, { schema, tenantId });
591
+ },
592
+
593
+ async getSessionFull(sessionId) {
594
+ // Try to find the session across agents by querying directly
595
+ const result = await pool.query(
596
+ `SELECT * FROM ${qi(schema)}.sessions
597
+ WHERE session_id = $1 AND tenant_id = $2
598
+ LIMIT 1`,
599
+ [sessionId, tenantId]
600
+ );
601
+ const session = result.rows[0];
602
+ if (!session) return null;
603
+
604
+ const [segResult, sumResult] = await Promise.all([
605
+ pool.query(
606
+ `SELECT * FROM ${qi(schema)}.session_segments
607
+ WHERE session_row_id = $1
608
+ ORDER BY segment_no ASC`,
609
+ [session.id]
610
+ ),
611
+ pool.query(
612
+ `SELECT * FROM ${qi(schema)}.session_summaries
613
+ WHERE session_row_id = $1
614
+ LIMIT 1`,
615
+ [session.id]
616
+ ),
617
+ ]);
618
+
619
+ return {
620
+ session,
621
+ segments: segResult.rows,
622
+ summary: sumResult.rows[0] || null,
623
+ };
624
+ },
625
+ };
626
+
627
+ return aquifer;
628
+ }
629
+
630
+ // ---------------------------------------------------------------------------
631
+ // Exports
632
+ // ---------------------------------------------------------------------------
633
+
634
+ module.exports = { createAquifer };