Repository navigation
Expand file tree
/
Copy pathdb.js
More file actions
403 lines (364 loc) · 12.3 KB
/
Copy pathdb.js
File metadata and controls
403 lines (364 loc) · 12.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
const fs = require('fs');
const { Pool } = require('pg');
const { chunkDocument, cleanDocument, queryTerms, sourceAuthority } = require('./retrieval');
const { normalizeProvenance } = require('./sourceProvenance');
function sslFor(connectionString) {
const value = String(connectionString || '');
if (!value || /localhost|127\.0\.0\.1|\.railway\.internal/i.test(value)) return false;
if (process.env.PGSSLROOTCERT) {
return { rejectUnauthorized: true, ca: fs.readFileSync(process.env.PGSSLROOTCERT, 'utf8') };
}
return { rejectUnauthorized: true };
}
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
ssl: sslFor(process.env.DATABASE_URL),
max: 10,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000,
});
pool.on('error', (err) => {
console.error('[DB] Unexpected pool error:', err.message);
});
async function initSchema() {
const client = await pool.connect();
try {
await client.query(`
CREATE TABLE IF NOT EXISTS threads (
thread_id TEXT PRIMARY KEY,
last_message_id TEXT
);
CREATE TABLE IF NOT EXISTS escalations (
id SERIAL PRIMARY KEY,
thread_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS knowledge_base (
id SERIAL PRIMARY KEY,
content TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS doc_pages (
url TEXT PRIMARY KEY,
title TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL DEFAULT '',
fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
ALTER TABLE doc_pages ADD COLUMN IF NOT EXISTS provenance JSONB NOT NULL DEFAULT '{}'::jsonb;
CREATE TABLE IF NOT EXISTS doc_chunks (
url TEXT NOT NULL REFERENCES doc_pages(url) ON DELETE CASCADE,
chunk_index INTEGER NOT NULL,
title TEXT NOT NULL DEFAULT '',
section TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT 'other',
authority INTEGER NOT NULL DEFAULT 2,
body TEXT NOT NULL DEFAULT '',
search_vector TSVECTOR GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
setweight(to_tsvector('english', coalesce(section, '')), 'A') ||
setweight(to_tsvector('english', coalesce(body, '')), 'B')
) STORED,
PRIMARY KEY (url, chunk_index)
);
CREATE INDEX IF NOT EXISTS doc_chunks_search_idx ON doc_chunks USING GIN (search_vector);
CREATE INDEX IF NOT EXISTS doc_chunks_source_idx ON doc_chunks (source, authority DESC);
CREATE TABLE IF NOT EXISTS source_sync (
source_key TEXT PRIMARY KEY,
synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
item_count INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS releases (
tag TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL DEFAULT '',
published_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS issue_threads (
issue_number INTEGER NOT NULL,
thread_id TEXT NOT NULL,
PRIMARY KEY (issue_number, thread_id)
);
CREATE TABLE IF NOT EXISTS ratings (
thread_id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
helped BOOLEAN NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
`);
await require('./supportRuntime').initSchema(client);
await require('./supportCases').initSchema(client);
await require('./caseCards').initSchema(client);
await require('./supportIdentityStore').initSchema(client);
await require('./supportApprovals').initSchema(client);
await require('./supportIssueLinks').initSchema(client);
await require('./githubNoticeReceipts').initSchema(client);
await require('./supportMemoryStore').initSchema(client);
await require('./knowledgeReviewStore').initSchema(client);
await require('./supportThreadLanguage').initSchema(client);
const chunks = await client.query(`SELECT count(*)::int AS n FROM doc_chunks`);
const pages = await client.query(`SELECT count(*)::int AS n FROM doc_pages`);
if ((chunks.rows[0]?.n || 0) === 0 && (pages.rows[0]?.n || 0) > 0) {
const existing = await client.query(`SELECT url, title, body, provenance FROM doc_pages`);
for (const page of existing.rows) {
await replaceDocChunks(client, page);
}
console.log(`[DB] indexed ${existing.rows.length} existing page(s)`);
}
console.log('[DB] Schema initialized');
} finally {
client.release();
}
}
async function upsertThread(threadId, lastMessageId) {
await pool.query(
`INSERT INTO threads (thread_id, last_message_id)
VALUES ($1, $2)
ON CONFLICT (thread_id)
DO UPDATE SET last_message_id = $2`,
[threadId, lastMessageId]
);
}
async function createEscalation(threadId) {
const { rows } = await pool.query(
`INSERT INTO escalations (thread_id, status) VALUES ($1, 'pending') RETURNING id`,
[threadId]
);
return rows[0].id;
}
async function getPendingEscalation(threadId) {
const { rows } = await pool.query(
`SELECT id, thread_id FROM escalations
WHERE thread_id = $1 AND status = 'pending'
ORDER BY created_at DESC LIMIT 1`,
[threadId]
);
return rows[0] || null;
}
async function resolveEscalation(id) {
await pool.query(
`UPDATE escalations SET status = 'resolved' WHERE id = $1`,
[id]
);
}
async function reopenEscalation(id) {
await pool.query(`UPDATE escalations SET status = 'pending' WHERE id = $1`, [id]);
}
async function getAllPendingEscalations() {
const { rows } = await pool.query(
`SELECT id, thread_id FROM escalations WHERE status = 'pending' ORDER BY created_at ASC`
);
return rows;
}
async function addKnowledge(content) {
await pool.query(
`INSERT INTO knowledge_base (content) VALUES ($1)`,
[content]
);
}
async function searchKnowledge(query, limit = 3) {
// Simple keyword search — returns most recent relevant snippets
// For production, consider pg_trgm or a vector DB for semantic search
const words = query
.toLowerCase()
.split(/\s+/)
.filter((w) => w.length > 3)
.slice(0, 5);
if (words.length === 0) {
const { rows } = await pool.query(
`SELECT content FROM knowledge_base ORDER BY created_at DESC LIMIT $1`,
[limit]
);
return rows.map((r) => r.content);
}
const conditions = words.map((_, i) => `LOWER(content) LIKE $${i + 1}`);
const params = words.map((w) => `%${w}%`);
params.push(limit);
const { rows } = await pool.query(
`SELECT content FROM knowledge_base
WHERE ${conditions.join(' OR ')}
ORDER BY created_at DESC
LIMIT $${params.length}`,
params
);
return rows.map((r) => r.content);
}
function queryWords(text) {
return queryTerms(text, 12);
}
async function replaceDocChunks(client, page) {
const chunks = chunkDocument(page);
await client.query(`DELETE FROM doc_chunks WHERE url = $1`, [page.url]);
for (const chunk of chunks) {
await client.query(
`INSERT INTO doc_chunks
(url, chunk_index, title, section, source, authority, body)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[
chunk.url,
chunk.chunkIndex,
chunk.title,
chunk.section,
chunk.source,
sourceAuthority(chunk.source),
chunk.body,
]
);
}
return chunks.length;
}
async function saveDocPage({ url, title, body, provenance }) {
if (!url) return;
const page = {
url: String(url),
title: String(title || ''),
body: cleanDocument(body),
provenance: normalizeProvenance(provenance),
};
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query(
`INSERT INTO doc_pages (url, title, body, fetched_at, provenance)
VALUES ($1, $2, $3, NOW(), $4::jsonb)
ON CONFLICT (url) DO UPDATE SET title = $2, body = $3, fetched_at = NOW(), provenance = $4::jsonb`,
[page.url, page.title, page.body, JSON.stringify(page.provenance)]
);
await replaceDocChunks(client, page);
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
async function searchDocPages(question, limit = 8, sources = []) {
const words = queryWords(question);
if (!words.length) return [];
const search = words.join(' OR ');
const wantedSources = Array.isArray(sources) ? sources.map(String).filter(Boolean) : [];
const { rows } = await pool.query(
`WITH query AS (SELECT websearch_to_tsquery('english', $1) AS value)
SELECT c.title, c.url, c.section, c.source, c.authority, c.body, c.chunk_index, p.provenance,
ts_rank_cd(search_vector, query.value, 32) AS rank
FROM doc_chunks c JOIN doc_pages p ON p.url=c.url CROSS JOIN query
WHERE search_vector @@ query.value
AND (cardinality($3::text[]) = 0 OR source = ANY($3::text[]))
ORDER BY (ts_rank_cd(search_vector, query.value, 32) * (1 + authority * 0.06)) DESC,
authority DESC,
chunk_index ASC
LIMIT $2`,
[search, limit, wantedSources]
);
return rows;
}
async function markSourceSynced(sourceKey, itemCount) {
if (!sourceKey) return;
await pool.query(
`INSERT INTO source_sync (source_key, synced_at, item_count)
VALUES ($1, NOW(), $2)
ON CONFLICT (source_key)
DO UPDATE SET synced_at = NOW(), item_count = $2`,
[String(sourceKey), Number(itemCount) || 0]
);
}
async function sourceNeedsSync(sourceKey, maxAgeMs = 24 * 60 * 60 * 1000) {
const { rows } = await pool.query(
`SELECT synced_at FROM source_sync WHERE source_key = $1`,
[String(sourceKey)]
);
const at = rows[0]?.synced_at ? new Date(rows[0].synced_at).getTime() : 0;
return !at || Date.now() - at >= maxAgeMs;
}
async function saveRating(threadId, userId, helped) {
if (!threadId || !userId) return;
await pool.query(
`INSERT INTO ratings (thread_id, user_id, helped)
VALUES ($1, $2, $3)
ON CONFLICT (thread_id) DO UPDATE SET user_id = $2, helped = $3, created_at = NOW()`,
[String(threadId), String(userId), Boolean(helped)]
);
}
async function ratingCounts() {
const { rows } = await pool.query(
`SELECT
count(*) FILTER (WHERE helped)::int AS yes,
count(*) FILTER (WHERE NOT helped)::int AS no
FROM ratings`
);
return { yes: rows[0]?.yes || 0, no: rows[0]?.no || 0 };
}
async function countPagesLike(prefix) {
const { rows } = await pool.query(`SELECT count(*)::int AS n FROM doc_pages WHERE url LIKE $1`, [`${prefix}%`]);
return rows[0].n;
}
async function saveRelease({ tag, name, body, publishedAt }) {
if (!tag) return;
await pool.query(
`INSERT INTO releases (tag, name, body, published_at)
VALUES ($1, $2, $3, $4)
ON CONFLICT (tag) DO UPDATE SET name = $2, body = $3, published_at = $4`,
[tag, String(name || ''), String(body || '').slice(0, 800), publishedAt || null]
);
}
async function searchReleases(question, limit = 1) {
const words = queryWords(question);
if (!words.length) return [];
const clauses = words.map((_, i) => `(name || ' ' || body) ILIKE $${i + 1}`);
const params = words.map((word) => `%${word}%`);
params.push(limit);
const { rows } = await pool.query(
`SELECT tag, name, body FROM releases
WHERE ${clauses.join(' OR ')}
ORDER BY published_at DESC NULLS LAST
LIMIT $${params.length}`,
params
);
return rows;
}
async function saveIssueThread(issueNumber, threadId) {
const number = Number(issueNumber);
const thread = String(threadId || '');
if (!number || !thread) return;
await pool.query(
`INSERT INTO issue_threads (issue_number, thread_id) VALUES ($1, $2)
ON CONFLICT DO NOTHING`,
[number, thread]
);
}
async function listIssueThreads() {
const { rows } = await pool.query(
`SELECT issue_number, thread_id FROM issue_threads ORDER BY issue_number`
);
return rows;
}
async function shutdown() {
await pool.end();
console.log('[DB] Pool closed');
}
module.exports = {
pool,
initSchema,
upsertThread,
createEscalation,
getPendingEscalation,
resolveEscalation,
reopenEscalation,
getAllPendingEscalations,
addKnowledge,
searchKnowledge,
saveDocPage,
searchDocPages,
markSourceSynced,
sourceNeedsSync,
countPagesLike,
saveRating,
ratingCounts,
saveRelease,
searchReleases,
saveIssueThread,
listIssueThreads,
sslFor,
shutdown,
};