brains-mcp 1.61.2
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/README.md +95 -0
- package/dist/access/projectShares.d.ts +48 -0
- package/dist/access/projectShares.d.ts.map +1 -0
- package/dist/access/projectShares.js +203 -0
- package/dist/access/projectShares.js.map +1 -0
- package/dist/access/sharedInstructionsNote.d.ts +37 -0
- package/dist/access/sharedInstructionsNote.d.ts.map +1 -0
- package/dist/access/sharedInstructionsNote.js +76 -0
- package/dist/access/sharedInstructionsNote.js.map +1 -0
- package/dist/admin/seedUsers.d.ts +23 -0
- package/dist/admin/seedUsers.d.ts.map +1 -0
- package/dist/admin/seedUsers.js +168 -0
- package/dist/admin/seedUsers.js.map +1 -0
- package/dist/api/openapi.d.ts +2 -0
- package/dist/api/openapi.d.ts.map +1 -0
- package/dist/api/openapi.js +942 -0
- package/dist/api/openapi.js.map +1 -0
- package/dist/auth/clientStore.d.ts +43 -0
- package/dist/auth/clientStore.d.ts.map +1 -0
- package/dist/auth/clientStore.js +168 -0
- package/dist/auth/clientStore.js.map +1 -0
- package/dist/auth/deleteAccount.d.ts +34 -0
- package/dist/auth/deleteAccount.d.ts.map +1 -0
- package/dist/auth/deleteAccount.js +203 -0
- package/dist/auth/deleteAccount.js.map +1 -0
- package/dist/auth/oauth.d.ts +118 -0
- package/dist/auth/oauth.d.ts.map +1 -0
- package/dist/auth/oauth.js +491 -0
- package/dist/auth/oauth.js.map +1 -0
- package/dist/auth/patStore.d.ts +87 -0
- package/dist/auth/patStore.d.ts.map +1 -0
- package/dist/auth/patStore.js +308 -0
- package/dist/auth/patStore.js.map +1 -0
- package/dist/auth/supabase.d.ts +15 -0
- package/dist/auth/supabase.d.ts.map +1 -0
- package/dist/auth/supabase.js +66 -0
- package/dist/auth/supabase.js.map +1 -0
- package/dist/auth/tokenStore.d.ts +73 -0
- package/dist/auth/tokenStore.d.ts.map +1 -0
- package/dist/auth/tokenStore.js +261 -0
- package/dist/auth/tokenStore.js.map +1 -0
- package/dist/backup/drive.d.ts +24 -0
- package/dist/backup/drive.d.ts.map +1 -0
- package/dist/backup/drive.js +70 -0
- package/dist/backup/drive.js.map +1 -0
- package/dist/billing/tierStore.d.ts +59 -0
- package/dist/billing/tierStore.d.ts.map +1 -0
- package/dist/billing/tierStore.js +152 -0
- package/dist/billing/tierStore.js.map +1 -0
- package/dist/digest/activityDigest.d.ts +32 -0
- package/dist/digest/activityDigest.d.ts.map +1 -0
- package/dist/digest/activityDigest.js +299 -0
- package/dist/digest/activityDigest.js.map +1 -0
- package/dist/digest/digestCron.d.ts +40 -0
- package/dist/digest/digestCron.d.ts.map +1 -0
- package/dist/digest/digestCron.js +146 -0
- package/dist/digest/digestCron.js.map +1 -0
- package/dist/digest/digestPreferences.d.ts +25 -0
- package/dist/digest/digestPreferences.d.ts.map +1 -0
- package/dist/digest/digestPreferences.js +79 -0
- package/dist/digest/digestPreferences.js.map +1 -0
- package/dist/digest/emailSender.d.ts +20 -0
- package/dist/digest/emailSender.d.ts.map +1 -0
- package/dist/digest/emailSender.js +35 -0
- package/dist/digest/emailSender.js.map +1 -0
- package/dist/drive/client.d.ts +43 -0
- package/dist/drive/client.d.ts.map +1 -0
- package/dist/drive/client.js +282 -0
- package/dist/drive/client.js.map +1 -0
- package/dist/drive/errors.d.ts +27 -0
- package/dist/drive/errors.d.ts.map +1 -0
- package/dist/drive/errors.js +51 -0
- package/dist/drive/errors.js.map +1 -0
- package/dist/drive/fileCache.d.ts +22 -0
- package/dist/drive/fileCache.d.ts.map +1 -0
- package/dist/drive/fileCache.js +57 -0
- package/dist/drive/fileCache.js.map +1 -0
- package/dist/governance/accessControl.d.ts +87 -0
- package/dist/governance/accessControl.d.ts.map +1 -0
- package/dist/governance/accessControl.js +179 -0
- package/dist/governance/accessControl.js.map +1 -0
- package/dist/html/components.d.ts +8 -0
- package/dist/html/components.d.ts.map +1 -0
- package/dist/html/components.js +19 -0
- package/dist/html/components.js.map +1 -0
- package/dist/html/layout.d.ts +7 -0
- package/dist/html/layout.d.ts.map +1 -0
- package/dist/html/layout.js +24 -0
- package/dist/html/layout.js.map +1 -0
- package/dist/html/metricsUi.d.ts +8 -0
- package/dist/html/metricsUi.d.ts.map +1 -0
- package/dist/html/metricsUi.js +271 -0
- package/dist/html/metricsUi.js.map +1 -0
- package/dist/httpServer.d.ts +19 -0
- package/dist/httpServer.d.ts.map +1 -0
- package/dist/httpServer.js +3913 -0
- package/dist/httpServer.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +40 -0
- package/dist/index.js.map +1 -0
- package/dist/jobs/asyncJobRegistry.d.ts +30 -0
- package/dist/jobs/asyncJobRegistry.d.ts.map +1 -0
- package/dist/jobs/asyncJobRegistry.js +70 -0
- package/dist/jobs/asyncJobRegistry.js.map +1 -0
- package/dist/observability/metricsQuery.d.ts +41 -0
- package/dist/observability/metricsQuery.d.ts.map +1 -0
- package/dist/observability/metricsQuery.js +106 -0
- package/dist/observability/metricsQuery.js.map +1 -0
- package/dist/observability/relevanceSignal.d.ts +26 -0
- package/dist/observability/relevanceSignal.d.ts.map +1 -0
- package/dist/observability/relevanceSignal.js +95 -0
- package/dist/observability/relevanceSignal.js.map +1 -0
- package/dist/observability/requestEvents.d.ts +31 -0
- package/dist/observability/requestEvents.d.ts.map +1 -0
- package/dist/observability/requestEvents.js +54 -0
- package/dist/observability/requestEvents.js.map +1 -0
- package/dist/observability/slowQueryAlert.d.ts +25 -0
- package/dist/observability/slowQueryAlert.d.ts.map +1 -0
- package/dist/observability/slowQueryAlert.js +67 -0
- package/dist/observability/slowQueryAlert.js.map +1 -0
- package/dist/observability/zeroResultTriage.d.ts +22 -0
- package/dist/observability/zeroResultTriage.d.ts.map +1 -0
- package/dist/observability/zeroResultTriage.js +116 -0
- package/dist/observability/zeroResultTriage.js.map +1 -0
- package/dist/search/embeddings.d.ts +31 -0
- package/dist/search/embeddings.d.ts.map +1 -0
- package/dist/search/embeddings.js +91 -0
- package/dist/search/embeddings.js.map +1 -0
- package/dist/search/fileWatcher.d.ts +28 -0
- package/dist/search/fileWatcher.d.ts.map +1 -0
- package/dist/search/fileWatcher.js +103 -0
- package/dist/search/fileWatcher.js.map +1 -0
- package/dist/search/hotQueryCache.d.ts +31 -0
- package/dist/search/hotQueryCache.d.ts.map +1 -0
- package/dist/search/hotQueryCache.js +103 -0
- package/dist/search/hotQueryCache.js.map +1 -0
- package/dist/search/postgresIndex.d.ts +439 -0
- package/dist/search/postgresIndex.d.ts.map +1 -0
- package/dist/search/postgresIndex.js +1610 -0
- package/dist/search/postgresIndex.js.map +1 -0
- package/dist/search/sqliteIndex.d.ts +66 -0
- package/dist/search/sqliteIndex.d.ts.map +1 -0
- package/dist/search/sqliteIndex.js +342 -0
- package/dist/search/sqliteIndex.js.map +1 -0
- package/dist/server.d.ts +43 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +532 -0
- package/dist/server.js.map +1 -0
- package/dist/session/bundle.d.ts +53 -0
- package/dist/session/bundle.d.ts.map +1 -0
- package/dist/session/bundle.js +97 -0
- package/dist/session/bundle.js.map +1 -0
- package/dist/storage/client.d.ts +157 -0
- package/dist/storage/client.d.ts.map +1 -0
- package/dist/storage/client.js +825 -0
- package/dist/storage/client.js.map +1 -0
- package/dist/tools/addWebhookPattern.d.ts +15 -0
- package/dist/tools/addWebhookPattern.d.ts.map +1 -0
- package/dist/tools/addWebhookPattern.js +15 -0
- package/dist/tools/addWebhookPattern.js.map +1 -0
- package/dist/tools/appendLog.d.ts +18 -0
- package/dist/tools/appendLog.d.ts.map +1 -0
- package/dist/tools/appendLog.js +18 -0
- package/dist/tools/appendLog.js.map +1 -0
- package/dist/tools/auditIndex.d.ts +15 -0
- package/dist/tools/auditIndex.d.ts.map +1 -0
- package/dist/tools/auditIndex.js +165 -0
- package/dist/tools/auditIndex.js.map +1 -0
- package/dist/tools/batchDeletePages.d.ts +18 -0
- package/dist/tools/batchDeletePages.d.ts.map +1 -0
- package/dist/tools/batchDeletePages.js +143 -0
- package/dist/tools/batchDeletePages.js.map +1 -0
- package/dist/tools/coerce.d.ts +3 -0
- package/dist/tools/coerce.d.ts.map +1 -0
- package/dist/tools/coerce.js +12 -0
- package/dist/tools/coerce.js.map +1 -0
- package/dist/tools/createPage.d.ts +24 -0
- package/dist/tools/createPage.d.ts.map +1 -0
- package/dist/tools/createPage.js +99 -0
- package/dist/tools/createPage.js.map +1 -0
- package/dist/tools/createPages.d.ts +51 -0
- package/dist/tools/createPages.d.ts.map +1 -0
- package/dist/tools/createPages.js +91 -0
- package/dist/tools/createPages.js.map +1 -0
- package/dist/tools/deletePage.d.ts +19 -0
- package/dist/tools/deletePage.d.ts.map +1 -0
- package/dist/tools/deletePage.js +127 -0
- package/dist/tools/deletePage.js.map +1 -0
- package/dist/tools/exportWiki.d.ts +48 -0
- package/dist/tools/exportWiki.d.ts.map +1 -0
- package/dist/tools/exportWiki.js +233 -0
- package/dist/tools/exportWiki.js.map +1 -0
- package/dist/tools/extractLinks.d.ts +14 -0
- package/dist/tools/extractLinks.d.ts.map +1 -0
- package/dist/tools/extractLinks.js +25 -0
- package/dist/tools/extractLinks.js.map +1 -0
- package/dist/tools/generateClaudeInstructions.d.ts +27 -0
- package/dist/tools/generateClaudeInstructions.d.ts.map +1 -0
- package/dist/tools/generateClaudeInstructions.js +263 -0
- package/dist/tools/generateClaudeInstructions.js.map +1 -0
- package/dist/tools/generateGptInstructions.d.ts +27 -0
- package/dist/tools/generateGptInstructions.d.ts.map +1 -0
- package/dist/tools/generateGptInstructions.js +208 -0
- package/dist/tools/generateGptInstructions.js.map +1 -0
- package/dist/tools/getBacklinks.d.ts +12 -0
- package/dist/tools/getBacklinks.d.ts.map +1 -0
- package/dist/tools/getBacklinks.js +13 -0
- package/dist/tools/getBacklinks.js.map +1 -0
- package/dist/tools/getRelated.d.ts +12 -0
- package/dist/tools/getRelated.d.ts.map +1 -0
- package/dist/tools/getRelated.js +13 -0
- package/dist/tools/getRelated.js.map +1 -0
- package/dist/tools/healthCheck.d.ts +5 -0
- package/dist/tools/healthCheck.d.ts.map +1 -0
- package/dist/tools/healthCheck.js +167 -0
- package/dist/tools/healthCheck.js.map +1 -0
- package/dist/tools/importBrainSource.d.ts +36 -0
- package/dist/tools/importBrainSource.d.ts.map +1 -0
- package/dist/tools/importBrainSource.js +314 -0
- package/dist/tools/importBrainSource.js.map +1 -0
- package/dist/tools/importWikiZip.d.ts +60 -0
- package/dist/tools/importWikiZip.d.ts.map +1 -0
- package/dist/tools/importWikiZip.js +379 -0
- package/dist/tools/importWikiZip.js.map +1 -0
- package/dist/tools/initProject.d.ts +34 -0
- package/dist/tools/initProject.d.ts.map +1 -0
- package/dist/tools/initProject.js +443 -0
- package/dist/tools/initProject.js.map +1 -0
- package/dist/tools/instructionContext.d.ts +60 -0
- package/dist/tools/instructionContext.d.ts.map +1 -0
- package/dist/tools/instructionContext.js +210 -0
- package/dist/tools/instructionContext.js.map +1 -0
- package/dist/tools/listArchivedPages.d.ts +17 -0
- package/dist/tools/listArchivedPages.d.ts.map +1 -0
- package/dist/tools/listArchivedPages.js +51 -0
- package/dist/tools/listArchivedPages.js.map +1 -0
- package/dist/tools/listPages.d.ts +28 -0
- package/dist/tools/listPages.d.ts.map +1 -0
- package/dist/tools/listPages.js +166 -0
- package/dist/tools/listPages.js.map +1 -0
- package/dist/tools/listWebhooks.d.ts +7 -0
- package/dist/tools/listWebhooks.d.ts.map +1 -0
- package/dist/tools/listWebhooks.js +10 -0
- package/dist/tools/listWebhooks.js.map +1 -0
- package/dist/tools/movePage.d.ts +18 -0
- package/dist/tools/movePage.d.ts.map +1 -0
- package/dist/tools/movePage.js +182 -0
- package/dist/tools/movePage.js.map +1 -0
- package/dist/tools/pageHistory.d.ts +36 -0
- package/dist/tools/pageHistory.d.ts.map +1 -0
- package/dist/tools/pageHistory.js +60 -0
- package/dist/tools/pageHistory.js.map +1 -0
- package/dist/tools/patchPage.d.ts +141 -0
- package/dist/tools/patchPage.d.ts.map +1 -0
- package/dist/tools/patchPage.js +287 -0
- package/dist/tools/patchPage.js.map +1 -0
- package/dist/tools/pullSessionBundle.d.ts +34 -0
- package/dist/tools/pullSessionBundle.d.ts.map +1 -0
- package/dist/tools/pullSessionBundle.js +432 -0
- package/dist/tools/pullSessionBundle.js.map +1 -0
- package/dist/tools/purgeWiki.d.ts +23 -0
- package/dist/tools/purgeWiki.d.ts.map +1 -0
- package/dist/tools/purgeWiki.js +113 -0
- package/dist/tools/purgeWiki.js.map +1 -0
- package/dist/tools/pushSessionBundle.d.ts +139 -0
- package/dist/tools/pushSessionBundle.d.ts.map +1 -0
- package/dist/tools/pushSessionBundle.js +282 -0
- package/dist/tools/pushSessionBundle.js.map +1 -0
- package/dist/tools/readGovernanceAudit.d.ts +17 -0
- package/dist/tools/readGovernanceAudit.d.ts.map +1 -0
- package/dist/tools/readGovernanceAudit.js +21 -0
- package/dist/tools/readGovernanceAudit.js.map +1 -0
- package/dist/tools/readPage.d.ts +13 -0
- package/dist/tools/readPage.d.ts.map +1 -0
- package/dist/tools/readPage.js +46 -0
- package/dist/tools/readPage.js.map +1 -0
- package/dist/tools/removeWebhook.d.ts +14 -0
- package/dist/tools/removeWebhook.d.ts.map +1 -0
- package/dist/tools/removeWebhook.js +12 -0
- package/dist/tools/removeWebhook.js.map +1 -0
- package/dist/tools/resolveProject.d.ts +58 -0
- package/dist/tools/resolveProject.d.ts.map +1 -0
- package/dist/tools/resolveProject.js +175 -0
- package/dist/tools/resolveProject.js.map +1 -0
- package/dist/tools/restorePage.d.ts +47 -0
- package/dist/tools/restorePage.d.ts.map +1 -0
- package/dist/tools/restorePage.js +111 -0
- package/dist/tools/restorePage.js.map +1 -0
- package/dist/tools/searchPages.d.ts +28 -0
- package/dist/tools/searchPages.d.ts.map +1 -0
- package/dist/tools/searchPages.js +372 -0
- package/dist/tools/searchPages.js.map +1 -0
- package/dist/tools/setWebhook.d.ts +30 -0
- package/dist/tools/setWebhook.d.ts.map +1 -0
- package/dist/tools/setWebhook.js +30 -0
- package/dist/tools/setWebhook.js.map +1 -0
- package/dist/tools/shareProject.d.ts +25 -0
- package/dist/tools/shareProject.d.ts.map +1 -0
- package/dist/tools/shareProject.js +56 -0
- package/dist/tools/shareProject.js.map +1 -0
- package/dist/tools/suggestLinks.d.ts +29 -0
- package/dist/tools/suggestLinks.d.ts.map +1 -0
- package/dist/tools/suggestLinks.js +214 -0
- package/dist/tools/suggestLinks.js.map +1 -0
- package/dist/tools/updatePage.d.ts +27 -0
- package/dist/tools/updatePage.d.ts.map +1 -0
- package/dist/tools/updatePage.js +118 -0
- package/dist/tools/updatePage.js.map +1 -0
- package/dist/webhooks/dispatch.d.ts +49 -0
- package/dist/webhooks/dispatch.d.ts.map +1 -0
- package/dist/webhooks/dispatch.js +159 -0
- package/dist/webhooks/dispatch.js.map +1 -0
- package/dist/webhooks/webhookRules.d.ts +34 -0
- package/dist/webhooks/webhookRules.d.ts.map +1 -0
- package/dist/webhooks/webhookRules.js +129 -0
- package/dist/webhooks/webhookRules.js.map +1 -0
- package/dist/wiki/frontmatter.d.ts +45 -0
- package/dist/wiki/frontmatter.d.ts.map +1 -0
- package/dist/wiki/frontmatter.js +68 -0
- package/dist/wiki/frontmatter.js.map +1 -0
- package/dist/wiki/index.d.ts +22 -0
- package/dist/wiki/index.d.ts.map +1 -0
- package/dist/wiki/index.js +203 -0
- package/dist/wiki/index.js.map +1 -0
- package/dist/wiki/ingestPolicy.d.ts +18 -0
- package/dist/wiki/ingestPolicy.d.ts.map +1 -0
- package/dist/wiki/ingestPolicy.js +64 -0
- package/dist/wiki/ingestPolicy.js.map +1 -0
- package/dist/wiki/layoutLint.d.ts +51 -0
- package/dist/wiki/layoutLint.d.ts.map +1 -0
- package/dist/wiki/layoutLint.js +146 -0
- package/dist/wiki/layoutLint.js.map +1 -0
- package/dist/wiki/links.d.ts +6 -0
- package/dist/wiki/links.d.ts.map +1 -0
- package/dist/wiki/links.js +45 -0
- package/dist/wiki/links.js.map +1 -0
- package/dist/wiki/log.d.ts +58 -0
- package/dist/wiki/log.d.ts.map +1 -0
- package/dist/wiki/log.js +126 -0
- package/dist/wiki/log.js.map +1 -0
- package/dist/wiki/path.d.ts +8 -0
- package/dist/wiki/path.d.ts.map +1 -0
- package/dist/wiki/path.js +25 -0
- package/dist/wiki/path.js.map +1 -0
- package/dist/wiki/storageLimits.d.ts +163 -0
- package/dist/wiki/storageLimits.d.ts.map +1 -0
- package/dist/wiki/storageLimits.js +281 -0
- package/dist/wiki/storageLimits.js.map +1 -0
- package/package.json +81 -0
|
@@ -0,0 +1,1610 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* postgresIndex.ts
|
|
3
|
+
*
|
|
4
|
+
* Supabase Postgres-backed search and listing index for wiki pages.
|
|
5
|
+
*
|
|
6
|
+
* Mirrors the SqliteSearchIndex public surface so callers can swap between
|
|
7
|
+
* backends without changing their code. Uses the Supabase PostgREST REST API
|
|
8
|
+
* (no extra SDK dependency — same raw-fetch pattern as SupabaseStorageClient).
|
|
9
|
+
*
|
|
10
|
+
* Auto-selected when STORAGE_BACKEND=supabase. Override with SEARCH_BACKEND=sqlite|scan.
|
|
11
|
+
*
|
|
12
|
+
* Table schema: scripts/supabase-pages-index.sql
|
|
13
|
+
*/
|
|
14
|
+
import { extractWikiLinks } from "../wiki/links.js";
|
|
15
|
+
import { generateEmbedding, buildEmbeddingInput, isEmbeddingEnabled } from "./embeddings.js";
|
|
16
|
+
// ─── Singleton management ────────────────────────────────────────────────────
|
|
17
|
+
let instance = null;
|
|
18
|
+
export function getPostgresSearchIndex() {
|
|
19
|
+
if (!instance)
|
|
20
|
+
instance = new PostgresSearchIndex();
|
|
21
|
+
return instance;
|
|
22
|
+
}
|
|
23
|
+
// ─── Main class ──────────────────────────────────────────────────────────────
|
|
24
|
+
export class PostgresSearchIndex {
|
|
25
|
+
supabaseUrl;
|
|
26
|
+
serviceRoleKey;
|
|
27
|
+
baseUrl;
|
|
28
|
+
linksBaseUrl;
|
|
29
|
+
archivedPagesBaseUrl;
|
|
30
|
+
eventsBaseUrl;
|
|
31
|
+
headers;
|
|
32
|
+
constructor() {
|
|
33
|
+
this.supabaseUrl = (process.env.SUPABASE_URL ?? "").trim().replace(/\/$/, "");
|
|
34
|
+
this.serviceRoleKey = (process.env.SUPABASE_SERVICE_ROLE_KEY ?? "").trim();
|
|
35
|
+
this.baseUrl = `${this.supabaseUrl}/rest/v1/pages`;
|
|
36
|
+
this.linksBaseUrl = `${this.supabaseUrl}/rest/v1/wiki_links`;
|
|
37
|
+
this.archivedPagesBaseUrl = `${this.supabaseUrl}/rest/v1/archived_pages`;
|
|
38
|
+
this.eventsBaseUrl = `${this.supabaseUrl}/rest/v1/wiki_events`;
|
|
39
|
+
this.headers = {
|
|
40
|
+
"content-type": "application/json",
|
|
41
|
+
"apikey": this.serviceRoleKey,
|
|
42
|
+
"authorization": `Bearer ${this.serviceRoleKey}`,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
isConfigured() {
|
|
46
|
+
return this.supabaseUrl.length > 0 && this.serviceRoleKey.length > 0;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Lightweight connectivity check. Uses a HEAD + count=exact to verify the
|
|
50
|
+
* Supabase REST endpoint is reachable without fetching any row data.
|
|
51
|
+
*/
|
|
52
|
+
async ping(userId = "") {
|
|
53
|
+
if (!this.isConfigured())
|
|
54
|
+
return { ok: false, error: "not configured" };
|
|
55
|
+
try {
|
|
56
|
+
const params = new URLSearchParams({ select: "name" });
|
|
57
|
+
if (userId)
|
|
58
|
+
params.set("user_id", `eq.${userId}`);
|
|
59
|
+
const res = await fetch(`${this.baseUrl}?${params}`, {
|
|
60
|
+
method: "HEAD",
|
|
61
|
+
headers: { ...this.headers, prefer: "count=exact" },
|
|
62
|
+
});
|
|
63
|
+
if (!res.ok)
|
|
64
|
+
return { ok: false, error: `HTTP ${res.status}` };
|
|
65
|
+
const range = res.headers.get("content-range");
|
|
66
|
+
const rowCount = range ? parseInt(range.split("/")[1] ?? "", 10) : undefined;
|
|
67
|
+
return { ok: true, ...(Number.isFinite(rowCount) ? { rowCount } : {}) };
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Count active (non-archived) pages for a user. Returns 0 when not configured
|
|
75
|
+
* or when the request fails. Uses HEAD + count=exact to avoid fetching rows.
|
|
76
|
+
*/
|
|
77
|
+
async countActivePages(userId) {
|
|
78
|
+
if (!this.isConfigured() || !userId)
|
|
79
|
+
return 0;
|
|
80
|
+
try {
|
|
81
|
+
const params = new URLSearchParams({ select: "name" });
|
|
82
|
+
params.set("user_id", `eq.${userId}`);
|
|
83
|
+
params.set("status", "neq.archived");
|
|
84
|
+
const res = await fetch(`${this.baseUrl}?${params}`, {
|
|
85
|
+
method: "HEAD",
|
|
86
|
+
headers: { ...this.headers, prefer: "count=exact" },
|
|
87
|
+
});
|
|
88
|
+
if (!res.ok)
|
|
89
|
+
return 0;
|
|
90
|
+
const range = res.headers.get("content-range");
|
|
91
|
+
if (!range)
|
|
92
|
+
return 0;
|
|
93
|
+
const count = parseInt(range.split("/")[1] ?? "", 10);
|
|
94
|
+
return Number.isFinite(count) ? count : 0;
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return 0;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Append one activity event.
|
|
102
|
+
*
|
|
103
|
+
* Replaces the read-modify-write of log.md that every page write used to
|
|
104
|
+
* perform: Storage has no append primitive, so appending to a 460 KB file
|
|
105
|
+
* meant moving ~920 KB and took 2.6-3.6s measured against production. An
|
|
106
|
+
* indexed insert is O(1) (GH #417).
|
|
107
|
+
*
|
|
108
|
+
* Throws on failure so callers can decide — the write paths log a warning
|
|
109
|
+
* rather than failing the page write, but a silent no-op is what let the
|
|
110
|
+
* old log.md problem go unnoticed, so the error is not swallowed here.
|
|
111
|
+
*/
|
|
112
|
+
async appendEvent(input) {
|
|
113
|
+
if (!this.isConfigured())
|
|
114
|
+
throw new Error("Postgres not configured");
|
|
115
|
+
const res = await fetch(this.eventsBaseUrl, {
|
|
116
|
+
method: "POST",
|
|
117
|
+
headers: { ...this.headers, prefer: "return=minimal" },
|
|
118
|
+
body: JSON.stringify({
|
|
119
|
+
user_id: input.userId,
|
|
120
|
+
type: input.type,
|
|
121
|
+
title: input.title,
|
|
122
|
+
...(input.detail !== undefined ? { detail: input.detail } : {}),
|
|
123
|
+
}),
|
|
124
|
+
});
|
|
125
|
+
if (!res.ok) {
|
|
126
|
+
const body = await res.text().catch(() => "");
|
|
127
|
+
throw new Error(`wiki_events insert failed (${res.status}): ${body.slice(0, 200)}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Read activity events for one user since a cutoff date (inclusive,
|
|
132
|
+
* `YYYY-MM-DD`). Newest first. Returns null when Postgres is unavailable so
|
|
133
|
+
* the caller can fall back to parsing log.md rather than silently reporting
|
|
134
|
+
* no activity.
|
|
135
|
+
*/
|
|
136
|
+
async listEvents(userId, sinceDate, limit = 5000) {
|
|
137
|
+
if (!this.isConfigured())
|
|
138
|
+
return null;
|
|
139
|
+
try {
|
|
140
|
+
const params = new URLSearchParams();
|
|
141
|
+
params.set("select", "ts,type,title");
|
|
142
|
+
params.set("user_id", `eq.${userId}`);
|
|
143
|
+
params.set("ts", `gte.${sinceDate}`);
|
|
144
|
+
params.set("order", "ts.desc");
|
|
145
|
+
params.set("limit", String(limit));
|
|
146
|
+
const res = await fetch(`${this.eventsBaseUrl}?${params}`, { headers: this.headers });
|
|
147
|
+
if (!res.ok)
|
|
148
|
+
return null;
|
|
149
|
+
const rows = (await res.json());
|
|
150
|
+
return rows.map((r) => ({
|
|
151
|
+
date: r.ts.slice(0, 10),
|
|
152
|
+
type: String(r.type ?? "").toLowerCase(),
|
|
153
|
+
title: r.title,
|
|
154
|
+
}));
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Fetch every row matching `params`, following limit/offset until exhausted.
|
|
162
|
+
*
|
|
163
|
+
* PostgREST clamps a result set to its configured max-rows (1000 here) and
|
|
164
|
+
* returns 200 OK. A truncated response is byte-identical to a complete one —
|
|
165
|
+
* no error, no flag — so any "fetch everything then aggregate in JS" call site
|
|
166
|
+
* silently computes on partial input and returns a confident wrong answer.
|
|
167
|
+
* That has now happened four times: the bundle folder filter undercounted 164
|
|
168
|
+
* against 213 (#432), listProjectInventory lost a whole project whose pages
|
|
169
|
+
* all fell in the truncated tail (#445), and audit_index reported ~174
|
|
170
|
+
* fabricated unindexed pages by diffing a capped snapshot against full
|
|
171
|
+
* storage (#449).
|
|
172
|
+
*
|
|
173
|
+
* Callers that need a whole table must route through here rather than issuing
|
|
174
|
+
* an unbounded request. Returns null on transport/HTTP failure, which callers
|
|
175
|
+
* must treat as "unknown" — never as "empty", since that is exactly how a
|
|
176
|
+
* partial read becomes a wrong answer.
|
|
177
|
+
*
|
|
178
|
+
* Pages by keyset (`name > last seen`), not by offset, and always sorts by
|
|
179
|
+
* `name.asc`. Two reasons, both of which offset paging gets wrong:
|
|
180
|
+
*
|
|
181
|
+
* - **Never infer the end from a short page.** `rows.length < pageSize` is
|
|
182
|
+
* only "the end" if pageSize happens to be ≤ the server's max-rows. That
|
|
183
|
+
* is a Supabase dashboard setting on a different system, and staging and
|
|
184
|
+
* prod are configured independently. If max-rows is ever lowered below
|
|
185
|
+
* pageSize, the first page comes back short and an offset walk stops
|
|
186
|
+
* there — silently returning a truncated list from the very helper written
|
|
187
|
+
* to prevent that, with no round-number tell to notice it by. Keyset walks
|
|
188
|
+
* until a page comes back empty, so it is correct whatever the cap is.
|
|
189
|
+
* - **Offset is not stable against concurrent writes.** A page created with
|
|
190
|
+
* an earlier-sorting name mid-walk shifts every later row down by one and
|
|
191
|
+
* drops exactly one real page from the snapshot. During an audit that page
|
|
192
|
+
* then looks unindexed, and cleanup would "repair" it by overwriting its
|
|
193
|
+
* DB row from Storage. Keyset resumes from a value, not a position, so an
|
|
194
|
+
* insert behind the cursor cannot displace anything.
|
|
195
|
+
*
|
|
196
|
+
* Requires `name` in the select — it is the cursor. Returns null if it is
|
|
197
|
+
* missing rather than looping.
|
|
198
|
+
*/
|
|
199
|
+
async fetchAllRows(params, pageSize = 1000) {
|
|
200
|
+
if (!Number.isInteger(pageSize) || pageSize < 1) {
|
|
201
|
+
throw new RangeError(`fetchAllRows: pageSize must be a positive integer, got ${pageSize}`);
|
|
202
|
+
}
|
|
203
|
+
const out = [];
|
|
204
|
+
let after;
|
|
205
|
+
for (;;) {
|
|
206
|
+
// Copy per iteration: never mutate the caller's object, and never let the
|
|
207
|
+
// previous page's cursor accumulate alongside the new one.
|
|
208
|
+
const p = new URLSearchParams(params);
|
|
209
|
+
p.set("order", "name.asc");
|
|
210
|
+
p.set("limit", String(pageSize));
|
|
211
|
+
if (after !== undefined)
|
|
212
|
+
p.append("name", `gt.${after}`);
|
|
213
|
+
let rows;
|
|
214
|
+
try {
|
|
215
|
+
const res = await fetch(`${this.baseUrl}?${p}`, { headers: this.headers });
|
|
216
|
+
if (!res.ok)
|
|
217
|
+
return null;
|
|
218
|
+
const body = await res.json();
|
|
219
|
+
// A 200 carrying a non-array body (proxy error page, gateway
|
|
220
|
+
// interstitial) must be "unknown", not a spread that throws. Parsing
|
|
221
|
+
// stays inside the try for the same reason: a body truncated mid-stream
|
|
222
|
+
// rejects here, and the contract is null-on-failure.
|
|
223
|
+
if (!Array.isArray(body))
|
|
224
|
+
return null;
|
|
225
|
+
rows = body;
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
out.push(...rows);
|
|
231
|
+
if (rows.length === 0)
|
|
232
|
+
return out;
|
|
233
|
+
const last = rows[rows.length - 1].name;
|
|
234
|
+
if (typeof last !== "string" || last === after)
|
|
235
|
+
return null; // no cursor, or no progress
|
|
236
|
+
after = last;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Every indexed page name for a user, complete — not capped at max-rows.
|
|
241
|
+
*
|
|
242
|
+
* Exists because audit_index compares the index against storage, and a
|
|
243
|
+
* truncated index snapshot makes every missing row look like an unindexed
|
|
244
|
+
* page. Applies the same exclusions as listPages so the two agree on what
|
|
245
|
+
* counts as a page.
|
|
246
|
+
*
|
|
247
|
+
* Returns null if any page of the walk fails. Callers must not fall back to a
|
|
248
|
+
* partial list — an incomplete audit is worse than no audit, because it
|
|
249
|
+
* invites a "repair" of pages that were never broken.
|
|
250
|
+
*/
|
|
251
|
+
async listAllPageNames(userId = "") {
|
|
252
|
+
if (!this.isConfigured())
|
|
253
|
+
return null;
|
|
254
|
+
try {
|
|
255
|
+
const p = new URLSearchParams();
|
|
256
|
+
p.set("select", "name");
|
|
257
|
+
p.set("user_id", `eq.${userId}`);
|
|
258
|
+
p.append("name", "not.like.page-versions/%");
|
|
259
|
+
p.append("name", "not.in.(index.md,log.md)");
|
|
260
|
+
const rows = await this.fetchAllRows(p);
|
|
261
|
+
return rows ? rows.map((r) => r.name) : null;
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
// Defence in depth. audit_index's refuse-to-report-drift path keys off a
|
|
265
|
+
// null return; letting an exception escape would bypass it and surface a
|
|
266
|
+
// raw stack instead of "retry the audit".
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* One row per project: slug, page count, and the root readme's title/aliases.
|
|
272
|
+
*
|
|
273
|
+
* Two requests rather than one per project. Aliases come from the readme's
|
|
274
|
+
* frontmatter and are how a nickname reaches the right project — the
|
|
275
|
+
* cressida/nessie case, where the slug never appears in what the user typed.
|
|
276
|
+
*
|
|
277
|
+
* Anchored to `projects/<slug>/readme.md` exactly. A looser match picks up
|
|
278
|
+
* nested readmes — cetera-pds alone has nine — and reports one project many
|
|
279
|
+
* times (GH #430).
|
|
280
|
+
*/
|
|
281
|
+
async listProjectInventory(userId) {
|
|
282
|
+
if (!this.isConfigured())
|
|
283
|
+
return null;
|
|
284
|
+
try {
|
|
285
|
+
// Paginate. A `limit` above PostgREST's configured max-rows is silently
|
|
286
|
+
// clamped, so a single large request looks complete and is not: this wiki
|
|
287
|
+
// has 1,029 pages under projects/ against a 1,000-row cap, and
|
|
288
|
+
// retropie-pi4's 13 pages all fell in the truncated tail — the project
|
|
289
|
+
// vanished from the inventory entirely and resolved to nothing.
|
|
290
|
+
//
|
|
291
|
+
// Same failure this file's folder filter had (#432): fetch a capped list,
|
|
292
|
+
// aggregate client-side, get a confident wrong answer. Paginating keeps
|
|
293
|
+
// the aggregate honest at the cost of one request per 1,000 pages.
|
|
294
|
+
const fetchAll = (select, nameFilter) => {
|
|
295
|
+
const p = new URLSearchParams();
|
|
296
|
+
p.set("select", select);
|
|
297
|
+
p.set("user_id", `eq.${userId}`);
|
|
298
|
+
p.set("name", nameFilter);
|
|
299
|
+
return this.fetchAllRows(p);
|
|
300
|
+
};
|
|
301
|
+
const [nameRows, readmeRows] = await Promise.all([
|
|
302
|
+
fetchAll("name", "like.projects/%"),
|
|
303
|
+
fetchAll("name,title,aliases,summary", "like.projects/%/readme.md"),
|
|
304
|
+
]);
|
|
305
|
+
if (!nameRows || !readmeRows)
|
|
306
|
+
return null;
|
|
307
|
+
// A sub-project lives at projects/<parent>/projects/<child>/. That nested
|
|
308
|
+
// `projects/` segment is the marker: a plain subfolder cannot be used as
|
|
309
|
+
// one, because category folders carry readme.md too (cetera-pds has eight),
|
|
310
|
+
// so folder shape alone cannot tell a sub-project from canonical/ or
|
|
311
|
+
// decisions/ (GH #444).
|
|
312
|
+
//
|
|
313
|
+
// Child pages count toward the parent as well as themselves. They sit
|
|
314
|
+
// under the parent's prefix, so a folder-scoped bundle for the parent
|
|
315
|
+
// genuinely includes them — which is the reason nesting was chosen.
|
|
316
|
+
const counts = new Map();
|
|
317
|
+
const parentOf = new Map();
|
|
318
|
+
for (const r of nameRows) {
|
|
319
|
+
const parts = r.name.split("/");
|
|
320
|
+
if (parts.length < 3)
|
|
321
|
+
continue; // needs projects/<slug>/<something>
|
|
322
|
+
const parent = parts[1];
|
|
323
|
+
counts.set(parent, (counts.get(parent) ?? 0) + 1);
|
|
324
|
+
if (parts.length >= 5 && parts[2] === "projects") {
|
|
325
|
+
const childKey = `${parent}/${parts[3]}`;
|
|
326
|
+
counts.set(childKey, (counts.get(childKey) ?? 0) + 1);
|
|
327
|
+
parentOf.set(childKey, parent);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
// Aliases come from the readme's explicit `aliases:` frontmatter and
|
|
331
|
+
// nowhere else. They were previously inferred from tags, which cost three
|
|
332
|
+
// separate false-match bugs: `oio` on projects/geoffrey means "this is an
|
|
333
|
+
// OIO vehicle" (membership), and `state` on shop-shelf's readme names a
|
|
334
|
+
// document kind. Filtering tags that happen to be project slugs caught the
|
|
335
|
+
// first and missed the second, because the tag vocabulary simply does not
|
|
336
|
+
// encode the difference between "also called X" and "is about X" (#446).
|
|
337
|
+
//
|
|
338
|
+
// No fallback to tags when `aliases:` is absent — a silent fallback
|
|
339
|
+
// reintroduces the bug the moment someone adds a tag.
|
|
340
|
+
const meta = new Map();
|
|
341
|
+
for (const r of readmeRows) {
|
|
342
|
+
const parts = r.name.split("/");
|
|
343
|
+
// projects/<slug>/readme.md (3 segments) is a project root;
|
|
344
|
+
// projects/<parent>/projects/<child>/readme.md (5) is a sub-project.
|
|
345
|
+
// Anything else is a category folder's readme and must be ignored, or a
|
|
346
|
+
// single project reports itself many times.
|
|
347
|
+
const isRoot = parts.length === 3;
|
|
348
|
+
const isSub = parts.length === 5 && parts[2] === "projects";
|
|
349
|
+
if (!isRoot && !isSub)
|
|
350
|
+
continue;
|
|
351
|
+
const slug = isRoot ? parts[1] : `${parts[1]}/${parts[3]}`;
|
|
352
|
+
// A title equal to the path is generator noise, not a real title.
|
|
353
|
+
const title = r.title && !r.title.startsWith("projects/") ? r.title : undefined;
|
|
354
|
+
// A project naming itself is not an alias; it is already the slug.
|
|
355
|
+
const aliases = (r.aliases ?? []).filter((a) => a !== slug && !slug.endsWith(`/${a}`));
|
|
356
|
+
meta.set(slug, { title, aliases: aliases.length > 0 ? aliases : undefined });
|
|
357
|
+
}
|
|
358
|
+
return [...counts.entries()].map(([slug, pageCount]) => ({
|
|
359
|
+
slug,
|
|
360
|
+
pageCount,
|
|
361
|
+
...(parentOf.has(slug) ? { parentSlug: parentOf.get(slug) } : {}),
|
|
362
|
+
...meta.get(slug),
|
|
363
|
+
}));
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Full-text hits per project, for when the slug does not appear in what the
|
|
371
|
+
* user typed. Returns a slug -> hit-count map.
|
|
372
|
+
*/
|
|
373
|
+
async countProjectContentHits(context, userId) {
|
|
374
|
+
const hits = new Map();
|
|
375
|
+
if (!this.isConfigured())
|
|
376
|
+
return hits;
|
|
377
|
+
try {
|
|
378
|
+
const result = await this.search({ query: context, userId, maxResults: 200 });
|
|
379
|
+
for (const r of result.results) {
|
|
380
|
+
const parts = r.name.split("/");
|
|
381
|
+
if (parts.length < 3 || parts[0] !== "projects")
|
|
382
|
+
continue;
|
|
383
|
+
hits.set(parts[1], (hits.get(parts[1]) ?? 0) + 1);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
catch {
|
|
387
|
+
// Content ranking is a fallback signal — losing it is not fatal.
|
|
388
|
+
}
|
|
389
|
+
return hits;
|
|
390
|
+
}
|
|
391
|
+
async countArchivedPages(userId) {
|
|
392
|
+
if (!this.isConfigured() || !userId)
|
|
393
|
+
return 0;
|
|
394
|
+
try {
|
|
395
|
+
const params = new URLSearchParams({ select: "id" });
|
|
396
|
+
params.set("user_id", `eq.${userId}`);
|
|
397
|
+
const res = await fetch(`${this.archivedPagesBaseUrl}?${params}`, {
|
|
398
|
+
method: "HEAD",
|
|
399
|
+
headers: { ...this.headers, prefer: "count=exact" },
|
|
400
|
+
});
|
|
401
|
+
if (!res.ok)
|
|
402
|
+
return 0;
|
|
403
|
+
const range = res.headers.get("content-range");
|
|
404
|
+
if (!range)
|
|
405
|
+
return 0;
|
|
406
|
+
const count = parseInt(range.split("/")[1] ?? "", 10);
|
|
407
|
+
return Number.isFinite(count) ? count : 0;
|
|
408
|
+
}
|
|
409
|
+
catch {
|
|
410
|
+
return 0;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
async archivePage(input) {
|
|
414
|
+
if (!this.isConfigured()) {
|
|
415
|
+
throw new Error("Postgres archive store not configured. Cannot perform archive-on-delete.");
|
|
416
|
+
}
|
|
417
|
+
if (!input.userId) {
|
|
418
|
+
throw new Error("userId is required for archive-on-delete.");
|
|
419
|
+
}
|
|
420
|
+
const row = {
|
|
421
|
+
user_id: input.userId,
|
|
422
|
+
original_path: input.originalPath,
|
|
423
|
+
original_folder: input.originalFolder,
|
|
424
|
+
title: input.title ?? null,
|
|
425
|
+
tags: input.tags ?? [],
|
|
426
|
+
content: input.content,
|
|
427
|
+
revision: input.revision ?? null,
|
|
428
|
+
size_bytes: input.sizeBytes ?? Buffer.byteLength(input.content, "utf8"),
|
|
429
|
+
reason: input.reason ?? null,
|
|
430
|
+
deleted_by: input.deletedBy,
|
|
431
|
+
};
|
|
432
|
+
const res = await fetch(this.archivedPagesBaseUrl, {
|
|
433
|
+
method: "POST",
|
|
434
|
+
headers: {
|
|
435
|
+
...this.headers,
|
|
436
|
+
prefer: "return=representation",
|
|
437
|
+
},
|
|
438
|
+
body: JSON.stringify(row),
|
|
439
|
+
});
|
|
440
|
+
if (!res.ok) {
|
|
441
|
+
throw new Error(`postgresIndex.archivePage failed (${res.status}): ${await res.text()}`);
|
|
442
|
+
}
|
|
443
|
+
const rows = (await res.json());
|
|
444
|
+
const id = rows[0]?.id ?? "";
|
|
445
|
+
if (!id) {
|
|
446
|
+
throw new Error("postgresIndex.archivePage succeeded but no archive ID was returned.");
|
|
447
|
+
}
|
|
448
|
+
return { id };
|
|
449
|
+
}
|
|
450
|
+
async listArchivedPages(options) {
|
|
451
|
+
if (!this.isConfigured())
|
|
452
|
+
return [];
|
|
453
|
+
const params = new URLSearchParams();
|
|
454
|
+
params.set("select", "id,original_path,original_folder,title,tags,content,revision,size_bytes,reason,deleted_at,deleted_by,restored_at,restored_by");
|
|
455
|
+
params.set("user_id", `eq.${options.userId}`);
|
|
456
|
+
params.set("order", "deleted_at.desc");
|
|
457
|
+
params.set("limit", String(Math.min(Math.max(options.limit ?? 50, 1), 200)));
|
|
458
|
+
if (!options.includeRestored) {
|
|
459
|
+
params.set("restored_at", "is.null");
|
|
460
|
+
}
|
|
461
|
+
if (options.originalPath) {
|
|
462
|
+
params.set("original_path", `eq.${options.originalPath}`);
|
|
463
|
+
}
|
|
464
|
+
const res = await fetch(`${this.archivedPagesBaseUrl}?${params}`, { headers: this.headers });
|
|
465
|
+
if (!res.ok) {
|
|
466
|
+
throw new Error(`postgresIndex.listArchivedPages failed (${res.status}): ${await res.text()}`);
|
|
467
|
+
}
|
|
468
|
+
const rows = (await res.json());
|
|
469
|
+
return rows.map((row) => ({
|
|
470
|
+
id: row.id,
|
|
471
|
+
originalPath: row.original_path ?? "",
|
|
472
|
+
originalFolder: row.original_folder ?? "",
|
|
473
|
+
title: row.title ?? "",
|
|
474
|
+
tags: row.tags ?? [],
|
|
475
|
+
content: row.content ?? "",
|
|
476
|
+
revision: row.revision ?? "",
|
|
477
|
+
sizeBytes: row.size_bytes ?? 0,
|
|
478
|
+
reason: row.reason ?? "",
|
|
479
|
+
deletedAt: row.deleted_at ?? "",
|
|
480
|
+
deletedBy: row.deleted_by ?? "",
|
|
481
|
+
restoredAt: row.restored_at ?? "",
|
|
482
|
+
restoredBy: row.restored_by ?? "",
|
|
483
|
+
}));
|
|
484
|
+
}
|
|
485
|
+
async getArchivedPageForRestore(input) {
|
|
486
|
+
const { userId, archiveId, originalPath, deletedAt } = input;
|
|
487
|
+
if (!this.isConfigured())
|
|
488
|
+
return null;
|
|
489
|
+
const params = new URLSearchParams();
|
|
490
|
+
params.set("select", "id,original_path,original_folder,title,tags,content,revision,size_bytes,reason,deleted_at,deleted_by,restored_at,restored_by");
|
|
491
|
+
params.set("user_id", `eq.${userId}`);
|
|
492
|
+
params.set("limit", "1");
|
|
493
|
+
params.set("order", "deleted_at.desc");
|
|
494
|
+
if (archiveId) {
|
|
495
|
+
params.set("id", `eq.${archiveId}`);
|
|
496
|
+
}
|
|
497
|
+
else if (originalPath) {
|
|
498
|
+
params.set("original_path", `eq.${originalPath}`);
|
|
499
|
+
if (deletedAt)
|
|
500
|
+
params.set("deleted_at", `eq.${deletedAt}`);
|
|
501
|
+
}
|
|
502
|
+
else {
|
|
503
|
+
return null;
|
|
504
|
+
}
|
|
505
|
+
const res = await fetch(`${this.archivedPagesBaseUrl}?${params}`, { headers: this.headers });
|
|
506
|
+
if (!res.ok) {
|
|
507
|
+
throw new Error(`postgresIndex.getArchivedPageForRestore failed (${res.status}): ${await res.text()}`);
|
|
508
|
+
}
|
|
509
|
+
const rows = (await res.json());
|
|
510
|
+
const row = rows[0];
|
|
511
|
+
if (!row)
|
|
512
|
+
return null;
|
|
513
|
+
return {
|
|
514
|
+
id: row.id,
|
|
515
|
+
originalPath: row.original_path ?? "",
|
|
516
|
+
originalFolder: row.original_folder ?? "",
|
|
517
|
+
title: row.title ?? "",
|
|
518
|
+
tags: row.tags ?? [],
|
|
519
|
+
content: row.content ?? "",
|
|
520
|
+
revision: row.revision ?? "",
|
|
521
|
+
sizeBytes: row.size_bytes ?? 0,
|
|
522
|
+
reason: row.reason ?? "",
|
|
523
|
+
deletedAt: row.deleted_at ?? "",
|
|
524
|
+
deletedBy: row.deleted_by ?? "",
|
|
525
|
+
restoredAt: row.restored_at ?? "",
|
|
526
|
+
restoredBy: row.restored_by ?? "",
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
async markArchivedPageRestored(archiveId, userId, restoredBy) {
|
|
530
|
+
if (!this.isConfigured())
|
|
531
|
+
return;
|
|
532
|
+
const params = new URLSearchParams();
|
|
533
|
+
params.set("id", `eq.${archiveId}`);
|
|
534
|
+
params.set("user_id", `eq.${userId}`);
|
|
535
|
+
const res = await fetch(`${this.archivedPagesBaseUrl}?${params}`, {
|
|
536
|
+
method: "PATCH",
|
|
537
|
+
headers: {
|
|
538
|
+
...this.headers,
|
|
539
|
+
prefer: "return=minimal",
|
|
540
|
+
},
|
|
541
|
+
body: JSON.stringify({
|
|
542
|
+
restored_at: new Date().toISOString(),
|
|
543
|
+
restored_by: restoredBy,
|
|
544
|
+
}),
|
|
545
|
+
});
|
|
546
|
+
if (!res.ok) {
|
|
547
|
+
throw new Error(`postgresIndex.markArchivedPageRestored failed (${res.status}): ${await res.text()}`);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
// ── Write operations ────────────────────────────────────────────────────────
|
|
551
|
+
/**
|
|
552
|
+
* Insert or update a page in the index.
|
|
553
|
+
* Non-blocking: callers should fire-and-forget with `.catch(() => {})`.
|
|
554
|
+
*/
|
|
555
|
+
async upsertPage(name, frontmatter, content, revision, modifiedTime, userId = "", contentModifiedTime) {
|
|
556
|
+
if (!this.isConfigured())
|
|
557
|
+
return;
|
|
558
|
+
// contentModifiedTime is the semantic content-edit time (e.g. frontmatter.updated on import).
|
|
559
|
+
const contentMod = contentModifiedTime ?? modifiedTime;
|
|
560
|
+
const row = {
|
|
561
|
+
user_id: userId,
|
|
562
|
+
name,
|
|
563
|
+
title: String(frontmatter.title ?? name),
|
|
564
|
+
summary: String(frontmatter.summary ?? ""),
|
|
565
|
+
tags: normalizeTags(frontmatter.tags),
|
|
566
|
+
// Same normalization as tags: aliases are matched case-insensitively
|
|
567
|
+
// against tokenized context, so storing them lowercased keeps the
|
|
568
|
+
// comparison in one place.
|
|
569
|
+
aliases: normalizeTags(frontmatter.aliases),
|
|
570
|
+
status: String(frontmatter.status ?? "active"),
|
|
571
|
+
updated: normalizeDate(frontmatter.updated),
|
|
572
|
+
revision,
|
|
573
|
+
modified_time: modifiedTime,
|
|
574
|
+
created: contentMod,
|
|
575
|
+
modified: contentMod,
|
|
576
|
+
content,
|
|
577
|
+
access_level: typeof frontmatter.access_level === "number" ? frontmatter.access_level : 1,
|
|
578
|
+
audience: String(frontmatter.audience ?? "internal"),
|
|
579
|
+
};
|
|
580
|
+
let res;
|
|
581
|
+
try {
|
|
582
|
+
res = await fetch(this.baseUrl, {
|
|
583
|
+
method: "POST",
|
|
584
|
+
headers: {
|
|
585
|
+
...this.headers,
|
|
586
|
+
prefer: "resolution=merge-duplicates,return=minimal",
|
|
587
|
+
},
|
|
588
|
+
body: JSON.stringify(row),
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
catch (err) {
|
|
592
|
+
const message = `postgresIndex.upsertPage request failed for "${name}": ${err instanceof Error ? err.message : String(err)}`;
|
|
593
|
+
console.error(message);
|
|
594
|
+
throw new Error(message);
|
|
595
|
+
}
|
|
596
|
+
if (!res.ok) {
|
|
597
|
+
const responseText = await res.text();
|
|
598
|
+
const message = `postgresIndex.upsertPage failed (${res.status}) for "${name}": ${responseText}${governanceMigrationHint(responseText)}`;
|
|
599
|
+
console.error(message);
|
|
600
|
+
throw new Error(message);
|
|
601
|
+
}
|
|
602
|
+
// Fire-and-forget embedding generation (non-blocking, same pattern as link sync).
|
|
603
|
+
if (isEmbeddingEnabled()) {
|
|
604
|
+
this.generateAndStoreEmbedding(name, row.title, row.summary, content, userId).catch((err) => {
|
|
605
|
+
console.warn(`[embeddings] Failed to embed "${name}":`, err instanceof Error ? err.message : String(err));
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* Conditionally update a page row only if it still has `expectedRevision`.
|
|
611
|
+
* Returns false (no throw) when the row's revision has moved or the row is
|
|
612
|
+
* gone — that's a concurrent-write signal, not an error. Used by
|
|
613
|
+
* patch_page's CAS retry loop (GH #500): unlike upsertPage's unconditional
|
|
614
|
+
* merge-duplicates upsert, this scopes the write with a `revision=eq.` filter
|
|
615
|
+
* so a concurrent writer between read and write can't be silently clobbered.
|
|
616
|
+
*/
|
|
617
|
+
async casUpdatePage(name, frontmatter, content, expectedRevision, newRevision, modifiedTime, userId = "", contentModifiedTime) {
|
|
618
|
+
if (!this.isConfigured())
|
|
619
|
+
return false;
|
|
620
|
+
const contentMod = contentModifiedTime ?? modifiedTime;
|
|
621
|
+
const row = {
|
|
622
|
+
title: String(frontmatter.title ?? name),
|
|
623
|
+
summary: String(frontmatter.summary ?? ""),
|
|
624
|
+
tags: normalizeTags(frontmatter.tags),
|
|
625
|
+
aliases: normalizeTags(frontmatter.aliases),
|
|
626
|
+
status: String(frontmatter.status ?? "active"),
|
|
627
|
+
updated: normalizeDate(frontmatter.updated),
|
|
628
|
+
revision: newRevision,
|
|
629
|
+
modified_time: modifiedTime,
|
|
630
|
+
modified: contentMod,
|
|
631
|
+
content,
|
|
632
|
+
access_level: typeof frontmatter.access_level === "number" ? frontmatter.access_level : 1,
|
|
633
|
+
audience: String(frontmatter.audience ?? "internal"),
|
|
634
|
+
};
|
|
635
|
+
const params = new URLSearchParams();
|
|
636
|
+
params.set("user_id", `eq.${userId}`);
|
|
637
|
+
params.set("name", `eq.${name}`);
|
|
638
|
+
params.set("revision", `eq.${expectedRevision}`);
|
|
639
|
+
let res;
|
|
640
|
+
try {
|
|
641
|
+
res = await fetch(`${this.baseUrl}?${params}`, {
|
|
642
|
+
method: "PATCH",
|
|
643
|
+
headers: { ...this.headers, prefer: "return=representation" },
|
|
644
|
+
body: JSON.stringify(row),
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
catch (err) {
|
|
648
|
+
const message = `postgresIndex.casUpdatePage request failed for "${name}": ${err instanceof Error ? err.message : String(err)}`;
|
|
649
|
+
console.error(message);
|
|
650
|
+
throw new Error(message);
|
|
651
|
+
}
|
|
652
|
+
if (!res.ok) {
|
|
653
|
+
const responseText = await res.text();
|
|
654
|
+
const message = `postgresIndex.casUpdatePage failed (${res.status}) for "${name}": ${responseText}${governanceMigrationHint(responseText)}`;
|
|
655
|
+
console.error(message);
|
|
656
|
+
throw new Error(message);
|
|
657
|
+
}
|
|
658
|
+
const updatedRows = (await res.json());
|
|
659
|
+
const applied = updatedRows.length > 0;
|
|
660
|
+
if (applied && isEmbeddingEnabled()) {
|
|
661
|
+
this.generateAndStoreEmbedding(name, row.title, row.summary, content, userId).catch((err) => {
|
|
662
|
+
console.warn(`[embeddings] Failed to embed "${name}":`, err instanceof Error ? err.message : String(err));
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
return applied;
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* Insert a brand-new page row only if one doesn't already exist.
|
|
669
|
+
* Returns false (no throw) when a concurrent writer already created the
|
|
670
|
+
* row — that's the insert-side counterpart of casUpdatePage (GH #500):
|
|
671
|
+
* patch_page's "first write, no row to CAS against" branch previously fell
|
|
672
|
+
* back to upsertPage's unconditional merge-duplicates upsert, so two
|
|
673
|
+
* concurrent first writes on the same new page could silently clobber one
|
|
674
|
+
* another with no conflict ever reported. `resolution=ignore-duplicates`
|
|
675
|
+
* makes the INSERT a no-op on a (user_id, name) collision instead of
|
|
676
|
+
* merging, and `return=representation` lets us tell a real insert apart
|
|
677
|
+
* from a no-op by whether a row came back.
|
|
678
|
+
*/
|
|
679
|
+
async casInsertPage(name, frontmatter, content, newRevision, modifiedTime, userId = "", contentModifiedTime) {
|
|
680
|
+
if (!this.isConfigured())
|
|
681
|
+
return false;
|
|
682
|
+
const contentMod = contentModifiedTime ?? modifiedTime;
|
|
683
|
+
const row = {
|
|
684
|
+
user_id: userId,
|
|
685
|
+
name,
|
|
686
|
+
title: String(frontmatter.title ?? name),
|
|
687
|
+
summary: String(frontmatter.summary ?? ""),
|
|
688
|
+
tags: normalizeTags(frontmatter.tags),
|
|
689
|
+
aliases: normalizeTags(frontmatter.aliases),
|
|
690
|
+
status: String(frontmatter.status ?? "active"),
|
|
691
|
+
updated: normalizeDate(frontmatter.updated),
|
|
692
|
+
revision: newRevision,
|
|
693
|
+
modified_time: modifiedTime,
|
|
694
|
+
created: contentMod,
|
|
695
|
+
modified: contentMod,
|
|
696
|
+
content,
|
|
697
|
+
access_level: typeof frontmatter.access_level === "number" ? frontmatter.access_level : 1,
|
|
698
|
+
audience: String(frontmatter.audience ?? "internal"),
|
|
699
|
+
};
|
|
700
|
+
let res;
|
|
701
|
+
try {
|
|
702
|
+
res = await fetch(this.baseUrl, {
|
|
703
|
+
method: "POST",
|
|
704
|
+
headers: {
|
|
705
|
+
...this.headers,
|
|
706
|
+
prefer: "resolution=ignore-duplicates,return=representation",
|
|
707
|
+
},
|
|
708
|
+
body: JSON.stringify(row),
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
catch (err) {
|
|
712
|
+
const message = `postgresIndex.casInsertPage request failed for "${name}": ${err instanceof Error ? err.message : String(err)}`;
|
|
713
|
+
console.error(message);
|
|
714
|
+
throw new Error(message);
|
|
715
|
+
}
|
|
716
|
+
if (!res.ok) {
|
|
717
|
+
const responseText = await res.text();
|
|
718
|
+
const message = `postgresIndex.casInsertPage failed (${res.status}) for "${name}": ${responseText}${governanceMigrationHint(responseText)}`;
|
|
719
|
+
console.error(message);
|
|
720
|
+
throw new Error(message);
|
|
721
|
+
}
|
|
722
|
+
const insertedRows = (await res.json());
|
|
723
|
+
const applied = insertedRows.length > 0;
|
|
724
|
+
if (applied && isEmbeddingEnabled()) {
|
|
725
|
+
this.generateAndStoreEmbedding(name, row.title, row.summary, content, userId).catch((err) => {
|
|
726
|
+
console.warn(`[embeddings] Failed to embed "${name}":`, err instanceof Error ? err.message : String(err));
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
return applied;
|
|
730
|
+
}
|
|
731
|
+
/**
|
|
732
|
+
* Generate an embedding for a page and store it in the embedding column.
|
|
733
|
+
* Called fire-and-forget from upsertPage.
|
|
734
|
+
*/
|
|
735
|
+
async generateAndStoreEmbedding(name, title, summary, content, userId = "") {
|
|
736
|
+
if (!this.isConfigured())
|
|
737
|
+
return;
|
|
738
|
+
const input = buildEmbeddingInput(title, summary, content);
|
|
739
|
+
const embedding = await generateEmbedding(input);
|
|
740
|
+
if (!embedding)
|
|
741
|
+
return;
|
|
742
|
+
await this.storeEmbedding(name, userId, embedding);
|
|
743
|
+
}
|
|
744
|
+
/**
|
|
745
|
+
* Write a pre-computed embedding vector to the pages table.
|
|
746
|
+
* Used by generateAndStoreEmbedding and the backfill script.
|
|
747
|
+
*/
|
|
748
|
+
async storeEmbedding(name, userId, embedding) {
|
|
749
|
+
if (!this.isConfigured())
|
|
750
|
+
return;
|
|
751
|
+
const params = new URLSearchParams();
|
|
752
|
+
params.set("user_id", `eq.${userId}`);
|
|
753
|
+
params.set("name", `eq.${name}`);
|
|
754
|
+
const res = await fetch(`${this.baseUrl}?${params}`, {
|
|
755
|
+
method: "PATCH",
|
|
756
|
+
headers: { ...this.headers, prefer: "return=minimal" },
|
|
757
|
+
body: JSON.stringify({ embedding: `[${embedding.join(",")}]` }),
|
|
758
|
+
});
|
|
759
|
+
if (!res.ok) {
|
|
760
|
+
throw new Error(`postgresIndex.storeEmbedding failed (${res.status}) for "${name}": ${await res.text()}`);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
/**
|
|
764
|
+
* Remove a page from the index by name.
|
|
765
|
+
* Non-blocking: callers should fire-and-forget with `.catch(() => {})`.
|
|
766
|
+
*/
|
|
767
|
+
async deletePage(name, userId = "") {
|
|
768
|
+
if (!this.isConfigured())
|
|
769
|
+
return;
|
|
770
|
+
const url = `${this.baseUrl}?user_id=eq.${encodeURIComponent(userId)}&name=eq.${encodeURIComponent(name)}`;
|
|
771
|
+
const res = await fetch(url, {
|
|
772
|
+
method: "DELETE",
|
|
773
|
+
headers: this.headers,
|
|
774
|
+
});
|
|
775
|
+
if (!res.ok) {
|
|
776
|
+
throw new Error(`postgresIndex.deletePage failed (${res.status}): ${await res.text()}`);
|
|
777
|
+
}
|
|
778
|
+
await this.deleteLinksForSource(name, userId);
|
|
779
|
+
}
|
|
780
|
+
/**
|
|
781
|
+
* Remove multiple pages and their wiki_links in two batch HTTP requests per chunk.
|
|
782
|
+
* Avoids N×2 serial round-trips when purging or reconciling many rows at once.
|
|
783
|
+
*/
|
|
784
|
+
async deletePages(names, userId = "") {
|
|
785
|
+
if (!this.isConfigured() || names.length === 0)
|
|
786
|
+
return;
|
|
787
|
+
const CHUNK = 100;
|
|
788
|
+
for (let i = 0; i < names.length; i += CHUNK) {
|
|
789
|
+
const chunk = names.slice(i, i + CHUNK);
|
|
790
|
+
const inClause = chunk.map(escapePostgrestLiteral).join(",");
|
|
791
|
+
const linksUrl = `${this.linksBaseUrl}?user_id=eq.${encodeURIComponent(userId)}&source=in.(${inClause})`;
|
|
792
|
+
const linksRes = await fetch(linksUrl, { method: "DELETE", headers: this.headers });
|
|
793
|
+
if (!linksRes.ok) {
|
|
794
|
+
throw new Error(`postgresIndex.deletePages links failed (${linksRes.status}): ${await linksRes.text()}`);
|
|
795
|
+
}
|
|
796
|
+
const pagesUrl = `${this.baseUrl}?user_id=eq.${encodeURIComponent(userId)}&name=in.(${inClause})`;
|
|
797
|
+
const pagesRes = await fetch(pagesUrl, { method: "DELETE", headers: this.headers });
|
|
798
|
+
if (!pagesRes.ok) {
|
|
799
|
+
throw new Error(`postgresIndex.deletePages failed (${pagesRes.status}): ${await pagesRes.text()}`);
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
async syncPageLinks(name, content, userId = "") {
|
|
804
|
+
const links = extractWikiLinks(name, content);
|
|
805
|
+
if (!this.isConfigured())
|
|
806
|
+
return { linkCount: links.length };
|
|
807
|
+
await this.deleteLinksForSource(name, userId);
|
|
808
|
+
if (links.length > 0) {
|
|
809
|
+
const rows = links.map((link) => ({
|
|
810
|
+
user_id: userId,
|
|
811
|
+
source: name,
|
|
812
|
+
target: link.target,
|
|
813
|
+
anchor_text: link.anchorText,
|
|
814
|
+
extracted_at: new Date().toISOString(),
|
|
815
|
+
}));
|
|
816
|
+
const writeRes = await fetch(this.linksBaseUrl, {
|
|
817
|
+
method: "POST",
|
|
818
|
+
headers: {
|
|
819
|
+
...this.headers,
|
|
820
|
+
prefer: "resolution=merge-duplicates,return=minimal",
|
|
821
|
+
},
|
|
822
|
+
body: JSON.stringify(rows),
|
|
823
|
+
});
|
|
824
|
+
if (!writeRes.ok) {
|
|
825
|
+
throw new Error(`postgresIndex.syncPageLinks write failed (${writeRes.status}): ${await writeRes.text()}`);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
await this.stampLinksExtractedAt(name, userId);
|
|
829
|
+
return { linkCount: links.length };
|
|
830
|
+
}
|
|
831
|
+
async getOutboundLinkCount(name, userId = "") {
|
|
832
|
+
if (!this.isConfigured())
|
|
833
|
+
return 0;
|
|
834
|
+
const params = new URLSearchParams();
|
|
835
|
+
params.set("user_id", `eq.${userId}`);
|
|
836
|
+
params.set("source", `eq.${name}`);
|
|
837
|
+
const res = await fetch(`${this.linksBaseUrl}?${params}`, {
|
|
838
|
+
method: "HEAD",
|
|
839
|
+
headers: { ...this.headers, prefer: "count=exact" },
|
|
840
|
+
});
|
|
841
|
+
if (!res.ok) {
|
|
842
|
+
throw new Error(`postgresIndex.getOutboundLinkCount failed (${res.status}): ${await res.text()}`);
|
|
843
|
+
}
|
|
844
|
+
const range = res.headers.get("content-range");
|
|
845
|
+
if (!range)
|
|
846
|
+
return 0;
|
|
847
|
+
const total = Number(range.split("/")[1] ?? "0");
|
|
848
|
+
return Number.isFinite(total) ? total : 0;
|
|
849
|
+
}
|
|
850
|
+
async getBacklinks(name, userId = "", accessLevel) {
|
|
851
|
+
if (!this.isConfigured())
|
|
852
|
+
return [];
|
|
853
|
+
const params = new URLSearchParams();
|
|
854
|
+
params.set("select", "source");
|
|
855
|
+
params.set("user_id", `eq.${userId}`);
|
|
856
|
+
params.set("target", `eq.${name}`);
|
|
857
|
+
params.set("order", "source.asc");
|
|
858
|
+
const res = await fetch(`${this.linksBaseUrl}?${params}`, { headers: this.headers });
|
|
859
|
+
if (!res.ok) {
|
|
860
|
+
throw new Error(`postgresIndex.getBacklinks failed (${res.status}): ${await res.text()}`);
|
|
861
|
+
}
|
|
862
|
+
const rows = (await res.json());
|
|
863
|
+
const sources = Array.from(new Set(rows.map((row) => row.source)));
|
|
864
|
+
if (accessLevel === undefined || sources.length === 0)
|
|
865
|
+
return sources;
|
|
866
|
+
return this.filterPagesByAccessLevel(sources, userId, accessLevel);
|
|
867
|
+
}
|
|
868
|
+
async getRelatedPages(name, userId = "", accessLevel) {
|
|
869
|
+
if (!this.isConfigured())
|
|
870
|
+
return [];
|
|
871
|
+
const targetParams = new URLSearchParams();
|
|
872
|
+
targetParams.set("select", "target");
|
|
873
|
+
targetParams.set("user_id", `eq.${userId}`);
|
|
874
|
+
targetParams.set("source", `eq.${name}`);
|
|
875
|
+
const sourceRes = await fetch(`${this.linksBaseUrl}?${targetParams}`, { headers: this.headers });
|
|
876
|
+
if (!sourceRes.ok) {
|
|
877
|
+
throw new Error(`postgresIndex.getRelatedPages source failed (${sourceRes.status}): ${await sourceRes.text()}`);
|
|
878
|
+
}
|
|
879
|
+
const sourceRows = (await sourceRes.json());
|
|
880
|
+
const targets = Array.from(new Set(sourceRows.map((row) => row.target))).filter(Boolean);
|
|
881
|
+
if (targets.length === 0)
|
|
882
|
+
return [];
|
|
883
|
+
const relatedParams = new URLSearchParams();
|
|
884
|
+
relatedParams.set("select", "source,target");
|
|
885
|
+
relatedParams.set("user_id", `eq.${userId}`);
|
|
886
|
+
relatedParams.set("target", `in.(${targets.map(escapePostgrestLiteral).join(",")})`);
|
|
887
|
+
const relatedRes = await fetch(`${this.linksBaseUrl}?${relatedParams}`, { headers: this.headers });
|
|
888
|
+
if (!relatedRes.ok) {
|
|
889
|
+
throw new Error(`postgresIndex.getRelatedPages related failed (${relatedRes.status}): ${await relatedRes.text()}`);
|
|
890
|
+
}
|
|
891
|
+
const relatedRows = (await relatedRes.json());
|
|
892
|
+
// Resolve allowed sources before scoring to avoid scoring pages that will be filtered.
|
|
893
|
+
let allowed = null;
|
|
894
|
+
if (accessLevel !== undefined) {
|
|
895
|
+
const distinctSources = Array.from(new Set(relatedRows.map((r) => r.source).filter((s) => s && s !== name)));
|
|
896
|
+
allowed = distinctSources.length > 0
|
|
897
|
+
? new Set(await this.filterPagesByAccessLevel(distinctSources, userId, accessLevel))
|
|
898
|
+
: new Set();
|
|
899
|
+
}
|
|
900
|
+
const scores = new Map();
|
|
901
|
+
for (const row of relatedRows) {
|
|
902
|
+
if (!row.source || row.source === name)
|
|
903
|
+
continue;
|
|
904
|
+
if (allowed !== null && !allowed.has(row.source))
|
|
905
|
+
continue;
|
|
906
|
+
scores.set(row.source, (scores.get(row.source) ?? 0) + 1);
|
|
907
|
+
}
|
|
908
|
+
return Array.from(scores.entries())
|
|
909
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
910
|
+
.map(([source]) => source);
|
|
911
|
+
}
|
|
912
|
+
/**
|
|
913
|
+
* Filter a list of page names to only those where `access_level <= accessLevel`
|
|
914
|
+
* for the given user. Used to enforce Hive mode governance in link-graph operations.
|
|
915
|
+
* Queries the `wiki_pages` table (columns: `name`, `user_id`, `access_level`).
|
|
916
|
+
*/
|
|
917
|
+
async filterPagesByAccessLevel(names, userId, accessLevel) {
|
|
918
|
+
if (names.length === 0)
|
|
919
|
+
return [];
|
|
920
|
+
const params = new URLSearchParams();
|
|
921
|
+
params.set("select", "name");
|
|
922
|
+
params.set("user_id", `eq.${userId}`);
|
|
923
|
+
params.set("name", `in.(${names.map(escapePostgrestLiteral).join(",")})`);
|
|
924
|
+
params.set("access_level", `lte.${accessLevel}`);
|
|
925
|
+
const res = await fetch(`${this.baseUrl}?${params}`, { headers: this.headers });
|
|
926
|
+
if (!res.ok) {
|
|
927
|
+
throw new Error(`postgresIndex.filterPagesByAccessLevel failed (${res.status}): ${await res.text()}`);
|
|
928
|
+
}
|
|
929
|
+
const rows = (await res.json());
|
|
930
|
+
return rows.map((r) => r.name);
|
|
931
|
+
}
|
|
932
|
+
async extractLinksForStalePages(filter) {
|
|
933
|
+
if (!this.isConfigured())
|
|
934
|
+
return { pagesProcessed: 0, linksExtracted: 0 };
|
|
935
|
+
const tags = (filter.tags ?? [])
|
|
936
|
+
.map((tag) => tag.trim())
|
|
937
|
+
.filter((tag) => tag.length > 0)
|
|
938
|
+
.map(validateTagFilter);
|
|
939
|
+
const folder = filter.folder?.trim();
|
|
940
|
+
if (tags.length === 0 && !folder) {
|
|
941
|
+
throw new Error("At least one filter is required: tags or folder");
|
|
942
|
+
}
|
|
943
|
+
const params = new URLSearchParams();
|
|
944
|
+
params.set("select", "name,modified_time,links_extracted_at");
|
|
945
|
+
params.set("user_id", `eq.${filter.userId ?? ""}`);
|
|
946
|
+
params.set("order", "modified_time.desc");
|
|
947
|
+
if (folder) {
|
|
948
|
+
params.set("name", `like.${escapeLikePattern(folder)}%`);
|
|
949
|
+
}
|
|
950
|
+
if (tags.length === 1) {
|
|
951
|
+
params.set("tags", `cs.{${tags[0]}}`);
|
|
952
|
+
}
|
|
953
|
+
else if (tags.length > 1) {
|
|
954
|
+
params.set("or", `(${tags.map((tag) => `tags.cs.{${tag}}`).join(",")})`);
|
|
955
|
+
}
|
|
956
|
+
const pagesRes = await fetch(`${this.baseUrl}?${params}`, { headers: this.headers });
|
|
957
|
+
if (!pagesRes.ok) {
|
|
958
|
+
throw new Error(`postgresIndex.extractLinksForStalePages list failed (${pagesRes.status}): ${await pagesRes.text()}`);
|
|
959
|
+
}
|
|
960
|
+
const rows = (await pagesRes.json());
|
|
961
|
+
const staleNames = rows.filter((row) => {
|
|
962
|
+
const extractedAt = row.links_extracted_at ? Date.parse(row.links_extracted_at) : Number.NaN;
|
|
963
|
+
const modifiedAt = row.modified_time ? Date.parse(row.modified_time) : Number.NaN;
|
|
964
|
+
if (!Number.isFinite(extractedAt))
|
|
965
|
+
return true;
|
|
966
|
+
if (!Number.isFinite(modifiedAt))
|
|
967
|
+
return false;
|
|
968
|
+
return extractedAt < modifiedAt;
|
|
969
|
+
}).map((row) => row.name);
|
|
970
|
+
let pagesProcessed = 0;
|
|
971
|
+
let linksExtracted = 0;
|
|
972
|
+
for (const name of staleNames) {
|
|
973
|
+
const content = await this.fetchPageContent(name, filter.userId ?? "");
|
|
974
|
+
const result = await this.syncPageLinks(name, content, filter.userId ?? "");
|
|
975
|
+
pagesProcessed += 1;
|
|
976
|
+
linksExtracted += result.linkCount;
|
|
977
|
+
}
|
|
978
|
+
return { pagesProcessed, linksExtracted };
|
|
979
|
+
}
|
|
980
|
+
async deleteLinksForSource(name, userId = "") {
|
|
981
|
+
if (!this.isConfigured())
|
|
982
|
+
return;
|
|
983
|
+
const url = `${this.linksBaseUrl}?user_id=eq.${encodeURIComponent(userId)}&source=eq.${encodeURIComponent(name)}`;
|
|
984
|
+
const res = await fetch(url, {
|
|
985
|
+
method: "DELETE",
|
|
986
|
+
headers: this.headers,
|
|
987
|
+
});
|
|
988
|
+
if (!res.ok) {
|
|
989
|
+
throw new Error(`postgresIndex.deleteLinksForSource failed (${res.status}): ${await res.text()}`);
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
async stampLinksExtractedAt(name, userId = "") {
|
|
993
|
+
const params = new URLSearchParams();
|
|
994
|
+
params.set("user_id", `eq.${userId}`);
|
|
995
|
+
params.set("name", `eq.${name}`);
|
|
996
|
+
const res = await fetch(`${this.baseUrl}?${params}`, {
|
|
997
|
+
method: "PATCH",
|
|
998
|
+
headers: {
|
|
999
|
+
...this.headers,
|
|
1000
|
+
prefer: "return=minimal",
|
|
1001
|
+
},
|
|
1002
|
+
body: JSON.stringify({ links_extracted_at: new Date().toISOString() }),
|
|
1003
|
+
});
|
|
1004
|
+
if (!res.ok) {
|
|
1005
|
+
throw new Error(`postgresIndex.stampLinksExtractedAt failed (${res.status}): ${await res.text()}`);
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
async fetchPageContent(name, userId = "") {
|
|
1009
|
+
const params = new URLSearchParams();
|
|
1010
|
+
params.set("select", "content");
|
|
1011
|
+
params.set("user_id", `eq.${userId}`);
|
|
1012
|
+
params.set("name", `eq.${name}`);
|
|
1013
|
+
const res = await fetch(`${this.baseUrl}?${params}`, { headers: this.headers });
|
|
1014
|
+
if (!res.ok) {
|
|
1015
|
+
throw new Error(`postgresIndex.fetchPageContent failed (${res.status}): ${await res.text()}`);
|
|
1016
|
+
}
|
|
1017
|
+
const rows = (await res.json());
|
|
1018
|
+
if (rows.length === 0)
|
|
1019
|
+
return "";
|
|
1020
|
+
return rows[0].content ?? "";
|
|
1021
|
+
}
|
|
1022
|
+
/**
|
|
1023
|
+
* Read a single page row from the DB for the hot path (read_page, patch_page).
|
|
1024
|
+
* Returns null when not configured, row not found, or on any error so the
|
|
1025
|
+
* caller can fall back to Supabase Storage transparently.
|
|
1026
|
+
*/
|
|
1027
|
+
async fetchPageRow(name, userId = "") {
|
|
1028
|
+
if (!this.isConfigured())
|
|
1029
|
+
return null;
|
|
1030
|
+
const params = new URLSearchParams();
|
|
1031
|
+
params.set("select", "content,revision,modified_time");
|
|
1032
|
+
params.set("user_id", `eq.${userId}`);
|
|
1033
|
+
params.set("name", `eq.${name}`);
|
|
1034
|
+
const res = await fetch(`${this.baseUrl}?${params}`, { headers: this.headers });
|
|
1035
|
+
if (!res.ok)
|
|
1036
|
+
return null;
|
|
1037
|
+
const rows = (await res.json());
|
|
1038
|
+
if (rows.length === 0)
|
|
1039
|
+
return null;
|
|
1040
|
+
const row = rows[0];
|
|
1041
|
+
if (typeof row.content !== "string")
|
|
1042
|
+
return null;
|
|
1043
|
+
return {
|
|
1044
|
+
fileId: name,
|
|
1045
|
+
name,
|
|
1046
|
+
content: row.content,
|
|
1047
|
+
revision: row.revision ?? "",
|
|
1048
|
+
modifiedTime: row.modified_time ?? new Date().toISOString(),
|
|
1049
|
+
};
|
|
1050
|
+
}
|
|
1051
|
+
// ── Read operations ──────────────────────────────────────────────────────────
|
|
1052
|
+
/**
|
|
1053
|
+
* List all pages, with optional tag and status filters.
|
|
1054
|
+
* Returns lightweight entries — no content.
|
|
1055
|
+
*/
|
|
1056
|
+
async listPages(options = {}) {
|
|
1057
|
+
const ownRows = await this.listPagesSingleScope(options);
|
|
1058
|
+
if (!options.grantScopes || options.grantScopes.length === 0)
|
|
1059
|
+
return ownRows;
|
|
1060
|
+
const grantRowsArrays = await Promise.all(options.grantScopes.map((scope) => this.listPagesSingleScope({ ...options, userId: scope.userId, pathPrefix: scope.folder, grantScopes: undefined })));
|
|
1061
|
+
const seen = new Set(ownRows.map((r) => r.name));
|
|
1062
|
+
const merged = [...ownRows];
|
|
1063
|
+
for (const rows of grantRowsArrays) {
|
|
1064
|
+
for (const r of rows) {
|
|
1065
|
+
if (seen.has(r.name))
|
|
1066
|
+
continue;
|
|
1067
|
+
seen.add(r.name);
|
|
1068
|
+
merged.push(r);
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
merged.sort((a, b) => (b.modified || "").localeCompare(a.modified || ""));
|
|
1072
|
+
// Best-effort bound for the grant-scoped merge: each sub-scope query is independently
|
|
1073
|
+
// limited/offset, so exact pagination across scopes isn't guaranteed — but the final
|
|
1074
|
+
// response is still capped at `limit` so a caller can never get more than they asked for.
|
|
1075
|
+
return options.limit !== undefined ? merged.slice(0, options.limit) : merged;
|
|
1076
|
+
}
|
|
1077
|
+
async listPagesSingleScope(options = {}) {
|
|
1078
|
+
if (!this.isConfigured())
|
|
1079
|
+
return [];
|
|
1080
|
+
const params = new URLSearchParams();
|
|
1081
|
+
params.set("select", options.compact ? "name,modified,revision" : "name,title,summary,updated,status,tags,created,modified,revision");
|
|
1082
|
+
// Secondary sort on `name` breaks ties deterministically — without it, rows sharing
|
|
1083
|
+
// the same `modified` timestamp (common for bulk imports) can be returned in a
|
|
1084
|
+
// different relative order across separate limit/offset queries, causing a page's
|
|
1085
|
+
// tail to duplicate the next page's head (or, symmetrically, drop a row entirely).
|
|
1086
|
+
// Reproduced on staging: a folder-scoped walk over 847 pages hit the same duplicate
|
|
1087
|
+
// at the same offset boundary across repeated runs.
|
|
1088
|
+
params.set("order", "modified.desc.nullslast,name.asc");
|
|
1089
|
+
params.set("user_id", `eq.${options.userId ?? ""}`);
|
|
1090
|
+
if (options.status) {
|
|
1091
|
+
params.set("status", `eq.${options.status}`);
|
|
1092
|
+
}
|
|
1093
|
+
if (options.tag) {
|
|
1094
|
+
params.set("tags", `cs.{${options.tag}}`);
|
|
1095
|
+
}
|
|
1096
|
+
if (options.accessLevel !== undefined) {
|
|
1097
|
+
params.set("access_level", `lte.${options.accessLevel}`);
|
|
1098
|
+
}
|
|
1099
|
+
if (options.changedAfter) {
|
|
1100
|
+
params.set("modified", `gt.${options.changedAfter}`);
|
|
1101
|
+
}
|
|
1102
|
+
// Exclude version-history snapshots (page-versions/* are internal — not current pages).
|
|
1103
|
+
params.append("name", "not.like.page-versions/%");
|
|
1104
|
+
// Exclude system files at the query level, not after fetching — doing this
|
|
1105
|
+
// client-side after limit/offset silently shrinks a page and desyncs the
|
|
1106
|
+
// pagination window (a page could return fewer than `limit` rows without
|
|
1107
|
+
// signaling it, and the next offset would skip or duplicate rows).
|
|
1108
|
+
params.append("name", "not.in.(index.md,log.md)");
|
|
1109
|
+
if (options.pathPrefix) {
|
|
1110
|
+
params.append("name", `like.${escapeLikePattern(options.pathPrefix)}/%`);
|
|
1111
|
+
}
|
|
1112
|
+
if (options.folder) {
|
|
1113
|
+
params.append("name", `like.${escapeLikePattern(options.folder)}/%`);
|
|
1114
|
+
}
|
|
1115
|
+
if (options.limit !== undefined) {
|
|
1116
|
+
params.set("limit", String(options.limit));
|
|
1117
|
+
}
|
|
1118
|
+
if (options.offset) {
|
|
1119
|
+
params.set("offset", String(options.offset));
|
|
1120
|
+
}
|
|
1121
|
+
const url = `${this.baseUrl}?${params}`;
|
|
1122
|
+
const res = await fetch(url, {
|
|
1123
|
+
headers: { ...this.headers, prefer: "return=representation" },
|
|
1124
|
+
});
|
|
1125
|
+
if (!res.ok) {
|
|
1126
|
+
throw new Error(`postgresIndex.listPages failed (${res.status}): ${await res.text()}`);
|
|
1127
|
+
}
|
|
1128
|
+
const rows = (await res.json());
|
|
1129
|
+
return rows.map((r) => ({
|
|
1130
|
+
name: r.name,
|
|
1131
|
+
title: r.title || r.name,
|
|
1132
|
+
summary: r.summary || "",
|
|
1133
|
+
updated: r.updated ?? "",
|
|
1134
|
+
status: r.status || "active",
|
|
1135
|
+
tags: r.tags ?? [],
|
|
1136
|
+
created: r.created ?? "",
|
|
1137
|
+
modified: r.modified ?? "",
|
|
1138
|
+
...(r.revision ? { revision: r.revision } : {}),
|
|
1139
|
+
}));
|
|
1140
|
+
}
|
|
1141
|
+
/**
|
|
1142
|
+
* Targeted existence check for health_check's taxonomy verification — point
|
|
1143
|
+
* lookups instead of diffing against a bulk listPages() fetch. A bulk fetch
|
|
1144
|
+
* ordered by modified.desc is bounded by PostgREST's default row cap, so on
|
|
1145
|
+
* wikis past ~1000 pages, old/rarely-touched seed pages silently age out of
|
|
1146
|
+
* the window and false-flag as "missing" (see GH #358). Point lookups by
|
|
1147
|
+
* exact name / folder prefix are correct regardless of wiki size.
|
|
1148
|
+
*
|
|
1149
|
+
* Checks both the clean name and the legacy `users/<userId>/`-prefixed
|
|
1150
|
+
* variant some accounts have from an older populate script, matching the
|
|
1151
|
+
* normalization the bulk-fetch approach used to do.
|
|
1152
|
+
*/
|
|
1153
|
+
async checkTaxonomy(userId, requiredSeedPages, requiredFolders) {
|
|
1154
|
+
if (!this.isConfigured() || !userId) {
|
|
1155
|
+
return { missingSeedPages: [...requiredSeedPages], missingFolders: [...requiredFolders] };
|
|
1156
|
+
}
|
|
1157
|
+
const legacyPrefix = `users/${userId}/`;
|
|
1158
|
+
const presentSeedPageNames = async () => {
|
|
1159
|
+
if (requiredSeedPages.length === 0)
|
|
1160
|
+
return new Set();
|
|
1161
|
+
const candidates = requiredSeedPages.flatMap((p) => [p, `${legacyPrefix}${p}`]);
|
|
1162
|
+
const params = new URLSearchParams();
|
|
1163
|
+
params.set("select", "name");
|
|
1164
|
+
params.set("user_id", `eq.${userId}`);
|
|
1165
|
+
params.set("name", `in.(${candidates.map(escapePostgrestLiteral).join(",")})`);
|
|
1166
|
+
const res = await fetch(`${this.baseUrl}?${params}`, { headers: this.headers });
|
|
1167
|
+
if (!res.ok)
|
|
1168
|
+
return new Set();
|
|
1169
|
+
const rows = (await res.json());
|
|
1170
|
+
return new Set(rows.map((r) => r.name));
|
|
1171
|
+
};
|
|
1172
|
+
const folderHasPages = async (folder) => {
|
|
1173
|
+
const params = new URLSearchParams();
|
|
1174
|
+
params.set("select", "name");
|
|
1175
|
+
params.set("user_id", `eq.${userId}`);
|
|
1176
|
+
params.set("or", `(name.like.${escapeLikePattern(folder)}/%,name.like.${escapeLikePattern(legacyPrefix + folder)}/%)`);
|
|
1177
|
+
params.set("limit", "1");
|
|
1178
|
+
const res = await fetch(`${this.baseUrl}?${params}`, { headers: this.headers });
|
|
1179
|
+
if (!res.ok)
|
|
1180
|
+
return false;
|
|
1181
|
+
const rows = (await res.json());
|
|
1182
|
+
return rows.length > 0;
|
|
1183
|
+
};
|
|
1184
|
+
const [presentNames, folderResults] = await Promise.all([
|
|
1185
|
+
presentSeedPageNames(),
|
|
1186
|
+
Promise.all(requiredFolders.map((f) => folderHasPages(f))),
|
|
1187
|
+
]);
|
|
1188
|
+
const missingSeedPages = requiredSeedPages.filter((p) => !presentNames.has(p) && !presentNames.has(`${legacyPrefix}${p}`));
|
|
1189
|
+
const missingFolders = requiredFolders.filter((_, i) => !folderResults[i]);
|
|
1190
|
+
return { missingSeedPages, missingFolders };
|
|
1191
|
+
}
|
|
1192
|
+
/**
|
|
1193
|
+
* Full-text search using Postgres tsvector / tsquery.
|
|
1194
|
+
* Returns ranked results compatible with IndexedSearchResponse.
|
|
1195
|
+
*/
|
|
1196
|
+
async search(options) {
|
|
1197
|
+
const ownResult = await this.searchSingleScope(options);
|
|
1198
|
+
if (!options.grantScopes || options.grantScopes.length === 0)
|
|
1199
|
+
return ownResult;
|
|
1200
|
+
const scopedResults = await Promise.all(options.grantScopes.map((scope) => this.searchSingleScope({ ...options, userId: scope.userId, folder: scope.folder, grantScopes: undefined })));
|
|
1201
|
+
const seen = new Set(ownResult.results.map((r) => r.name));
|
|
1202
|
+
const merged = [...ownResult.results];
|
|
1203
|
+
let totalMatches = ownResult.totalMatches;
|
|
1204
|
+
let candidateCount = ownResult.candidateCount;
|
|
1205
|
+
for (const r of scopedResults) {
|
|
1206
|
+
totalMatches += r.totalMatches;
|
|
1207
|
+
candidateCount += r.candidateCount;
|
|
1208
|
+
for (const item of r.results) {
|
|
1209
|
+
if (seen.has(item.name))
|
|
1210
|
+
continue;
|
|
1211
|
+
seen.add(item.name);
|
|
1212
|
+
merged.push(item);
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
return {
|
|
1216
|
+
results: merged.slice(0, options.maxResults),
|
|
1217
|
+
totalMatches,
|
|
1218
|
+
candidateCount,
|
|
1219
|
+
queryMs: ownResult.queryMs,
|
|
1220
|
+
};
|
|
1221
|
+
}
|
|
1222
|
+
async searchSingleScope(options) {
|
|
1223
|
+
const queryStart = Date.now();
|
|
1224
|
+
if (!this.isConfigured()) {
|
|
1225
|
+
return { results: [], totalMatches: 0, candidateCount: 0, queryMs: 0 };
|
|
1226
|
+
}
|
|
1227
|
+
const q = options.query.trim();
|
|
1228
|
+
if (!q) {
|
|
1229
|
+
return { results: [], totalMatches: 0, candidateCount: 0, queryMs: Date.now() - queryStart };
|
|
1230
|
+
}
|
|
1231
|
+
const tsquery = toTsQuery(q);
|
|
1232
|
+
// AND-first for precision.
|
|
1233
|
+
let rows = await this.runFtsQuery(tsquery, options);
|
|
1234
|
+
// OR fallback: if the AND query returns nothing and there is more than one
|
|
1235
|
+
// content token, retry with OR so that verbose natural-language questions
|
|
1236
|
+
// still surface documents that contain some—but not all—of the query words.
|
|
1237
|
+
// The ranked RPC orders by ts_rank in-DB; when it is unavailable we pull a
|
|
1238
|
+
// wider pool with content and pass orTokens so runFtsQuery re-ranks by
|
|
1239
|
+
// term overlap (the REST filter falls back to recency order).
|
|
1240
|
+
const rawTokens = tokenizeQuery(q);
|
|
1241
|
+
let usedOrFallback = false;
|
|
1242
|
+
if (rows.length === 0 && rawTokens.length > 1) {
|
|
1243
|
+
rows = await this.runFtsQuery(toTsQuery(q, "OR"), options, {
|
|
1244
|
+
withContent: true,
|
|
1245
|
+
candidateLimit: Math.min(options.maxResults * 10, 200),
|
|
1246
|
+
orTokens: stripStopwords(rawTokens),
|
|
1247
|
+
});
|
|
1248
|
+
usedOrFallback = rows.length > 0;
|
|
1249
|
+
}
|
|
1250
|
+
// usedOrFallback is a query-level flag, not a per-row one: the OR fallback
|
|
1251
|
+
// replaces the entire AND result set, so every row in `rows` here came
|
|
1252
|
+
// from the same (fallback or not) query and shares the same flag value.
|
|
1253
|
+
const results = rows.slice(0, options.maxResults).map((r) => ({
|
|
1254
|
+
name: r.name,
|
|
1255
|
+
title: r.title || r.name,
|
|
1256
|
+
summary: r.summary || "",
|
|
1257
|
+
updated: r.updated ?? "",
|
|
1258
|
+
matchedOn: computeMatchedOn(r, q),
|
|
1259
|
+
...(usedOrFallback ? { fallback: "or" } : {}),
|
|
1260
|
+
}));
|
|
1261
|
+
return {
|
|
1262
|
+
results,
|
|
1263
|
+
totalMatches: rows.length,
|
|
1264
|
+
candidateCount: rows.length,
|
|
1265
|
+
queryMs: Date.now() - queryStart,
|
|
1266
|
+
};
|
|
1267
|
+
}
|
|
1268
|
+
async runFtsQuery(tsquery, options, opts = {}) {
|
|
1269
|
+
if (!tsquery)
|
|
1270
|
+
return [];
|
|
1271
|
+
const candidateLimit = opts.candidateLimit ?? Math.min(options.maxResults * 3, 500);
|
|
1272
|
+
// Prefer the relevance-ordered RPC (ts_rank in-DB). Returns null when the
|
|
1273
|
+
// function isn't deployed, so older deployments fall back transparently.
|
|
1274
|
+
const ranked = await this.runRankedQuery(tsquery, options, candidateLimit);
|
|
1275
|
+
if (ranked !== null)
|
|
1276
|
+
return ranked;
|
|
1277
|
+
// Fallback: PostgREST FTS filter (fts_vector=fts.tsquery), recency-ordered.
|
|
1278
|
+
const params = new URLSearchParams();
|
|
1279
|
+
// content is fetched only when we need to re-rank client-side (OR fallback).
|
|
1280
|
+
params.set("select", `name,title,summary,updated,modified,tags${opts.withContent ? ",content" : ""}`);
|
|
1281
|
+
params.set("fts_vector", `fts.${tsquery}`);
|
|
1282
|
+
params.set("user_id", `eq.${options.userId ?? ""}`);
|
|
1283
|
+
params.set("limit", String(candidateLimit));
|
|
1284
|
+
params.set("order", "modified.desc.nullslast");
|
|
1285
|
+
if (options.tag) {
|
|
1286
|
+
params.set("tags", `cs.{${options.tag}}`);
|
|
1287
|
+
}
|
|
1288
|
+
if (options.accessLevel !== undefined) {
|
|
1289
|
+
params.set("access_level", `lte.${options.accessLevel}`);
|
|
1290
|
+
}
|
|
1291
|
+
if (options.folder) {
|
|
1292
|
+
params.append("name", `like.${escapeLikePattern(options.folder)}/%`);
|
|
1293
|
+
}
|
|
1294
|
+
// Exclude version-history snapshots — same exclusion as listPagesSingleScope.
|
|
1295
|
+
params.append("name", "not.like.page-versions/%");
|
|
1296
|
+
const url = `${this.baseUrl}?${params}`;
|
|
1297
|
+
const res = await fetch(url, {
|
|
1298
|
+
headers: { ...this.headers, prefer: "return=representation" },
|
|
1299
|
+
});
|
|
1300
|
+
if (!res.ok) {
|
|
1301
|
+
throw new Error(`postgresIndex.search query failed (${res.status}): ${await res.text()}`);
|
|
1302
|
+
}
|
|
1303
|
+
const rows = (await res.json());
|
|
1304
|
+
// No in-DB ranking available — re-rank the recency-ordered pool by overlap
|
|
1305
|
+
// when the caller is the OR fallback (orTokens supplied).
|
|
1306
|
+
return opts.orTokens ? rankByOverlap(rows, opts.orTokens) : rows;
|
|
1307
|
+
}
|
|
1308
|
+
/**
|
|
1309
|
+
* Relevance-ordered FTS via the search_pages_ranked RPC (orders by ts_rank in
|
|
1310
|
+
* Postgres). Returns null when the function isn't deployed (PGRST202 / 404) so
|
|
1311
|
+
* the caller can fall back to the REST filter.
|
|
1312
|
+
*/
|
|
1313
|
+
async runRankedQuery(tsquery, options, limit) {
|
|
1314
|
+
const params = new URLSearchParams();
|
|
1315
|
+
params.set("select", "name,title,summary,updated,modified,tags");
|
|
1316
|
+
const body = {
|
|
1317
|
+
p_user_id: options.userId ?? "",
|
|
1318
|
+
p_tsquery: tsquery,
|
|
1319
|
+
p_limit: limit,
|
|
1320
|
+
p_access_level: options.accessLevel ?? null,
|
|
1321
|
+
p_tag: options.tag ?? null,
|
|
1322
|
+
p_folder: options.folder ?? null,
|
|
1323
|
+
};
|
|
1324
|
+
let res;
|
|
1325
|
+
try {
|
|
1326
|
+
res = await fetch(`${this.supabaseUrl}/rest/v1/rpc/search_pages_ranked?${params}`, {
|
|
1327
|
+
method: "POST",
|
|
1328
|
+
headers: { ...this.headers, prefer: "return=representation" },
|
|
1329
|
+
body: JSON.stringify(body),
|
|
1330
|
+
});
|
|
1331
|
+
}
|
|
1332
|
+
catch {
|
|
1333
|
+
return null; // network/abort — let the REST filter path try.
|
|
1334
|
+
}
|
|
1335
|
+
if (res.status === 404)
|
|
1336
|
+
return null;
|
|
1337
|
+
if (!res.ok) {
|
|
1338
|
+
const text = await res.text();
|
|
1339
|
+
if (/PGRST202|could not find the function|does not exist/i.test(text))
|
|
1340
|
+
return null;
|
|
1341
|
+
throw new Error(`postgresIndex.search ranked query failed (${res.status}): ${text}`);
|
|
1342
|
+
}
|
|
1343
|
+
return (await res.json());
|
|
1344
|
+
}
|
|
1345
|
+
/**
|
|
1346
|
+
* Re-trigger the FTS update trigger for all rows where fts_vector is NULL.
|
|
1347
|
+
* Needed when rows were inserted before the trigger was applied (e.g. after a
|
|
1348
|
+
* Supabase project migration). Safe to call multiple times — a second call is
|
|
1349
|
+
* a no-op because no null rows remain.
|
|
1350
|
+
*/
|
|
1351
|
+
async reindexFts(userId) {
|
|
1352
|
+
if (!this.isConfigured())
|
|
1353
|
+
return { updated: 0 };
|
|
1354
|
+
// Count rows with null fts_vector.
|
|
1355
|
+
const countParams = new URLSearchParams({ select: "name" });
|
|
1356
|
+
countParams.set("fts_vector", "is.null");
|
|
1357
|
+
if (userId !== undefined)
|
|
1358
|
+
countParams.set("user_id", `eq.${userId}`);
|
|
1359
|
+
const countRes = await fetch(`${this.baseUrl}?${countParams}`, {
|
|
1360
|
+
method: "HEAD",
|
|
1361
|
+
headers: { ...this.headers, prefer: "count=exact" },
|
|
1362
|
+
});
|
|
1363
|
+
if (!countRes.ok) {
|
|
1364
|
+
throw new Error(`reindexFts count failed (${countRes.status}): ${await countRes.text()}`);
|
|
1365
|
+
}
|
|
1366
|
+
const range = countRes.headers.get("content-range");
|
|
1367
|
+
const nullCount = range ? parseInt(range.split("/")[1] ?? "0", 10) : 0;
|
|
1368
|
+
if (!Number.isFinite(nullCount) || nullCount <= 0)
|
|
1369
|
+
return { updated: 0 };
|
|
1370
|
+
// PATCH those rows — the BEFORE UPDATE trigger recomputes fts_vector from
|
|
1371
|
+
// the existing title/summary/tags/content columns.
|
|
1372
|
+
const patchParams = new URLSearchParams();
|
|
1373
|
+
patchParams.set("fts_vector", "is.null");
|
|
1374
|
+
if (userId !== undefined)
|
|
1375
|
+
patchParams.set("user_id", `eq.${userId}`);
|
|
1376
|
+
const patchRes = await fetch(`${this.baseUrl}?${patchParams}`, {
|
|
1377
|
+
method: "PATCH",
|
|
1378
|
+
headers: { ...this.headers, prefer: "return=minimal" },
|
|
1379
|
+
body: JSON.stringify({ fts_vector: null }),
|
|
1380
|
+
});
|
|
1381
|
+
if (!patchRes.ok) {
|
|
1382
|
+
throw new Error(`reindexFts patch failed (${patchRes.status}): ${await patchRes.text()}`);
|
|
1383
|
+
}
|
|
1384
|
+
return { updated: nullCount };
|
|
1385
|
+
}
|
|
1386
|
+
/**
|
|
1387
|
+
* Score-based hybrid retrieval: FTS (weight 0.3) + vector cosine similarity (weight 0.7).
|
|
1388
|
+
* Calls the search_pages_hybrid stored function deployed by embedding-migration.sql.
|
|
1389
|
+
* Returns null when the function isn't deployed or embedding is unavailable.
|
|
1390
|
+
*/
|
|
1391
|
+
async searchHybrid(options) {
|
|
1392
|
+
if (!this.isConfigured())
|
|
1393
|
+
return null;
|
|
1394
|
+
if (!isEmbeddingEnabled())
|
|
1395
|
+
return null;
|
|
1396
|
+
const q = options.query.trim();
|
|
1397
|
+
if (!q)
|
|
1398
|
+
return null;
|
|
1399
|
+
const embedding = await generateEmbedding(q);
|
|
1400
|
+
if (!embedding)
|
|
1401
|
+
return null;
|
|
1402
|
+
const queryStart = Date.now();
|
|
1403
|
+
const body = {
|
|
1404
|
+
query_text: q,
|
|
1405
|
+
query_embedding: `[${embedding.join(",")}]`,
|
|
1406
|
+
p_user_id: options.userId ?? "",
|
|
1407
|
+
p_max_results: options.maxResults,
|
|
1408
|
+
p_fts_weight: 0.3,
|
|
1409
|
+
p_vec_weight: 0.7,
|
|
1410
|
+
p_access_level: options.accessLevel ?? null,
|
|
1411
|
+
p_tag: options.tag ?? null,
|
|
1412
|
+
};
|
|
1413
|
+
let res;
|
|
1414
|
+
try {
|
|
1415
|
+
res = await fetch(`${this.supabaseUrl}/rest/v1/rpc/search_pages_hybrid`, {
|
|
1416
|
+
method: "POST",
|
|
1417
|
+
headers: { ...this.headers, "content-type": "application/json", prefer: "return=representation" },
|
|
1418
|
+
body: JSON.stringify(body),
|
|
1419
|
+
});
|
|
1420
|
+
}
|
|
1421
|
+
catch {
|
|
1422
|
+
return null;
|
|
1423
|
+
}
|
|
1424
|
+
if (res.status === 404)
|
|
1425
|
+
return null;
|
|
1426
|
+
if (!res.ok) {
|
|
1427
|
+
const text = await res.text();
|
|
1428
|
+
if (/PGRST202|could not find the function|does not exist/i.test(text))
|
|
1429
|
+
return null;
|
|
1430
|
+
throw new Error(`postgresIndex.searchHybrid failed (${res.status}): ${text}`);
|
|
1431
|
+
}
|
|
1432
|
+
const rows = (await res.json());
|
|
1433
|
+
const results = rows.map((r) => ({
|
|
1434
|
+
name: r.name,
|
|
1435
|
+
title: r.title || r.name,
|
|
1436
|
+
summary: r.summary || "",
|
|
1437
|
+
updated: r.updated ?? "",
|
|
1438
|
+
// search_pages_hybrid doesn't return tags, so this synthetic FtsRow always
|
|
1439
|
+
// has tags: [] — hybrid matchedOn can never include "tags". Intentional,
|
|
1440
|
+
// not an oversight, until the RPC is extended to select tags.
|
|
1441
|
+
matchedOn: computeMatchedOn({ name: r.name, title: r.title, summary: r.summary, updated: r.updated, modified: null, tags: [] }, q),
|
|
1442
|
+
hybridScore: r.hybrid_score,
|
|
1443
|
+
ftsScore: r.fts_score,
|
|
1444
|
+
vecScore: r.vec_score,
|
|
1445
|
+
}));
|
|
1446
|
+
return {
|
|
1447
|
+
results,
|
|
1448
|
+
totalMatches: results.length,
|
|
1449
|
+
queryMs: Date.now() - queryStart,
|
|
1450
|
+
backend: "postgres-hybrid",
|
|
1451
|
+
};
|
|
1452
|
+
}
|
|
1453
|
+
/** No-op: connection is stateless (each call is an independent HTTP request). */
|
|
1454
|
+
close() {
|
|
1455
|
+
// nothing to do
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
function normalizeTags(raw) {
|
|
1459
|
+
if (!Array.isArray(raw))
|
|
1460
|
+
return [];
|
|
1461
|
+
return raw
|
|
1462
|
+
.map((t) => String(t).trim().toLowerCase())
|
|
1463
|
+
.filter((t) => t.length > 0);
|
|
1464
|
+
}
|
|
1465
|
+
// gray-matter parses bare YAML dates (`updated: 2026-04-13`) into JS Date objects.
|
|
1466
|
+
// Postgres `date` columns reject `Date.toString()` output ("Mon Apr 13 2026 …"),
|
|
1467
|
+
// so coerce to ISO YYYY-MM-DD here. Strings already in that form pass through;
|
|
1468
|
+
// anything unparseable becomes null.
|
|
1469
|
+
function normalizeDate(raw) {
|
|
1470
|
+
if (raw === undefined || raw === null || raw === "")
|
|
1471
|
+
return null;
|
|
1472
|
+
if (raw instanceof Date) {
|
|
1473
|
+
return Number.isNaN(raw.getTime()) ? null : raw.toISOString().slice(0, 10);
|
|
1474
|
+
}
|
|
1475
|
+
const s = String(raw).trim();
|
|
1476
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(s))
|
|
1477
|
+
return s;
|
|
1478
|
+
const parsed = new Date(s);
|
|
1479
|
+
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString().slice(0, 10);
|
|
1480
|
+
}
|
|
1481
|
+
/**
|
|
1482
|
+
* Low-signal English stopwords. Dropped before building the tsquery so that
|
|
1483
|
+
* verbose natural-language questions ("I'm checking our previous chat about…")
|
|
1484
|
+
* don't force every filler word into the match — they bloat the AND query into
|
|
1485
|
+
* a guaranteed miss and flood the OR fallback with noise.
|
|
1486
|
+
*/
|
|
1487
|
+
const STOPWORDS = new Set([
|
|
1488
|
+
"a", "an", "and", "are", "as", "at", "be", "been", "but", "by", "can", "could",
|
|
1489
|
+
"did", "do", "does", "for", "from", "had", "has", "have", "how", "i", "if", "im",
|
|
1490
|
+
"in", "into", "is", "it", "its", "just", "me", "my", "of", "on", "or", "our",
|
|
1491
|
+
"previous", "remind", "should", "so", "some", "that", "the", "their", "them",
|
|
1492
|
+
"then", "there", "these", "this", "to", "us", "was", "we", "were", "what", "when",
|
|
1493
|
+
"where", "which", "who", "why", "will", "with", "would", "you", "your",
|
|
1494
|
+
]);
|
|
1495
|
+
/** Lowercase, strip punctuation, split into tokens. */
|
|
1496
|
+
export function tokenizeQuery(query) {
|
|
1497
|
+
return query
|
|
1498
|
+
.toLowerCase()
|
|
1499
|
+
.split(/\s+/)
|
|
1500
|
+
.map((t) => t.replace(/[^\p{L}\p{N}_-]/gu, "").trim())
|
|
1501
|
+
.filter((t) => t.length > 0);
|
|
1502
|
+
}
|
|
1503
|
+
/** Drop stopwords — but never return empty (keep originals if all were stopwords). */
|
|
1504
|
+
export function stripStopwords(tokens) {
|
|
1505
|
+
const kept = tokens.filter((t) => !STOPWORDS.has(t));
|
|
1506
|
+
return kept.length > 0 ? kept : tokens;
|
|
1507
|
+
}
|
|
1508
|
+
/** Below this length a token is too short for word-boundary matching to be a useful signal (e.g. "is", "or"). */
|
|
1509
|
+
const MIN_MATCH_TOKEN_LENGTH = 3;
|
|
1510
|
+
/**
|
|
1511
|
+
* Tokens usable for a *matched-on* relevance signal: stopwords and sub-3-char
|
|
1512
|
+
* tokens excluded, since both produce false-positive substring hits (e.g. the
|
|
1513
|
+
* stopword "is" or "or" appearing inside "History"/"Editor").
|
|
1514
|
+
*/
|
|
1515
|
+
export function matchableTokens(query) {
|
|
1516
|
+
return stripStopwords(tokenizeQuery(query)).filter((t) => t.length >= MIN_MATCH_TOKEN_LENGTH);
|
|
1517
|
+
}
|
|
1518
|
+
function escapeRegExp(s) {
|
|
1519
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1520
|
+
}
|
|
1521
|
+
/**
|
|
1522
|
+
* Prefix match on a word boundary (mirrors the `token:*` prefix semantics used
|
|
1523
|
+
* in the tsquery builder below) — not a bare substring test. Prevents a token
|
|
1524
|
+
* like "type" from being credited for matching inside "stereotype".
|
|
1525
|
+
*/
|
|
1526
|
+
export function tokenMatchesText(text, token) {
|
|
1527
|
+
return new RegExp(`(?<![\\p{L}\\p{N}_])${escapeRegExp(token)}`, "iu").test(text);
|
|
1528
|
+
}
|
|
1529
|
+
/**
|
|
1530
|
+
* Convert a plain-text query string into a Postgres tsquery expression.
|
|
1531
|
+
* Tokens are joined with prefix matching (`:*`) using the given operator.
|
|
1532
|
+
*
|
|
1533
|
+
* AND (default) keeps **every** token for precision — dropping stopwords here
|
|
1534
|
+
* broadens the match and lets a competing doc outrank the true one under the
|
|
1535
|
+
* recency ordering. OR drops stopwords, since the broad fallback shouldn't be
|
|
1536
|
+
* dominated by filler words like "the"/"our".
|
|
1537
|
+
*/
|
|
1538
|
+
function toTsQuery(query, operator = "AND") {
|
|
1539
|
+
const all = tokenizeQuery(query);
|
|
1540
|
+
const tokens = operator === "OR" ? stripStopwords(all) : all;
|
|
1541
|
+
if (tokens.length === 0)
|
|
1542
|
+
return "";
|
|
1543
|
+
const sep = operator === "AND" ? " & " : " | ";
|
|
1544
|
+
return tokens.map((t) => `${t}:*`).join(sep);
|
|
1545
|
+
}
|
|
1546
|
+
/**
|
|
1547
|
+
* Relevance score for the OR fallback: how many distinct query tokens appear in
|
|
1548
|
+
* the row's searchable text. PostgREST can only order by columns (not by
|
|
1549
|
+
* ts_rank without an RPC), so the OR fetch comes back ordered by recency; we
|
|
1550
|
+
* re-rank here so the document matching the most query terms surfaces first
|
|
1551
|
+
* instead of merely the most recently modified one.
|
|
1552
|
+
*/
|
|
1553
|
+
function overlapScore(row, tokens) {
|
|
1554
|
+
const hay = `${row.title ?? ""} ${row.summary ?? ""} ${(row.tags ?? []).join(" ")} ${row.content ?? ""}`.toLowerCase();
|
|
1555
|
+
let score = 0;
|
|
1556
|
+
for (const t of tokens)
|
|
1557
|
+
if (hay.includes(t))
|
|
1558
|
+
score++;
|
|
1559
|
+
return score;
|
|
1560
|
+
}
|
|
1561
|
+
/** Stable sort by descending term-overlap; ties keep the incoming (recency) order. */
|
|
1562
|
+
export function rankByOverlap(rows, tokens) {
|
|
1563
|
+
return rows
|
|
1564
|
+
.map((row, i) => ({ row, i, score: overlapScore(row, tokens) }))
|
|
1565
|
+
.sort((a, b) => b.score - a.score || a.i - b.i)
|
|
1566
|
+
.map((x) => x.row);
|
|
1567
|
+
}
|
|
1568
|
+
/**
|
|
1569
|
+
* Per-token field match, not whole-query substring — a multi-word query like
|
|
1570
|
+
* "Lake Garnett event date" must match token-by-token, since it will almost
|
|
1571
|
+
* never appear verbatim in a title/tag/summary even on a real hit.
|
|
1572
|
+
*/
|
|
1573
|
+
function computeMatchedOn(row, query) {
|
|
1574
|
+
const tokens = matchableTokens(query);
|
|
1575
|
+
const title = row.title?.toLowerCase() ?? "";
|
|
1576
|
+
const summary = row.summary?.toLowerCase() ?? "";
|
|
1577
|
+
const tags = row.tags ?? [];
|
|
1578
|
+
const matched = new Set();
|
|
1579
|
+
for (const t of tokens) {
|
|
1580
|
+
if (tokenMatchesText(title, t))
|
|
1581
|
+
matched.add("title");
|
|
1582
|
+
if (tags.some((tag) => tokenMatchesText(tag.toLowerCase(), t)))
|
|
1583
|
+
matched.add("tags");
|
|
1584
|
+
if (tokenMatchesText(summary, t))
|
|
1585
|
+
matched.add("summary");
|
|
1586
|
+
}
|
|
1587
|
+
return [...matched];
|
|
1588
|
+
}
|
|
1589
|
+
function governanceMigrationHint(responseText) {
|
|
1590
|
+
if (!/\b(access_level|audience)\b/i.test(responseText))
|
|
1591
|
+
return "";
|
|
1592
|
+
return " Ensure scripts/governance-migration.sql has been applied.";
|
|
1593
|
+
}
|
|
1594
|
+
function escapePostgrestLiteral(value) {
|
|
1595
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
1596
|
+
}
|
|
1597
|
+
function escapeLikePattern(value) {
|
|
1598
|
+
return value
|
|
1599
|
+
.replace(/\\/g, "\\\\")
|
|
1600
|
+
.replace(/%/g, "\\%")
|
|
1601
|
+
.replace(/_/g, "\\_")
|
|
1602
|
+
.replace(/\*/g, "\\*");
|
|
1603
|
+
}
|
|
1604
|
+
function validateTagFilter(value) {
|
|
1605
|
+
if (/[{},()]/.test(value)) {
|
|
1606
|
+
throw new Error("Tag filters cannot contain '{', '}', ',', '(' or ')' characters.");
|
|
1607
|
+
}
|
|
1608
|
+
return value;
|
|
1609
|
+
}
|
|
1610
|
+
//# sourceMappingURL=postgresIndex.js.map
|