ruflo 3.10.46 → 3.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +412 -412
- package/bin/ruflo.js +77 -77
- package/package.json +118 -113
- package/src/chat-ui/Dockerfile +25 -25
- package/src/chat-ui/patch-mcp-url-safety.sh +28 -28
- package/src/config/config.example.json +76 -76
- package/src/mcp-bridge/Dockerfile +45 -45
- package/src/mcp-bridge/index.js +1692 -1692
- package/src/mcp-bridge/mcp-stdio-kernel.js +159 -159
- package/src/mcp-bridge/package.json +17 -17
- package/src/mcp-bridge/test-harness.js +470 -470
- package/src/nginx/Dockerfile +10 -10
- package/src/nginx/nginx.conf +67 -67
- package/src/nginx/static/favicon-dark.svg +4 -4
- package/src/nginx/static/favicon.svg +4 -4
- package/src/nginx/static/icon.svg +5 -5
- package/src/nginx/static/logo.svg +9 -9
- package/src/nginx/static/manifest.json +22 -22
- package/src/nginx/static/welcome.js +184 -184
- package/src/ruvocal/.claude/skills/add-model-descriptions/SKILL.md +73 -73
- package/src/ruvocal/.claude-flow/daemon-state.json +135 -0
- package/src/ruvocal/.claude-flow/data/pending-insights.jsonl +0 -0
- package/src/ruvocal/.claude-flow/data/ranked-context.json +5 -0
- package/src/ruvocal/.claude-flow/logs/daemon.log +31 -0
- package/src/ruvocal/.claude-flow/logs/headless/audit_1777949411822_juxau0_prompt.log +989 -0
- package/src/ruvocal/.claude-flow/logs/headless/audit_1777949411822_juxau0_result.log +67 -0
- package/src/ruvocal/.claude-flow/logs/headless/audit_1777950042278_jvj5xq_prompt.log +989 -0
- package/src/ruvocal/.claude-flow/logs/headless/audit_1777950042278_jvj5xq_result.log +93 -0
- package/src/ruvocal/.claude-flow/logs/headless/optimize_1777949531823_yt5yc2_prompt.log +1498 -0
- package/src/ruvocal/.claude-flow/logs/headless/optimize_1777949531823_yt5yc2_result.log +93 -0
- package/src/ruvocal/.claude-flow/logs/headless/testgaps_1777949771821_elw1j4_prompt.log +1498 -0
- package/src/ruvocal/.claude-flow/logs/headless/testgaps_1777949771821_elw1j4_result.log +100 -0
- package/src/ruvocal/.claude-flow/metrics/codebase-map.json +11 -0
- package/src/ruvocal/.claude-flow/metrics/consolidation.json +6 -0
- package/src/ruvocal/.claude-flow/neural/stats.json +6 -0
- package/src/ruvocal/.claude-flow/sessions/current.json +13 -0
- package/src/ruvocal/.devcontainer/Dockerfile +9 -9
- package/src/ruvocal/.devcontainer/devcontainer.json +36 -36
- package/src/ruvocal/.dockerignore +16 -16
- package/src/ruvocal/.eslintignore +13 -13
- package/src/ruvocal/.eslintrc.cjs +45 -45
- package/src/ruvocal/.gcloudignore +18 -18
- package/src/ruvocal/.github/ISSUE_TEMPLATE/bug-report--chat-ui-.md +43 -43
- package/src/ruvocal/.github/ISSUE_TEMPLATE/config-support.md +9 -9
- package/src/ruvocal/.github/ISSUE_TEMPLATE/feature-request--chat-ui-.md +17 -17
- package/src/ruvocal/.github/ISSUE_TEMPLATE/huggingchat.md +11 -11
- package/src/ruvocal/.github/release.yml +16 -16
- package/src/ruvocal/.github/workflows/build-docs.yml +18 -18
- package/src/ruvocal/.github/workflows/build-image.yml +142 -142
- package/src/ruvocal/.github/workflows/build-pr-docs.yml +20 -20
- package/src/ruvocal/.github/workflows/deploy-dev.yml +63 -63
- package/src/ruvocal/.github/workflows/deploy-prod.yml +78 -78
- package/src/ruvocal/.github/workflows/lint-and-test.yml +84 -84
- package/src/ruvocal/.github/workflows/slugify.yaml +72 -72
- package/src/ruvocal/.github/workflows/trufflehog.yml +17 -17
- package/src/ruvocal/.github/workflows/upload-pr-documentation.yml +16 -16
- package/src/ruvocal/.husky/lint-stage-config.js +4 -4
- package/src/ruvocal/.husky/pre-commit +2 -2
- package/src/ruvocal/.prettierignore +14 -14
- package/src/ruvocal/.prettierrc +7 -7
- package/src/ruvocal/.swarm/attestation.db +0 -0
- package/src/ruvocal/.swarm/hnsw.index +0 -0
- package/src/ruvocal/.swarm/hnsw.metadata.json +1 -0
- package/src/ruvocal/.swarm/memory.db +0 -0
- package/src/ruvocal/.swarm/schema.sql +305 -0
- package/src/ruvocal/CLAUDE.md +126 -126
- package/src/ruvocal/Dockerfile +96 -96
- package/src/ruvocal/LICENSE +202 -202
- package/src/ruvocal/PRIVACY.md +41 -41
- package/src/ruvocal/README.md +164 -164
- package/src/ruvocal/chart/Chart.yaml +5 -5
- package/src/ruvocal/chart/env/dev.yaml +260 -260
- package/src/ruvocal/chart/env/prod.yaml +273 -273
- package/src/ruvocal/chart/templates/_helpers.tpl +22 -22
- package/src/ruvocal/chart/templates/config.yaml +10 -10
- package/src/ruvocal/chart/templates/deployment.yaml +81 -81
- package/src/ruvocal/chart/templates/hpa.yaml +45 -45
- package/src/ruvocal/chart/templates/infisical.yaml +24 -24
- package/src/ruvocal/chart/templates/ingress-internal.yaml +32 -32
- package/src/ruvocal/chart/templates/ingress.yaml +32 -32
- package/src/ruvocal/chart/templates/network-policy.yaml +36 -36
- package/src/ruvocal/chart/templates/service-account.yaml +13 -13
- package/src/ruvocal/chart/templates/service-monitor.yaml +17 -17
- package/src/ruvocal/chart/templates/service.yaml +21 -21
- package/src/ruvocal/chart/values.yaml +73 -73
- package/src/ruvocal/cloudbuild.yaml +68 -68
- package/src/ruvocal/config/branding.env.example +19 -19
- package/src/ruvocal/docker-compose.yml +21 -21
- package/src/ruvocal/docs/adr/ADR-029-HUGGINGFACE-CHAT-UI-CLOUD-RUN.md +1236 -1236
- package/src/ruvocal/docs/adr/ADR-033-RUVECTOR-RUFLO-MCP-INTEGRATION.md +111 -111
- package/src/ruvocal/docs/adr/ADR-034-OPTIONAL-MCP-BACKENDS.md +117 -117
- package/src/ruvocal/docs/adr/ADR-035-MCP-TOOL-GROUPS.md +186 -186
- package/src/ruvocal/docs/adr/ADR-037-AUTOPILOT-CHAT-MODE.md +1500 -1500
- package/src/ruvocal/docs/adr/ADR-038-RUVOCAL-FORK.md +286 -286
- package/src/ruvocal/docs/source/_toctree.yml +30 -30
- package/src/ruvocal/docs/source/configuration/common-issues.md +38 -38
- package/src/ruvocal/docs/source/configuration/llm-router.md +105 -105
- package/src/ruvocal/docs/source/configuration/mcp-tools.md +84 -84
- package/src/ruvocal/docs/source/configuration/metrics.md +9 -9
- package/src/ruvocal/docs/source/configuration/open-id.md +57 -57
- package/src/ruvocal/docs/source/configuration/overview.md +89 -89
- package/src/ruvocal/docs/source/configuration/theming.md +20 -20
- package/src/ruvocal/docs/source/developing/architecture.md +48 -48
- package/src/ruvocal/docs/source/index.md +53 -53
- package/src/ruvocal/docs/source/installation/docker.md +43 -43
- package/src/ruvocal/docs/source/installation/helm.md +43 -43
- package/src/ruvocal/docs/source/installation/local.md +62 -62
- package/src/ruvocal/entrypoint.sh +18 -18
- package/src/ruvocal/mcp-bridge/Dockerfile +45 -45
- package/src/ruvocal/mcp-bridge/cloudbuild.yaml +49 -49
- package/src/ruvocal/mcp-bridge/index.js +1902 -1902
- package/src/ruvocal/mcp-bridge/mcp-stdio-kernel.js +159 -159
- package/src/ruvocal/mcp-bridge/package-lock.json +762 -762
- package/src/ruvocal/mcp-bridge/package.json +17 -17
- package/src/ruvocal/mcp-bridge/test-harness.js +470 -470
- package/src/ruvocal/package-lock.json +11741 -11741
- package/src/ruvocal/package.json +121 -121
- package/src/ruvocal/postcss.config.js +6 -6
- package/src/ruvocal/rvf.manifest.json +204 -204
- package/src/ruvocal/scripts/config.ts +64 -64
- package/src/ruvocal/scripts/generate-welcome.mjs +181 -181
- package/src/ruvocal/scripts/populate.ts +288 -288
- package/src/ruvocal/scripts/samples.txt +194 -194
- package/src/ruvocal/scripts/setups/vitest-setup-server.ts +44 -44
- package/src/ruvocal/scripts/updateLocalEnv.ts +48 -48
- package/src/ruvocal/src/ambient.d.ts +7 -7
- package/src/ruvocal/src/app.d.ts +29 -29
- package/src/ruvocal/src/app.html +53 -53
- package/src/ruvocal/src/hooks.server.ts +32 -32
- package/src/ruvocal/src/hooks.ts +6 -6
- package/src/ruvocal/src/lib/APIClient.ts +148 -148
- package/src/ruvocal/src/lib/actions/clickOutside.ts +18 -18
- package/src/ruvocal/src/lib/actions/snapScrollToBottom.ts +346 -346
- package/src/ruvocal/src/lib/buildPrompt.ts +33 -33
- package/src/ruvocal/src/lib/components/AnnouncementBanner.svelte +20 -20
- package/src/ruvocal/src/lib/components/BackgroundGenerationPoller.svelte +168 -168
- package/src/ruvocal/src/lib/components/CodeBlock.svelte +73 -73
- package/src/ruvocal/src/lib/components/CopyToClipBoardBtn.svelte +92 -92
- package/src/ruvocal/src/lib/components/DeleteConversationModal.svelte +75 -75
- package/src/ruvocal/src/lib/components/EditConversationModal.svelte +100 -100
- package/src/ruvocal/src/lib/components/ExpandNavigation.svelte +22 -22
- package/src/ruvocal/src/lib/components/FoundationBackground.svelte +242 -242
- package/src/ruvocal/src/lib/components/HoverTooltip.svelte +44 -44
- package/src/ruvocal/src/lib/components/HtmlPreviewModal.svelte +143 -143
- package/src/ruvocal/src/lib/components/InfiniteScroll.svelte +50 -50
- package/src/ruvocal/src/lib/components/MobileNav.svelte +300 -300
- package/src/ruvocal/src/lib/components/Modal.svelte +115 -115
- package/src/ruvocal/src/lib/components/ModelCardMetadata.svelte +71 -71
- package/src/ruvocal/src/lib/components/NavConversationItem.svelte +151 -151
- package/src/ruvocal/src/lib/components/NavMenu.svelte +313 -313
- package/src/ruvocal/src/lib/components/Pagination.svelte +97 -97
- package/src/ruvocal/src/lib/components/PaginationArrow.svelte +27 -27
- package/src/ruvocal/src/lib/components/Portal.svelte +24 -24
- package/src/ruvocal/src/lib/components/RetryBtn.svelte +18 -18
- package/src/ruvocal/src/lib/components/RuFloUniverse.svelte +185 -185
- package/src/ruvocal/src/lib/components/RufloHelpModal.svelte +411 -411
- package/src/ruvocal/src/lib/components/ScrollToBottomBtn.svelte +47 -47
- package/src/ruvocal/src/lib/components/ScrollToPreviousBtn.svelte +77 -77
- package/src/ruvocal/src/lib/components/ShareConversationModal.svelte +182 -182
- package/src/ruvocal/src/lib/components/StopGeneratingBtn.svelte +69 -69
- package/src/ruvocal/src/lib/components/SubscribeModal.svelte +87 -87
- package/src/ruvocal/src/lib/components/Switch.svelte +36 -36
- package/src/ruvocal/src/lib/components/SystemPromptModal.svelte +44 -44
- package/src/ruvocal/src/lib/components/Toast.svelte +27 -27
- package/src/ruvocal/src/lib/components/Tooltip.svelte +30 -30
- package/src/ruvocal/src/lib/components/WelcomeModal.svelte +46 -46
- package/src/ruvocal/src/lib/components/chat/Alternatives.svelte +77 -77
- package/src/ruvocal/src/lib/components/chat/BlockWrapper.svelte +72 -72
- package/src/ruvocal/src/lib/components/chat/ChatInput.svelte +490 -490
- package/src/ruvocal/src/lib/components/chat/ChatIntroduction.svelte +123 -123
- package/src/ruvocal/src/lib/components/chat/ChatMessage.svelte +548 -548
- package/src/ruvocal/src/lib/components/chat/ChatWindow.svelte +1057 -1057
- package/src/ruvocal/src/lib/components/chat/FileDropzone.svelte +92 -92
- package/src/ruvocal/src/lib/components/chat/ImageLightbox.svelte +66 -66
- package/src/ruvocal/src/lib/components/chat/MarkdownBlock.svelte +23 -23
- package/src/ruvocal/src/lib/components/chat/MarkdownRenderer.svelte +69 -69
- package/src/ruvocal/src/lib/components/chat/MarkdownRenderer.svelte.test.ts +58 -58
- package/src/ruvocal/src/lib/components/chat/MessageAvatar.svelte +103 -103
- package/src/ruvocal/src/lib/components/chat/ModelSwitch.svelte +64 -64
- package/src/ruvocal/src/lib/components/chat/OpenReasoningResults.svelte +81 -81
- package/src/ruvocal/src/lib/components/chat/TaskGroup.svelte +88 -88
- package/src/ruvocal/src/lib/components/chat/ToolUpdate.svelte +273 -273
- package/src/ruvocal/src/lib/components/chat/UploadedFile.svelte +253 -253
- package/src/ruvocal/src/lib/components/chat/UrlFetchModal.svelte +203 -203
- package/src/ruvocal/src/lib/components/chat/VoiceRecorder.svelte +214 -214
- package/src/ruvocal/src/lib/components/icons/IconBurger.svelte +20 -20
- package/src/ruvocal/src/lib/components/icons/IconCheap.svelte +20 -20
- package/src/ruvocal/src/lib/components/icons/IconChevron.svelte +24 -24
- package/src/ruvocal/src/lib/components/icons/IconDazzled.svelte +40 -40
- package/src/ruvocal/src/lib/components/icons/IconFast.svelte +20 -20
- package/src/ruvocal/src/lib/components/icons/IconLoading.svelte +22 -22
- package/src/ruvocal/src/lib/components/icons/IconMCP.svelte +28 -28
- package/src/ruvocal/src/lib/components/icons/IconMoon.svelte +21 -21
- package/src/ruvocal/src/lib/components/icons/IconNew.svelte +20 -20
- package/src/ruvocal/src/lib/components/icons/IconOmni.svelte +90 -90
- package/src/ruvocal/src/lib/components/icons/IconPaperclip.svelte +24 -24
- package/src/ruvocal/src/lib/components/icons/IconPro.svelte +37 -37
- package/src/ruvocal/src/lib/components/icons/IconShare.svelte +21 -21
- package/src/ruvocal/src/lib/components/icons/IconSun.svelte +93 -93
- package/src/ruvocal/src/lib/components/icons/Logo.svelte +68 -68
- package/src/ruvocal/src/lib/components/icons/LogoHuggingFaceBorderless.svelte +54 -54
- package/src/ruvocal/src/lib/components/mcp/AddServerForm.svelte +250 -250
- package/src/ruvocal/src/lib/components/mcp/MCPServerManager.svelte +185 -185
- package/src/ruvocal/src/lib/components/mcp/ServerCard.svelte +203 -203
- package/src/ruvocal/src/lib/components/players/AudioPlayer.svelte +82 -82
- package/src/ruvocal/src/lib/components/voice/AudioWaveform.svelte +96 -96
- package/src/ruvocal/src/lib/components/wasm/GalleryPanel.svelte +357 -357
- package/src/ruvocal/src/lib/constants/mcpExamples.ts +114 -114
- package/src/ruvocal/src/lib/constants/mime.ts +11 -11
- package/src/ruvocal/src/lib/constants/pagination.ts +1 -1
- package/src/ruvocal/src/lib/constants/publicSepToken.ts +1 -1
- package/src/ruvocal/src/lib/constants/routerExamples.ts +133 -133
- package/src/ruvocal/src/lib/constants/rvagentPresets.ts +206 -206
- package/src/ruvocal/src/lib/createShareLink.ts +27 -27
- package/src/ruvocal/src/lib/jobs/refresh-conversation-stats.ts +297 -297
- package/src/ruvocal/src/lib/migrations/lock.ts +56 -56
- package/src/ruvocal/src/lib/migrations/migrations.spec.ts +74 -74
- package/src/ruvocal/src/lib/migrations/migrations.ts +109 -109
- package/src/ruvocal/src/lib/migrations/routines/01-update-search-assistants.ts +50 -50
- package/src/ruvocal/src/lib/migrations/routines/02-update-assistants-models.ts +48 -48
- package/src/ruvocal/src/lib/migrations/routines/04-update-message-updates.ts +151 -151
- package/src/ruvocal/src/lib/migrations/routines/05-update-message-files.ts +56 -56
- package/src/ruvocal/src/lib/migrations/routines/06-trim-message-updates.ts +56 -56
- package/src/ruvocal/src/lib/migrations/routines/08-update-featured-to-review.ts +32 -32
- package/src/ruvocal/src/lib/migrations/routines/09-delete-empty-conversations.spec.ts +214 -214
- package/src/ruvocal/src/lib/migrations/routines/09-delete-empty-conversations.ts +88 -88
- package/src/ruvocal/src/lib/migrations/routines/10-update-reports-assistantid.ts +29 -29
- package/src/ruvocal/src/lib/migrations/routines/index.ts +15 -15
- package/src/ruvocal/src/lib/server/__tests__/conversation-stop-generating.spec.ts +103 -103
- package/src/ruvocal/src/lib/server/abortRegistry.ts +57 -57
- package/src/ruvocal/src/lib/server/abortedGenerations.ts +43 -43
- package/src/ruvocal/src/lib/server/adminToken.ts +62 -62
- package/src/ruvocal/src/lib/server/api/__tests__/conversations-id.spec.ts +296 -296
- package/src/ruvocal/src/lib/server/api/__tests__/conversations-message.spec.ts +216 -216
- package/src/ruvocal/src/lib/server/api/__tests__/conversations.spec.ts +235 -235
- package/src/ruvocal/src/lib/server/api/__tests__/misc.spec.ts +72 -72
- package/src/ruvocal/src/lib/server/api/__tests__/testHelpers.ts +86 -86
- package/src/ruvocal/src/lib/server/api/__tests__/user-reports.spec.ts +78 -78
- package/src/ruvocal/src/lib/server/api/__tests__/user.spec.ts +239 -239
- package/src/ruvocal/src/lib/server/api/types.ts +37 -37
- package/src/ruvocal/src/lib/server/api/utils/requireAuth.ts +22 -22
- package/src/ruvocal/src/lib/server/api/utils/resolveConversation.ts +69 -69
- package/src/ruvocal/src/lib/server/api/utils/resolveModel.ts +27 -27
- package/src/ruvocal/src/lib/server/api/utils/superjsonResponse.ts +15 -15
- package/src/ruvocal/src/lib/server/apiToken.ts +11 -11
- package/src/ruvocal/src/lib/server/auth.ts +554 -554
- package/src/ruvocal/src/lib/server/config.ts +187 -187
- package/src/ruvocal/src/lib/server/conversation.ts +83 -83
- package/src/ruvocal/src/lib/server/database/__tests__/rvf.spec.ts +709 -709
- package/src/ruvocal/src/lib/server/database/postgres.ts +700 -700
- package/src/ruvocal/src/lib/server/database/rvf.ts +1078 -1078
- package/src/ruvocal/src/lib/server/database.ts +145 -145
- package/src/ruvocal/src/lib/server/endpoints/document.ts +68 -68
- package/src/ruvocal/src/lib/server/endpoints/endpoints.ts +43 -43
- package/src/ruvocal/src/lib/server/endpoints/images.ts +211 -211
- package/src/ruvocal/src/lib/server/endpoints/openai/endpointOai.ts +266 -266
- package/src/ruvocal/src/lib/server/endpoints/openai/openAIChatToTextGenerationStream.ts +212 -212
- package/src/ruvocal/src/lib/server/endpoints/openai/openAICompletionToTextGenerationStream.ts +32 -32
- package/src/ruvocal/src/lib/server/endpoints/preprocessMessages.ts +61 -61
- package/src/ruvocal/src/lib/server/exitHandler.ts +59 -59
- package/src/ruvocal/src/lib/server/files/downloadFile.ts +34 -34
- package/src/ruvocal/src/lib/server/files/uploadFile.ts +29 -29
- package/src/ruvocal/src/lib/server/findRepoRoot.ts +13 -13
- package/src/ruvocal/src/lib/server/generateFromDefaultEndpoint.ts +46 -46
- package/src/ruvocal/src/lib/server/hooks/error.ts +37 -37
- package/src/ruvocal/src/lib/server/hooks/fetch.ts +22 -22
- package/src/ruvocal/src/lib/server/hooks/handle.ts +250 -250
- package/src/ruvocal/src/lib/server/hooks/init.ts +51 -51
- package/src/ruvocal/src/lib/server/isURLLocal.spec.ts +31 -31
- package/src/ruvocal/src/lib/server/isURLLocal.ts +74 -74
- package/src/ruvocal/src/lib/server/logger.ts +42 -42
- package/src/ruvocal/src/lib/server/mcp/clientPool.spec.ts +175 -175
- package/src/ruvocal/src/lib/server/mcp/hf.ts +32 -32
- package/src/ruvocal/src/lib/server/mcp/httpClient.ts +122 -122
- package/src/ruvocal/src/lib/server/mcp/registry.ts +76 -76
- package/src/ruvocal/src/lib/server/mcp/tools.ts +196 -196
- package/src/ruvocal/src/lib/server/metrics.ts +255 -255
- package/src/ruvocal/src/lib/server/models.ts +518 -518
- package/src/ruvocal/src/lib/server/requestContext.ts +55 -55
- package/src/ruvocal/src/lib/server/router/arch.ts +230 -230
- package/src/ruvocal/src/lib/server/router/endpoint.ts +316 -316
- package/src/ruvocal/src/lib/server/router/multimodal.ts +28 -28
- package/src/ruvocal/src/lib/server/router/policy.ts +49 -49
- package/src/ruvocal/src/lib/server/router/toolsRoute.ts +51 -51
- package/src/ruvocal/src/lib/server/router/types.ts +21 -21
- package/src/ruvocal/src/lib/server/sendSlack.ts +23 -23
- package/src/ruvocal/src/lib/server/textGeneration/generate.ts +258 -258
- package/src/ruvocal/src/lib/server/textGeneration/index.ts +96 -96
- package/src/ruvocal/src/lib/server/textGeneration/mcp/fileRefs.ts +155 -155
- package/src/ruvocal/src/lib/server/textGeneration/mcp/routerResolution.ts +108 -108
- package/src/ruvocal/src/lib/server/textGeneration/mcp/runMcpFlow.ts +831 -831
- package/src/ruvocal/src/lib/server/textGeneration/mcp/toolInvocation.ts +349 -349
- package/src/ruvocal/src/lib/server/textGeneration/mcp/wasmTools.test.ts +633 -633
- package/src/ruvocal/src/lib/server/textGeneration/reasoning.ts +23 -23
- package/src/ruvocal/src/lib/server/textGeneration/title.ts +83 -83
- package/src/ruvocal/src/lib/server/textGeneration/types.ts +28 -28
- package/src/ruvocal/src/lib/server/textGeneration/utils/prepareFiles.ts +88 -88
- package/src/ruvocal/src/lib/server/textGeneration/utils/routing.ts +21 -21
- package/src/ruvocal/src/lib/server/textGeneration/utils/toolPrompt.ts +49 -49
- package/src/ruvocal/src/lib/server/urlSafety.ts +77 -77
- package/src/ruvocal/src/lib/server/usageLimits.ts +30 -30
- package/src/ruvocal/src/lib/stores/autopilotStore.svelte.ts +175 -175
- package/src/ruvocal/src/lib/stores/backgroundGenerations.svelte.ts +32 -32
- package/src/ruvocal/src/lib/stores/backgroundGenerations.ts +1 -1
- package/src/ruvocal/src/lib/stores/errors.ts +9 -9
- package/src/ruvocal/src/lib/stores/isAborted.ts +3 -3
- package/src/ruvocal/src/lib/stores/isPro.ts +4 -4
- package/src/ruvocal/src/lib/stores/loading.ts +3 -3
- package/src/ruvocal/src/lib/stores/mcpServers.ts +534 -534
- package/src/ruvocal/src/lib/stores/pendingChatInput.ts +3 -3
- package/src/ruvocal/src/lib/stores/pendingMessage.ts +9 -9
- package/src/ruvocal/src/lib/stores/settings.ts +182 -182
- package/src/ruvocal/src/lib/stores/shareModal.ts +13 -13
- package/src/ruvocal/src/lib/stores/titleUpdate.ts +8 -8
- package/src/ruvocal/src/lib/stores/wasmMcp.ts +472 -472
- package/src/ruvocal/src/lib/switchTheme.ts +124 -124
- package/src/ruvocal/src/lib/types/AbortedGeneration.ts +8 -8
- package/src/ruvocal/src/lib/types/Assistant.ts +31 -31
- package/src/ruvocal/src/lib/types/AssistantStats.ts +11 -11
- package/src/ruvocal/src/lib/types/ConfigKey.ts +4 -4
- package/src/ruvocal/src/lib/types/ConvSidebar.ts +9 -9
- package/src/ruvocal/src/lib/types/Conversation.ts +27 -27
- package/src/ruvocal/src/lib/types/ConversationStats.ts +13 -13
- package/src/ruvocal/src/lib/types/Message.ts +41 -41
- package/src/ruvocal/src/lib/types/MessageEvent.ts +10 -10
- package/src/ruvocal/src/lib/types/MessageUpdate.ts +139 -139
- package/src/ruvocal/src/lib/types/MigrationResult.ts +7 -7
- package/src/ruvocal/src/lib/types/Model.ts +23 -23
- package/src/ruvocal/src/lib/types/Report.ts +12 -12
- package/src/ruvocal/src/lib/types/Review.ts +6 -6
- package/src/ruvocal/src/lib/types/Semaphore.ts +19 -19
- package/src/ruvocal/src/lib/types/Session.ts +22 -22
- package/src/ruvocal/src/lib/types/Settings.ts +93 -93
- package/src/ruvocal/src/lib/types/SharedConversation.ts +9 -9
- package/src/ruvocal/src/lib/types/Template.ts +6 -6
- package/src/ruvocal/src/lib/types/Timestamps.ts +4 -4
- package/src/ruvocal/src/lib/types/TokenCache.ts +6 -6
- package/src/ruvocal/src/lib/types/Tool.ts +77 -77
- package/src/ruvocal/src/lib/types/UrlDependency.ts +5 -5
- package/src/ruvocal/src/lib/types/User.ts +14 -14
- package/src/ruvocal/src/lib/utils/PublicConfig.svelte.ts +75 -75
- package/src/ruvocal/src/lib/utils/auth.ts +17 -17
- package/src/ruvocal/src/lib/utils/chunk.ts +33 -33
- package/src/ruvocal/src/lib/utils/cookiesAreEnabled.ts +13 -13
- package/src/ruvocal/src/lib/utils/debounce.ts +17 -17
- package/src/ruvocal/src/lib/utils/deepestChild.ts +6 -6
- package/src/ruvocal/src/lib/utils/favicon.ts +21 -21
- package/src/ruvocal/src/lib/utils/fetchJSON.ts +23 -23
- package/src/ruvocal/src/lib/utils/file2base64.ts +14 -14
- package/src/ruvocal/src/lib/utils/formatUserCount.ts +37 -37
- package/src/ruvocal/src/lib/utils/generationState.spec.ts +75 -75
- package/src/ruvocal/src/lib/utils/generationState.ts +26 -26
- package/src/ruvocal/src/lib/utils/getHref.ts +41 -41
- package/src/ruvocal/src/lib/utils/getReturnFromGenerator.ts +7 -7
- package/src/ruvocal/src/lib/utils/haptics.ts +64 -64
- package/src/ruvocal/src/lib/utils/hashConv.ts +12 -12
- package/src/ruvocal/src/lib/utils/hf.ts +17 -17
- package/src/ruvocal/src/lib/utils/isDesktop.ts +7 -7
- package/src/ruvocal/src/lib/utils/isUrl.ts +8 -8
- package/src/ruvocal/src/lib/utils/isVirtualKeyboard.ts +16 -16
- package/src/ruvocal/src/lib/utils/loadAttachmentsFromUrls.ts +115 -115
- package/src/ruvocal/src/lib/utils/marked.spec.ts +96 -96
- package/src/ruvocal/src/lib/utils/marked.ts +531 -531
- package/src/ruvocal/src/lib/utils/mcpValidation.ts +147 -147
- package/src/ruvocal/src/lib/utils/mergeAsyncGenerators.ts +38 -38
- package/src/ruvocal/src/lib/utils/messageUpdates.spec.ts +262 -262
- package/src/ruvocal/src/lib/utils/messageUpdates.ts +324 -324
- package/src/ruvocal/src/lib/utils/mime.ts +56 -56
- package/src/ruvocal/src/lib/utils/models.ts +14 -14
- package/src/ruvocal/src/lib/utils/parseBlocks.ts +120 -120
- package/src/ruvocal/src/lib/utils/parseIncompleteMarkdown.ts +644 -644
- package/src/ruvocal/src/lib/utils/parseStringToList.ts +10 -10
- package/src/ruvocal/src/lib/utils/randomUuid.ts +14 -14
- package/src/ruvocal/src/lib/utils/searchTokens.ts +33 -33
- package/src/ruvocal/src/lib/utils/sha256.ts +7 -7
- package/src/ruvocal/src/lib/utils/stringifyError.ts +12 -12
- package/src/ruvocal/src/lib/utils/sum.ts +3 -3
- package/src/ruvocal/src/lib/utils/template.spec.ts +59 -59
- package/src/ruvocal/src/lib/utils/template.ts +53 -53
- package/src/ruvocal/src/lib/utils/timeout.ts +9 -9
- package/src/ruvocal/src/lib/utils/toolProgress.spec.ts +46 -46
- package/src/ruvocal/src/lib/utils/toolProgress.ts +11 -11
- package/src/ruvocal/src/lib/utils/tree/addChildren.spec.ts +102 -102
- package/src/ruvocal/src/lib/utils/tree/addChildren.ts +48 -48
- package/src/ruvocal/src/lib/utils/tree/addSibling.spec.ts +81 -81
- package/src/ruvocal/src/lib/utils/tree/addSibling.ts +41 -41
- package/src/ruvocal/src/lib/utils/tree/buildSubtree.spec.ts +110 -110
- package/src/ruvocal/src/lib/utils/tree/buildSubtree.ts +24 -24
- package/src/ruvocal/src/lib/utils/tree/convertLegacyConversation.spec.ts +31 -31
- package/src/ruvocal/src/lib/utils/tree/convertLegacyConversation.ts +36 -36
- package/src/ruvocal/src/lib/utils/tree/isMessageId.spec.ts +15 -15
- package/src/ruvocal/src/lib/utils/tree/isMessageId.ts +5 -5
- package/src/ruvocal/src/lib/utils/tree/tree.d.ts +14 -14
- package/src/ruvocal/src/lib/utils/tree/treeHelpers.spec.ts +167 -167
- package/src/ruvocal/src/lib/utils/updates.ts +39 -39
- package/src/ruvocal/src/lib/utils/urlParams.ts +13 -13
- package/src/ruvocal/src/lib/wasm/idb.ts +438 -438
- package/src/ruvocal/src/lib/wasm/index.ts +1213 -1213
- package/src/ruvocal/src/lib/wasm/tests/wasm-capabilities.test.ts +565 -565
- package/src/ruvocal/src/lib/wasm/wasm.worker.ts +332 -332
- package/src/ruvocal/src/lib/wasm/workerClient.ts +166 -166
- package/src/ruvocal/src/lib/workers/autopilotWorker.ts +221 -221
- package/src/ruvocal/src/lib/workers/detailFetchWorker.ts +100 -100
- package/src/ruvocal/src/lib/workers/markdownWorker.ts +61 -61
- package/src/ruvocal/src/routes/+error.svelte +20 -20
- package/src/ruvocal/src/routes/+layout.svelte +324 -324
- package/src/ruvocal/src/routes/+layout.ts +91 -91
- package/src/ruvocal/src/routes/+page.svelte +168 -168
- package/src/ruvocal/src/routes/.well-known/oauth-cimd/+server.ts +37 -37
- package/src/ruvocal/src/routes/__debug/openai/+server.ts +21 -21
- package/src/ruvocal/src/routes/admin/export/+server.ts +159 -159
- package/src/ruvocal/src/routes/admin/stats/compute/+server.ts +16 -16
- package/src/ruvocal/src/routes/api/conversation/[id]/+server.ts +40 -40
- package/src/ruvocal/src/routes/api/conversation/[id]/message/[messageId]/+server.ts +42 -42
- package/src/ruvocal/src/routes/api/conversations/+server.ts +48 -48
- package/src/ruvocal/src/routes/api/fetch-url/+server.ts +147 -147
- package/src/ruvocal/src/routes/api/mcp/health/+server.ts +292 -292
- package/src/ruvocal/src/routes/api/mcp/servers/+server.ts +32 -32
- package/src/ruvocal/src/routes/api/models/+server.ts +25 -25
- package/src/ruvocal/src/routes/api/transcribe/+server.ts +104 -104
- package/src/ruvocal/src/routes/api/user/+server.ts +15 -15
- package/src/ruvocal/src/routes/api/user/validate-token/+server.ts +20 -20
- package/src/ruvocal/src/routes/api/v2/conversations/+server.ts +48 -48
- package/src/ruvocal/src/routes/api/v2/conversations/[id]/+server.ts +94 -94
- package/src/ruvocal/src/routes/api/v2/conversations/[id]/message/[messageId]/+server.ts +43 -43
- package/src/ruvocal/src/routes/api/v2/conversations/import-share/+server.ts +23 -23
- package/src/ruvocal/src/routes/api/v2/debug/config/+server.ts +16 -16
- package/src/ruvocal/src/routes/api/v2/debug/refresh/+server.ts +30 -30
- package/src/ruvocal/src/routes/api/v2/export/+server.ts +196 -196
- package/src/ruvocal/src/routes/api/v2/feature-flags/+server.ts +14 -14
- package/src/ruvocal/src/routes/api/v2/models/+server.ts +38 -38
- package/src/ruvocal/src/routes/api/v2/models/[namespace]/+server.ts +8 -8
- package/src/ruvocal/src/routes/api/v2/models/[namespace]/[model]/+server.ts +8 -8
- package/src/ruvocal/src/routes/api/v2/models/[namespace]/[model]/subscribe/+server.ts +28 -28
- package/src/ruvocal/src/routes/api/v2/models/[namespace]/subscribe/+server.ts +28 -28
- package/src/ruvocal/src/routes/api/v2/models/old/+server.ts +7 -7
- package/src/ruvocal/src/routes/api/v2/models/refresh/+server.ts +33 -33
- package/src/ruvocal/src/routes/api/v2/public-config/+server.ts +7 -7
- package/src/ruvocal/src/routes/api/v2/user/+server.ts +17 -17
- package/src/ruvocal/src/routes/api/v2/user/billing-orgs/+server.ts +73 -73
- package/src/ruvocal/src/routes/api/v2/user/reports/+server.ts +17 -17
- package/src/ruvocal/src/routes/api/v2/user/settings/+server.ts +110 -110
- package/src/ruvocal/src/routes/conversation/+server.ts +115 -115
- package/src/ruvocal/src/routes/conversation/[id]/+page.svelte +586 -586
- package/src/ruvocal/src/routes/conversation/[id]/+page.ts +60 -60
- package/src/ruvocal/src/routes/conversation/[id]/+server.ts +740 -740
- package/src/ruvocal/src/routes/conversation/[id]/message/[messageId]/prompt/+server.ts +66 -66
- package/src/ruvocal/src/routes/conversation/[id]/share/+server.ts +69 -69
- package/src/ruvocal/src/routes/conversation/[id]/stop-generating/+server.ts +35 -35
- package/src/ruvocal/src/routes/healthcheck/+server.ts +3 -3
- package/src/ruvocal/src/routes/login/+server.ts +5 -5
- package/src/ruvocal/src/routes/login/callback/+server.ts +103 -103
- package/src/ruvocal/src/routes/login/callback/updateUser.spec.ts +157 -157
- package/src/ruvocal/src/routes/login/callback/updateUser.ts +215 -215
- package/src/ruvocal/src/routes/logout/+server.ts +18 -18
- package/src/ruvocal/src/routes/metrics/+server.ts +18 -18
- package/src/ruvocal/src/routes/models/+page.svelte +233 -233
- package/src/ruvocal/src/routes/models/[...model]/+page.svelte +161 -161
- package/src/ruvocal/src/routes/models/[...model]/+page.ts +14 -14
- package/src/ruvocal/src/routes/models/[...model]/thumbnail.png/+server.ts +64 -64
- package/src/ruvocal/src/routes/models/[...model]/thumbnail.png/ModelThumbnail.svelte +28 -28
- package/src/ruvocal/src/routes/privacy/+page.svelte +11 -11
- package/src/ruvocal/src/routes/r/[id]/+page.ts +34 -34
- package/src/ruvocal/src/routes/settings/(nav)/+layout.svelte +282 -282
- package/src/ruvocal/src/routes/settings/(nav)/+layout.ts +1 -1
- package/src/ruvocal/src/routes/settings/(nav)/+server.ts +59 -59
- package/src/ruvocal/src/routes/settings/(nav)/[...model]/+page.svelte +464 -464
- package/src/ruvocal/src/routes/settings/(nav)/[...model]/+page.ts +14 -14
- package/src/ruvocal/src/routes/settings/(nav)/application/+page.svelte +362 -362
- package/src/ruvocal/src/routes/settings/+layout.svelte +40 -40
- package/src/ruvocal/src/styles/highlight-js.css +195 -195
- package/src/ruvocal/src/styles/main.css +144 -144
- package/src/ruvocal/static/chatui/favicon-dark.svg +3 -3
- package/src/ruvocal/static/chatui/favicon-dev.svg +3 -3
- package/src/ruvocal/static/chatui/favicon.svg +3 -3
- package/src/ruvocal/static/chatui/icon.svg +3 -3
- package/src/ruvocal/static/chatui/logo.svg +7 -7
- package/src/ruvocal/static/chatui/manifest.json +54 -54
- package/src/ruvocal/static/chatui/welcome.js +184 -184
- package/src/ruvocal/static/huggingchat/favicon-dark.svg +4 -4
- package/src/ruvocal/static/huggingchat/favicon-dev.svg +4 -4
- package/src/ruvocal/static/huggingchat/favicon.svg +4 -4
- package/src/ruvocal/static/huggingchat/fulltext-logo.svg +1 -1
- package/src/ruvocal/static/huggingchat/icon.svg +4 -4
- package/src/ruvocal/static/huggingchat/logo.svg +4 -4
- package/src/ruvocal/static/huggingchat/manifest.json +54 -54
- package/src/ruvocal/static/huggingchat/routes.chat.json +226 -226
- package/src/ruvocal/static/robots.txt +10 -10
- package/src/ruvocal/static/wasm/rvagent_wasm.js +1539 -1539
- package/src/ruvocal/stub/@reflink/reflink/package.json +5 -5
- package/src/ruvocal/svelte.config.js +53 -53
- package/src/ruvocal/tailwind.config.cjs +30 -30
- package/src/ruvocal/tsconfig.json +19 -19
- package/src/ruvocal/vite.config.ts +87 -87
- package/src/scripts/deploy.sh +116 -116
- package/src/scripts/generate-config.js +245 -245
- package/src/scripts/generate-welcome.js +187 -187
- package/src/scripts/package-rvf.sh +116 -116
|
@@ -1,700 +1,700 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* PostgreSQL adapter for RuVocal — drop-in replacement for MongoDB collections.
|
|
3
|
-
*
|
|
4
|
-
* Implements the MongoDB Collection interface used by HF Chat UI,
|
|
5
|
-
* translating find/insert/update/delete/aggregate calls to SQL.
|
|
6
|
-
*
|
|
7
|
-
* Uses the `pg` driver with connection pooling. ObjectId fields are
|
|
8
|
-
* mapped to UUID. Messages remain embedded in conversations as JSONB
|
|
9
|
-
* to minimise upstream diff.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import pg from "pg";
|
|
13
|
-
import { randomUUID } from "crypto";
|
|
14
|
-
import { logger } from "$lib/server/logger";
|
|
15
|
-
|
|
16
|
-
const { Pool } = pg;
|
|
17
|
-
|
|
18
|
-
let pool: pg.Pool | null = null;
|
|
19
|
-
|
|
20
|
-
export function getPool(): pg.Pool {
|
|
21
|
-
if (!pool) {
|
|
22
|
-
const connectionString =
|
|
23
|
-
process.env.DATABASE_URL ||
|
|
24
|
-
"postgresql://ruvocal:ruvocal@localhost:5432/ruvocal";
|
|
25
|
-
pool = new Pool({
|
|
26
|
-
connectionString,
|
|
27
|
-
max: 20,
|
|
28
|
-
idleTimeoutMillis: 30_000,
|
|
29
|
-
connectionTimeoutMillis: 5_000,
|
|
30
|
-
});
|
|
31
|
-
pool.on("error", (err) => logger.error(err, "Postgres pool error"));
|
|
32
|
-
}
|
|
33
|
-
return pool;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export async function closePool(): Promise<void> {
|
|
37
|
-
if (pool) {
|
|
38
|
-
await pool.end();
|
|
39
|
-
pool = null;
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// ---------------------------------------------------------------------------
|
|
44
|
-
// ObjectId compatibility
|
|
45
|
-
// ---------------------------------------------------------------------------
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Minimal ObjectId stand-in that wraps a UUID string.
|
|
49
|
-
* MongoDB's ObjectId is a 24-hex-char string; we use UUID v4 instead.
|
|
50
|
-
*/
|
|
51
|
-
export class ObjectId {
|
|
52
|
-
private _id: string;
|
|
53
|
-
constructor(id?: string) {
|
|
54
|
-
this._id = id ?? randomUUID();
|
|
55
|
-
}
|
|
56
|
-
toString() {
|
|
57
|
-
return this._id;
|
|
58
|
-
}
|
|
59
|
-
toHexString() {
|
|
60
|
-
return this._id;
|
|
61
|
-
}
|
|
62
|
-
equals(other: ObjectId | string) {
|
|
63
|
-
const otherStr = typeof other === "string" ? other : other.toString();
|
|
64
|
-
return this._id === otherStr;
|
|
65
|
-
}
|
|
66
|
-
toJSON() {
|
|
67
|
-
return this._id;
|
|
68
|
-
}
|
|
69
|
-
static createFromHexString(hex: string) {
|
|
70
|
-
return new ObjectId(hex);
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// ---------------------------------------------------------------------------
|
|
75
|
-
// MongoDB-compatible filter → SQL WHERE
|
|
76
|
-
// ---------------------------------------------------------------------------
|
|
77
|
-
|
|
78
|
-
interface FilterOp {
|
|
79
|
-
text: string;
|
|
80
|
-
values: unknown[];
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function filterToWhere(
|
|
84
|
-
filter: Record<string, unknown>,
|
|
85
|
-
startIdx = 1
|
|
86
|
-
): FilterOp {
|
|
87
|
-
const clauses: string[] = [];
|
|
88
|
-
const values: unknown[] = [];
|
|
89
|
-
let idx = startIdx;
|
|
90
|
-
|
|
91
|
-
for (const [key, val] of Object.entries(filter)) {
|
|
92
|
-
if (key === "$or" && Array.isArray(val)) {
|
|
93
|
-
const orClauses: string[] = [];
|
|
94
|
-
for (const sub of val) {
|
|
95
|
-
const r = filterToWhere(sub as Record<string, unknown>, idx);
|
|
96
|
-
orClauses.push(`(${r.text})`);
|
|
97
|
-
values.push(...r.values);
|
|
98
|
-
idx += r.values.length;
|
|
99
|
-
}
|
|
100
|
-
clauses.push(`(${orClauses.join(" OR ")})`);
|
|
101
|
-
continue;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
if (key === "$and" && Array.isArray(val)) {
|
|
105
|
-
for (const sub of val) {
|
|
106
|
-
const r = filterToWhere(sub as Record<string, unknown>, idx);
|
|
107
|
-
clauses.push(`(${r.text})`);
|
|
108
|
-
values.push(...r.values);
|
|
109
|
-
idx += r.values.length;
|
|
110
|
-
}
|
|
111
|
-
continue;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// Nested dot notation → JSONB path
|
|
115
|
-
const col = key.includes(".") ? jsonbPath(key) : `"${snakeCase(key)}"`;
|
|
116
|
-
|
|
117
|
-
if (val === null || val === undefined) {
|
|
118
|
-
clauses.push(`${col} IS NULL`);
|
|
119
|
-
} else if (typeof val === "object" && !Array.isArray(val) && !(val instanceof ObjectId)) {
|
|
120
|
-
const ops = val as Record<string, unknown>;
|
|
121
|
-
for (const [op, opVal] of Object.entries(ops)) {
|
|
122
|
-
switch (op) {
|
|
123
|
-
case "$exists":
|
|
124
|
-
clauses.push(
|
|
125
|
-
opVal ? `${col} IS NOT NULL` : `${col} IS NULL`
|
|
126
|
-
);
|
|
127
|
-
break;
|
|
128
|
-
case "$gt":
|
|
129
|
-
clauses.push(`${col} > $${idx++}`);
|
|
130
|
-
values.push(opVal);
|
|
131
|
-
break;
|
|
132
|
-
case "$gte":
|
|
133
|
-
clauses.push(`${col} >= $${idx++}`);
|
|
134
|
-
values.push(opVal);
|
|
135
|
-
break;
|
|
136
|
-
case "$lt":
|
|
137
|
-
clauses.push(`${col} < $${idx++}`);
|
|
138
|
-
values.push(opVal);
|
|
139
|
-
break;
|
|
140
|
-
case "$lte":
|
|
141
|
-
clauses.push(`${col} <= $${idx++}`);
|
|
142
|
-
values.push(opVal);
|
|
143
|
-
break;
|
|
144
|
-
case "$ne":
|
|
145
|
-
clauses.push(`${col} != $${idx++}`);
|
|
146
|
-
values.push(opVal);
|
|
147
|
-
break;
|
|
148
|
-
case "$in":
|
|
149
|
-
clauses.push(`${col} = ANY($${idx++})`);
|
|
150
|
-
values.push(opVal);
|
|
151
|
-
break;
|
|
152
|
-
case "$nin":
|
|
153
|
-
clauses.push(`${col} != ALL($${idx++})`);
|
|
154
|
-
values.push(opVal);
|
|
155
|
-
break;
|
|
156
|
-
case "$regex": {
|
|
157
|
-
const flags =
|
|
158
|
-
ops.$options === "i" ? "~*" : "~";
|
|
159
|
-
clauses.push(`${col}::text ${flags} $${idx++}`);
|
|
160
|
-
values.push(opVal);
|
|
161
|
-
break;
|
|
162
|
-
}
|
|
163
|
-
default:
|
|
164
|
-
logger.warn(`Unknown filter operator: ${op}`);
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
} else {
|
|
168
|
-
const v = val instanceof ObjectId ? val.toString() : val;
|
|
169
|
-
clauses.push(`${col} = $${idx++}`);
|
|
170
|
-
values.push(v);
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
return {
|
|
175
|
-
text: clauses.length > 0 ? clauses.join(" AND ") : "TRUE",
|
|
176
|
-
values,
|
|
177
|
-
};
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
function snakeCase(s: string): string {
|
|
181
|
-
// Common MongoDB field → Postgres column mappings
|
|
182
|
-
const map: Record<string, string> = {
|
|
183
|
-
_id: "_id",
|
|
184
|
-
sessionId: "session_id",
|
|
185
|
-
userId: "user_id",
|
|
186
|
-
hfUserId: "hf_user_id",
|
|
187
|
-
createdAt: "created_at",
|
|
188
|
-
updatedAt: "updated_at",
|
|
189
|
-
deletedAt: "deleted_at",
|
|
190
|
-
expiresAt: "expires_at",
|
|
191
|
-
deleteAt: "delete_at",
|
|
192
|
-
conversationId: "conversation_id",
|
|
193
|
-
assistantId: "assistant_id",
|
|
194
|
-
createdById: "created_by_id",
|
|
195
|
-
createdByName: "created_by_name",
|
|
196
|
-
modelId: "model_id",
|
|
197
|
-
userCount: "user_count",
|
|
198
|
-
useCount: "use_count",
|
|
199
|
-
searchTokens: "search_tokens",
|
|
200
|
-
last24HoursCount: "last24_hours_count",
|
|
201
|
-
last24HoursUseCount: "last24_hours_use_count",
|
|
202
|
-
rootMessageId: "root_message_id",
|
|
203
|
-
tokenHash: "token_hash",
|
|
204
|
-
avatarUrl: "avatar_url",
|
|
205
|
-
isAdmin: "is_admin",
|
|
206
|
-
isEarlyAccess: "is_early_access",
|
|
207
|
-
contentId: "content_id",
|
|
208
|
-
eventType: "event_type",
|
|
209
|
-
messageId: "message_id",
|
|
210
|
-
dateField: "date_field",
|
|
211
|
-
dateSpan: "date_span",
|
|
212
|
-
dateAt: "date_at",
|
|
213
|
-
};
|
|
214
|
-
return map[s] ?? s.replace(/([A-Z])/g, "_$1").toLowerCase();
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
function jsonbPath(dotPath: string): string {
|
|
218
|
-
const parts = dotPath.split(".");
|
|
219
|
-
const col = `"${snakeCase(parts[0])}"`;
|
|
220
|
-
if (parts.length === 1) return col;
|
|
221
|
-
// JSONB deep access: data->'messages'->>'from'
|
|
222
|
-
const jsonParts = parts.slice(1);
|
|
223
|
-
const last = jsonParts.pop()!;
|
|
224
|
-
let expr = col;
|
|
225
|
-
for (const p of jsonParts) {
|
|
226
|
-
expr += `->'${p}'`;
|
|
227
|
-
}
|
|
228
|
-
expr += `->>'${last}'`;
|
|
229
|
-
return expr;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
// ---------------------------------------------------------------------------
|
|
233
|
-
// MongoDB-compatible update → SQL SET
|
|
234
|
-
// ---------------------------------------------------------------------------
|
|
235
|
-
|
|
236
|
-
interface UpdateOp {
|
|
237
|
-
setClauses: string[];
|
|
238
|
-
values: unknown[];
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
function updateToSet(
|
|
242
|
-
update: Record<string, unknown>,
|
|
243
|
-
startIdx: number
|
|
244
|
-
): UpdateOp {
|
|
245
|
-
const setClauses: string[] = [];
|
|
246
|
-
const values: unknown[] = [];
|
|
247
|
-
let idx = startIdx;
|
|
248
|
-
|
|
249
|
-
const setFields =
|
|
250
|
-
(update.$set as Record<string, unknown>) ?? update;
|
|
251
|
-
|
|
252
|
-
// If update has no operators, treat the whole thing as $set
|
|
253
|
-
const hasOperators = Object.keys(update).some((k) => k.startsWith("$"));
|
|
254
|
-
const fields = hasOperators
|
|
255
|
-
? (update.$set as Record<string, unknown>) ?? {}
|
|
256
|
-
: update;
|
|
257
|
-
|
|
258
|
-
for (const [key, val] of Object.entries(fields)) {
|
|
259
|
-
if (key === "_id") continue; // never update PK
|
|
260
|
-
const col = snakeCase(key);
|
|
261
|
-
const v = val instanceof ObjectId ? val.toString() : val;
|
|
262
|
-
if (typeof v === "object" && v !== null && !Array.isArray(v) && !(v instanceof Date)) {
|
|
263
|
-
setClauses.push(`"${col}" = $${idx++}::jsonb`);
|
|
264
|
-
values.push(JSON.stringify(v));
|
|
265
|
-
} else {
|
|
266
|
-
setClauses.push(`"${col}" = $${idx++}`);
|
|
267
|
-
values.push(v);
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
// Handle $push (append to JSONB array)
|
|
272
|
-
if (update.$push) {
|
|
273
|
-
for (const [key, val] of Object.entries(
|
|
274
|
-
update.$push as Record<string, unknown>
|
|
275
|
-
)) {
|
|
276
|
-
const col = snakeCase(key);
|
|
277
|
-
if (typeof val === "object" && val !== null && "$each" in (val as Record<string, unknown>)) {
|
|
278
|
-
const each = (val as Record<string, unknown>).$each as unknown[];
|
|
279
|
-
setClauses.push(
|
|
280
|
-
`"${col}" = "${col}" || $${idx++}::jsonb`
|
|
281
|
-
);
|
|
282
|
-
values.push(JSON.stringify(each));
|
|
283
|
-
} else {
|
|
284
|
-
setClauses.push(
|
|
285
|
-
`"${col}" = COALESCE("${col}", '[]'::jsonb) || $${idx++}::jsonb`
|
|
286
|
-
);
|
|
287
|
-
values.push(JSON.stringify([val]));
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
// Handle $inc
|
|
293
|
-
if (update.$inc) {
|
|
294
|
-
for (const [key, val] of Object.entries(
|
|
295
|
-
update.$inc as Record<string, number>
|
|
296
|
-
)) {
|
|
297
|
-
const col = snakeCase(key);
|
|
298
|
-
setClauses.push(`"${col}" = COALESCE("${col}", 0) + $${idx++}`);
|
|
299
|
-
values.push(val);
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
// Handle $unset
|
|
304
|
-
if (update.$unset) {
|
|
305
|
-
for (const key of Object.keys(update.$unset as Record<string, unknown>)) {
|
|
306
|
-
const col = snakeCase(key);
|
|
307
|
-
setClauses.push(`"${col}" = NULL`);
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
// Always update updated_at
|
|
312
|
-
if (!setClauses.some((c) => c.includes('"updated_at"'))) {
|
|
313
|
-
setClauses.push(`"updated_at" = NOW()`);
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
return { setClauses, values };
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
// ---------------------------------------------------------------------------
|
|
320
|
-
// Sort/limit/skip helpers
|
|
321
|
-
// ---------------------------------------------------------------------------
|
|
322
|
-
|
|
323
|
-
function sortToOrderBy(sort: Record<string, 1 | -1>): string {
|
|
324
|
-
const parts = Object.entries(sort).map(([key, dir]) => {
|
|
325
|
-
const col = key.includes(".")
|
|
326
|
-
? jsonbPath(key)
|
|
327
|
-
: `"${snakeCase(key)}"`;
|
|
328
|
-
return `${col} ${dir === -1 ? "DESC" : "ASC"}`;
|
|
329
|
-
});
|
|
330
|
-
return parts.length > 0 ? `ORDER BY ${parts.join(", ")}` : "";
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
// ---------------------------------------------------------------------------
|
|
334
|
-
// PostgresCollection — MongoDB Collection interface
|
|
335
|
-
// ---------------------------------------------------------------------------
|
|
336
|
-
|
|
337
|
-
export interface FindOptions {
|
|
338
|
-
sort?: Record<string, 1 | -1>;
|
|
339
|
-
limit?: number;
|
|
340
|
-
skip?: number;
|
|
341
|
-
projection?: Record<string, 0 | 1>;
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
export class PostgresCollection<T extends Record<string, unknown>> {
|
|
345
|
-
constructor(public readonly tableName: string) {}
|
|
346
|
-
|
|
347
|
-
private get pool() {
|
|
348
|
-
return getPool();
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
// Convert Postgres row (snake_case) back to camelCase for app
|
|
352
|
-
private rowToDoc(row: Record<string, unknown>): T {
|
|
353
|
-
// For now, return as-is — the app code uses camelCase field names
|
|
354
|
-
// but we store snake_case. We rely on column aliases or a transform.
|
|
355
|
-
// Since HF Chat UI accesses fields via MongoDB collection refs,
|
|
356
|
-
// we need the row to look like a MongoDB document.
|
|
357
|
-
const doc: Record<string, unknown> = {};
|
|
358
|
-
for (const [key, val] of Object.entries(row)) {
|
|
359
|
-
doc[camelCase(key)] = val;
|
|
360
|
-
}
|
|
361
|
-
return doc as T;
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
async findOne(filter: Record<string, unknown> = {}): Promise<T | null> {
|
|
365
|
-
const w = filterToWhere(filter);
|
|
366
|
-
const sql = `SELECT * FROM "${this.tableName}" WHERE ${w.text} LIMIT 1`;
|
|
367
|
-
const result = await this.pool.query(sql, w.values);
|
|
368
|
-
return result.rows.length > 0 ? this.rowToDoc(result.rows[0]) : null;
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
find(
|
|
372
|
-
filter: Record<string, unknown> = {},
|
|
373
|
-
options: FindOptions = {}
|
|
374
|
-
): PostgresCursor<T> {
|
|
375
|
-
return new PostgresCursor<T>(this, filter, options);
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
async insertOne(
|
|
379
|
-
doc: Partial<T> & Record<string, unknown>
|
|
380
|
-
): Promise<{ insertedId: ObjectId; acknowledged: boolean }> {
|
|
381
|
-
const id = doc._id
|
|
382
|
-
? typeof doc._id === "string"
|
|
383
|
-
? doc._id
|
|
384
|
-
: (doc._id as ObjectId).toString()
|
|
385
|
-
: randomUUID();
|
|
386
|
-
|
|
387
|
-
const entries = Object.entries(doc).filter(([k]) => k !== "_id");
|
|
388
|
-
const cols = ["_id", ...entries.map(([k]) => `"${snakeCase(k)}"`)];
|
|
389
|
-
const placeholders = [
|
|
390
|
-
"$1",
|
|
391
|
-
...entries.map((_, i) => `$${i + 2}`),
|
|
392
|
-
];
|
|
393
|
-
const values: unknown[] = [
|
|
394
|
-
id,
|
|
395
|
-
...entries.map(([, v]) => {
|
|
396
|
-
if (v instanceof ObjectId) return v.toString();
|
|
397
|
-
if (typeof v === "object" && v !== null && !(v instanceof Date) && !Array.isArray(v))
|
|
398
|
-
return JSON.stringify(v);
|
|
399
|
-
if (Array.isArray(v)) return JSON.stringify(v);
|
|
400
|
-
return v;
|
|
401
|
-
}),
|
|
402
|
-
];
|
|
403
|
-
|
|
404
|
-
const sql = `INSERT INTO "${this.tableName}" (${cols.join(", ")}) VALUES (${placeholders.join(", ")}) ON CONFLICT DO NOTHING RETURNING _id`;
|
|
405
|
-
await this.pool.query(sql, values);
|
|
406
|
-
return { insertedId: new ObjectId(id), acknowledged: true };
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
async insertMany(
|
|
410
|
-
docs: Array<Partial<T> & Record<string, unknown>>
|
|
411
|
-
): Promise<{ insertedIds: ObjectId[]; acknowledged: boolean }> {
|
|
412
|
-
const ids: ObjectId[] = [];
|
|
413
|
-
for (const doc of docs) {
|
|
414
|
-
const result = await this.insertOne(doc);
|
|
415
|
-
ids.push(result.insertedId);
|
|
416
|
-
}
|
|
417
|
-
return { insertedIds: ids, acknowledged: true };
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
async updateOne(
|
|
421
|
-
filter: Record<string, unknown>,
|
|
422
|
-
update: Record<string, unknown>
|
|
423
|
-
): Promise<{ matchedCount: number; modifiedCount: number; acknowledged: boolean }> {
|
|
424
|
-
const w = filterToWhere(filter);
|
|
425
|
-
const u = updateToSet(update, w.values.length + 1);
|
|
426
|
-
if (u.setClauses.length === 0) {
|
|
427
|
-
return { matchedCount: 0, modifiedCount: 0, acknowledged: true };
|
|
428
|
-
}
|
|
429
|
-
const sql = `UPDATE "${this.tableName}" SET ${u.setClauses.join(", ")} WHERE ${w.text}`;
|
|
430
|
-
const result = await this.pool.query(sql, [...w.values, ...u.values]);
|
|
431
|
-
const count = result.rowCount ?? 0;
|
|
432
|
-
return { matchedCount: count, modifiedCount: count, acknowledged: true };
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
async updateMany(
|
|
436
|
-
filter: Record<string, unknown>,
|
|
437
|
-
update: Record<string, unknown>
|
|
438
|
-
): Promise<{ matchedCount: number; modifiedCount: number; acknowledged: boolean }> {
|
|
439
|
-
return this.updateOne(filter, update); // same SQL, no LIMIT 1
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
async deleteOne(
|
|
443
|
-
filter: Record<string, unknown>
|
|
444
|
-
): Promise<{ deletedCount: number; acknowledged: boolean }> {
|
|
445
|
-
const w = filterToWhere(filter);
|
|
446
|
-
const sql = `DELETE FROM "${this.tableName}" WHERE ${w.text}`;
|
|
447
|
-
const result = await this.pool.query(sql, w.values);
|
|
448
|
-
return { deletedCount: result.rowCount ?? 0, acknowledged: true };
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
async deleteMany(
|
|
452
|
-
filter: Record<string, unknown>
|
|
453
|
-
): Promise<{ deletedCount: number; acknowledged: boolean }> {
|
|
454
|
-
return this.deleteOne(filter);
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
async countDocuments(
|
|
458
|
-
filter: Record<string, unknown> = {}
|
|
459
|
-
): Promise<number> {
|
|
460
|
-
const w = filterToWhere(filter);
|
|
461
|
-
const sql = `SELECT COUNT(*)::int AS count FROM "${this.tableName}" WHERE ${w.text}`;
|
|
462
|
-
const result = await this.pool.query(sql, w.values);
|
|
463
|
-
return result.rows[0]?.count ?? 0;
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
async distinct(
|
|
467
|
-
field: string,
|
|
468
|
-
filter: Record<string, unknown> = {}
|
|
469
|
-
): Promise<unknown[]> {
|
|
470
|
-
const col = `"${snakeCase(field)}"`;
|
|
471
|
-
const w = filterToWhere(filter);
|
|
472
|
-
const sql = `SELECT DISTINCT ${col} FROM "${this.tableName}" WHERE ${w.text}`;
|
|
473
|
-
const result = await this.pool.query(sql, w.values);
|
|
474
|
-
return result.rows.map((r) => r[snakeCase(field)]);
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
async aggregate(pipeline: Record<string, unknown>[]): Promise<T[]> {
|
|
478
|
-
// Basic aggregation support — handle common patterns
|
|
479
|
-
// For complex pipelines, we'd need a full translator.
|
|
480
|
-
// For now, log a warning and return empty.
|
|
481
|
-
logger.warn(
|
|
482
|
-
{ pipeline, table: this.tableName },
|
|
483
|
-
"aggregate() called — basic translation only"
|
|
484
|
-
);
|
|
485
|
-
return [];
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
async createIndex(
|
|
489
|
-
_spec: Record<string, unknown>,
|
|
490
|
-
_options?: Record<string, unknown>
|
|
491
|
-
): Promise<void> {
|
|
492
|
-
// Indexes are pre-created in the migration. This is a no-op.
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
async findOneAndUpdate(
|
|
496
|
-
filter: Record<string, unknown>,
|
|
497
|
-
update: Record<string, unknown>,
|
|
498
|
-
options?: { upsert?: boolean; returnDocument?: "before" | "after" }
|
|
499
|
-
): Promise<{ value: T | null }> {
|
|
500
|
-
if (options?.upsert) {
|
|
501
|
-
const existing = await this.findOne(filter);
|
|
502
|
-
if (!existing) {
|
|
503
|
-
const doc = { ...filter, ...((update.$set as Record<string, unknown>) ?? update) };
|
|
504
|
-
await this.insertOne(doc as Partial<T> & Record<string, unknown>);
|
|
505
|
-
const inserted = await this.findOne(filter);
|
|
506
|
-
return { value: inserted };
|
|
507
|
-
}
|
|
508
|
-
}
|
|
509
|
-
await this.updateOne(filter, update);
|
|
510
|
-
const updated = await this.findOne(filter);
|
|
511
|
-
return { value: updated };
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
async findOneAndDelete(
|
|
515
|
-
filter: Record<string, unknown>
|
|
516
|
-
): Promise<{ value: T | null }> {
|
|
517
|
-
const doc = await this.findOne(filter);
|
|
518
|
-
if (doc) await this.deleteOne(filter);
|
|
519
|
-
return { value: doc };
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
// RuVector extension: semantic search via pgvector
|
|
523
|
-
async semanticSearch(
|
|
524
|
-
queryEmbedding: number[],
|
|
525
|
-
limit = 10,
|
|
526
|
-
filter: Record<string, unknown> = {}
|
|
527
|
-
): Promise<Array<T & { similarity: number }>> {
|
|
528
|
-
const w = filterToWhere(filter);
|
|
529
|
-
const embIdx = w.values.length + 1;
|
|
530
|
-
const limIdx = embIdx + 1;
|
|
531
|
-
const sql = `
|
|
532
|
-
SELECT *, 1 - (embedding <=> $${embIdx}::vector) AS similarity
|
|
533
|
-
FROM "${this.tableName}"
|
|
534
|
-
WHERE ${w.text} AND embedding IS NOT NULL
|
|
535
|
-
ORDER BY embedding <=> $${embIdx}::vector
|
|
536
|
-
LIMIT $${limIdx}
|
|
537
|
-
`;
|
|
538
|
-
const result = await this.pool.query(sql, [
|
|
539
|
-
...w.values,
|
|
540
|
-
`[${queryEmbedding.join(",")}]`,
|
|
541
|
-
limit,
|
|
542
|
-
]);
|
|
543
|
-
return result.rows.map((r) => ({ ...this.rowToDoc(r), similarity: r.similarity }));
|
|
544
|
-
}
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
// ---------------------------------------------------------------------------
|
|
548
|
-
// Cursor — implements MongoDB-like chaining (sort/limit/skip/toArray)
|
|
549
|
-
// ---------------------------------------------------------------------------
|
|
550
|
-
|
|
551
|
-
export class PostgresCursor<T extends Record<string, unknown>> {
|
|
552
|
-
private _sort: Record<string, 1 | -1> = {};
|
|
553
|
-
private _limit?: number;
|
|
554
|
-
private _skip?: number;
|
|
555
|
-
private _projection?: Record<string, 0 | 1>;
|
|
556
|
-
|
|
557
|
-
constructor(
|
|
558
|
-
private collection: PostgresCollection<T>,
|
|
559
|
-
private filter: Record<string, unknown>,
|
|
560
|
-
options: FindOptions = {}
|
|
561
|
-
) {
|
|
562
|
-
if (options.sort) this._sort = options.sort;
|
|
563
|
-
if (options.limit) this._limit = options.limit;
|
|
564
|
-
if (options.skip) this._skip = options.skip;
|
|
565
|
-
if (options.projection) this._projection = options.projection;
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
sort(spec: Record<string, 1 | -1>): this {
|
|
569
|
-
this._sort = { ...this._sort, ...spec };
|
|
570
|
-
return this;
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
limit(n: number): this {
|
|
574
|
-
this._limit = n;
|
|
575
|
-
return this;
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
skip(n: number): this {
|
|
579
|
-
this._skip = n;
|
|
580
|
-
return this;
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
project(spec: Record<string, 0 | 1>): this {
|
|
584
|
-
this._projection = spec;
|
|
585
|
-
return this;
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
async toArray(): Promise<T[]> {
|
|
589
|
-
const w = filterToWhere(this.filter);
|
|
590
|
-
const order = sortToOrderBy(this._sort);
|
|
591
|
-
let sql = `SELECT * FROM "${this.collection.tableName}" WHERE ${w.text} ${order}`;
|
|
592
|
-
const values = [...w.values];
|
|
593
|
-
if (this._limit !== undefined) {
|
|
594
|
-
sql += ` LIMIT $${values.length + 1}`;
|
|
595
|
-
values.push(this._limit);
|
|
596
|
-
}
|
|
597
|
-
if (this._skip !== undefined) {
|
|
598
|
-
sql += ` OFFSET $${values.length + 1}`;
|
|
599
|
-
values.push(this._skip);
|
|
600
|
-
}
|
|
601
|
-
const pool = getPool();
|
|
602
|
-
const result = await pool.query(sql, values);
|
|
603
|
-
return result.rows.map((row) => {
|
|
604
|
-
const doc: Record<string, unknown> = {};
|
|
605
|
-
for (const [key, val] of Object.entries(row)) {
|
|
606
|
-
doc[camelCase(key)] = val;
|
|
607
|
-
}
|
|
608
|
-
return doc as T;
|
|
609
|
-
});
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
// Async iterable support
|
|
613
|
-
async *[Symbol.asyncIterator](): AsyncGenerator<T> {
|
|
614
|
-
const rows = await this.toArray();
|
|
615
|
-
for (const row of rows) {
|
|
616
|
-
yield row;
|
|
617
|
-
}
|
|
618
|
-
}
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
// ---------------------------------------------------------------------------
|
|
622
|
-
// GridFS replacement — stores files as BYTEA in a `files` table
|
|
623
|
-
// ---------------------------------------------------------------------------
|
|
624
|
-
|
|
625
|
-
export class PostgresGridFSBucket {
|
|
626
|
-
private readonly tableName = "files";
|
|
627
|
-
|
|
628
|
-
async openUploadStream(
|
|
629
|
-
filename: string,
|
|
630
|
-
options?: { metadata?: Record<string, unknown>; contentType?: string }
|
|
631
|
-
) {
|
|
632
|
-
const id = randomUUID();
|
|
633
|
-
const chunks: Buffer[] = [];
|
|
634
|
-
|
|
635
|
-
return {
|
|
636
|
-
id: new ObjectId(id),
|
|
637
|
-
write(chunk: Buffer) {
|
|
638
|
-
chunks.push(chunk);
|
|
639
|
-
},
|
|
640
|
-
async end() {
|
|
641
|
-
const data = Buffer.concat(chunks);
|
|
642
|
-
const pool = getPool();
|
|
643
|
-
await pool.query(
|
|
644
|
-
`INSERT INTO files (_id, filename, content_type, length, data, metadata) VALUES ($1, $2, $3, $4, $5, $6)`,
|
|
645
|
-
[
|
|
646
|
-
id,
|
|
647
|
-
filename,
|
|
648
|
-
options?.contentType ?? "application/octet-stream",
|
|
649
|
-
data.length,
|
|
650
|
-
data,
|
|
651
|
-
JSON.stringify(options?.metadata ?? {}),
|
|
652
|
-
]
|
|
653
|
-
);
|
|
654
|
-
},
|
|
655
|
-
};
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
openDownloadStream(id: ObjectId | string) {
|
|
659
|
-
const fileId = typeof id === "string" ? id : id.toString();
|
|
660
|
-
// Return a readable-like object
|
|
661
|
-
return {
|
|
662
|
-
async toArray(): Promise<Buffer[]> {
|
|
663
|
-
const pool = getPool();
|
|
664
|
-
const result = await pool.query(
|
|
665
|
-
`SELECT data FROM files WHERE _id = $1`,
|
|
666
|
-
[fileId]
|
|
667
|
-
);
|
|
668
|
-
if (result.rows.length === 0) throw new Error("File not found");
|
|
669
|
-
return [result.rows[0].data];
|
|
670
|
-
},
|
|
671
|
-
};
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
async delete(id: ObjectId | string) {
|
|
675
|
-
const fileId = typeof id === "string" ? id : id.toString();
|
|
676
|
-
const pool = getPool();
|
|
677
|
-
await pool.query(`DELETE FROM files WHERE _id = $1`, [fileId]);
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
async find(filter: Record<string, unknown> = {}) {
|
|
681
|
-
const w = filterToWhere(filter);
|
|
682
|
-
const pool = getPool();
|
|
683
|
-
const result = await pool.query(
|
|
684
|
-
`SELECT _id, filename, content_type, length, metadata, created_at FROM files WHERE ${w.text}`,
|
|
685
|
-
w.values
|
|
686
|
-
);
|
|
687
|
-
return {
|
|
688
|
-
toArray: async () => result.rows,
|
|
689
|
-
};
|
|
690
|
-
}
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
// ---------------------------------------------------------------------------
|
|
694
|
-
// Helpers
|
|
695
|
-
// ---------------------------------------------------------------------------
|
|
696
|
-
|
|
697
|
-
function camelCase(s: string): string {
|
|
698
|
-
if (s === "_id") return "_id";
|
|
699
|
-
return s.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
700
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* PostgreSQL adapter for RuVocal — drop-in replacement for MongoDB collections.
|
|
3
|
+
*
|
|
4
|
+
* Implements the MongoDB Collection interface used by HF Chat UI,
|
|
5
|
+
* translating find/insert/update/delete/aggregate calls to SQL.
|
|
6
|
+
*
|
|
7
|
+
* Uses the `pg` driver with connection pooling. ObjectId fields are
|
|
8
|
+
* mapped to UUID. Messages remain embedded in conversations as JSONB
|
|
9
|
+
* to minimise upstream diff.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import pg from "pg";
|
|
13
|
+
import { randomUUID } from "crypto";
|
|
14
|
+
import { logger } from "$lib/server/logger";
|
|
15
|
+
|
|
16
|
+
const { Pool } = pg;
|
|
17
|
+
|
|
18
|
+
let pool: pg.Pool | null = null;
|
|
19
|
+
|
|
20
|
+
export function getPool(): pg.Pool {
|
|
21
|
+
if (!pool) {
|
|
22
|
+
const connectionString =
|
|
23
|
+
process.env.DATABASE_URL ||
|
|
24
|
+
"postgresql://ruvocal:ruvocal@localhost:5432/ruvocal";
|
|
25
|
+
pool = new Pool({
|
|
26
|
+
connectionString,
|
|
27
|
+
max: 20,
|
|
28
|
+
idleTimeoutMillis: 30_000,
|
|
29
|
+
connectionTimeoutMillis: 5_000,
|
|
30
|
+
});
|
|
31
|
+
pool.on("error", (err) => logger.error(err, "Postgres pool error"));
|
|
32
|
+
}
|
|
33
|
+
return pool;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function closePool(): Promise<void> {
|
|
37
|
+
if (pool) {
|
|
38
|
+
await pool.end();
|
|
39
|
+
pool = null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// ObjectId compatibility
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Minimal ObjectId stand-in that wraps a UUID string.
|
|
49
|
+
* MongoDB's ObjectId is a 24-hex-char string; we use UUID v4 instead.
|
|
50
|
+
*/
|
|
51
|
+
export class ObjectId {
|
|
52
|
+
private _id: string;
|
|
53
|
+
constructor(id?: string) {
|
|
54
|
+
this._id = id ?? randomUUID();
|
|
55
|
+
}
|
|
56
|
+
toString() {
|
|
57
|
+
return this._id;
|
|
58
|
+
}
|
|
59
|
+
toHexString() {
|
|
60
|
+
return this._id;
|
|
61
|
+
}
|
|
62
|
+
equals(other: ObjectId | string) {
|
|
63
|
+
const otherStr = typeof other === "string" ? other : other.toString();
|
|
64
|
+
return this._id === otherStr;
|
|
65
|
+
}
|
|
66
|
+
toJSON() {
|
|
67
|
+
return this._id;
|
|
68
|
+
}
|
|
69
|
+
static createFromHexString(hex: string) {
|
|
70
|
+
return new ObjectId(hex);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// MongoDB-compatible filter → SQL WHERE
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
interface FilterOp {
|
|
79
|
+
text: string;
|
|
80
|
+
values: unknown[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function filterToWhere(
|
|
84
|
+
filter: Record<string, unknown>,
|
|
85
|
+
startIdx = 1
|
|
86
|
+
): FilterOp {
|
|
87
|
+
const clauses: string[] = [];
|
|
88
|
+
const values: unknown[] = [];
|
|
89
|
+
let idx = startIdx;
|
|
90
|
+
|
|
91
|
+
for (const [key, val] of Object.entries(filter)) {
|
|
92
|
+
if (key === "$or" && Array.isArray(val)) {
|
|
93
|
+
const orClauses: string[] = [];
|
|
94
|
+
for (const sub of val) {
|
|
95
|
+
const r = filterToWhere(sub as Record<string, unknown>, idx);
|
|
96
|
+
orClauses.push(`(${r.text})`);
|
|
97
|
+
values.push(...r.values);
|
|
98
|
+
idx += r.values.length;
|
|
99
|
+
}
|
|
100
|
+
clauses.push(`(${orClauses.join(" OR ")})`);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (key === "$and" && Array.isArray(val)) {
|
|
105
|
+
for (const sub of val) {
|
|
106
|
+
const r = filterToWhere(sub as Record<string, unknown>, idx);
|
|
107
|
+
clauses.push(`(${r.text})`);
|
|
108
|
+
values.push(...r.values);
|
|
109
|
+
idx += r.values.length;
|
|
110
|
+
}
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Nested dot notation → JSONB path
|
|
115
|
+
const col = key.includes(".") ? jsonbPath(key) : `"${snakeCase(key)}"`;
|
|
116
|
+
|
|
117
|
+
if (val === null || val === undefined) {
|
|
118
|
+
clauses.push(`${col} IS NULL`);
|
|
119
|
+
} else if (typeof val === "object" && !Array.isArray(val) && !(val instanceof ObjectId)) {
|
|
120
|
+
const ops = val as Record<string, unknown>;
|
|
121
|
+
for (const [op, opVal] of Object.entries(ops)) {
|
|
122
|
+
switch (op) {
|
|
123
|
+
case "$exists":
|
|
124
|
+
clauses.push(
|
|
125
|
+
opVal ? `${col} IS NOT NULL` : `${col} IS NULL`
|
|
126
|
+
);
|
|
127
|
+
break;
|
|
128
|
+
case "$gt":
|
|
129
|
+
clauses.push(`${col} > $${idx++}`);
|
|
130
|
+
values.push(opVal);
|
|
131
|
+
break;
|
|
132
|
+
case "$gte":
|
|
133
|
+
clauses.push(`${col} >= $${idx++}`);
|
|
134
|
+
values.push(opVal);
|
|
135
|
+
break;
|
|
136
|
+
case "$lt":
|
|
137
|
+
clauses.push(`${col} < $${idx++}`);
|
|
138
|
+
values.push(opVal);
|
|
139
|
+
break;
|
|
140
|
+
case "$lte":
|
|
141
|
+
clauses.push(`${col} <= $${idx++}`);
|
|
142
|
+
values.push(opVal);
|
|
143
|
+
break;
|
|
144
|
+
case "$ne":
|
|
145
|
+
clauses.push(`${col} != $${idx++}`);
|
|
146
|
+
values.push(opVal);
|
|
147
|
+
break;
|
|
148
|
+
case "$in":
|
|
149
|
+
clauses.push(`${col} = ANY($${idx++})`);
|
|
150
|
+
values.push(opVal);
|
|
151
|
+
break;
|
|
152
|
+
case "$nin":
|
|
153
|
+
clauses.push(`${col} != ALL($${idx++})`);
|
|
154
|
+
values.push(opVal);
|
|
155
|
+
break;
|
|
156
|
+
case "$regex": {
|
|
157
|
+
const flags =
|
|
158
|
+
ops.$options === "i" ? "~*" : "~";
|
|
159
|
+
clauses.push(`${col}::text ${flags} $${idx++}`);
|
|
160
|
+
values.push(opVal);
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
default:
|
|
164
|
+
logger.warn(`Unknown filter operator: ${op}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
} else {
|
|
168
|
+
const v = val instanceof ObjectId ? val.toString() : val;
|
|
169
|
+
clauses.push(`${col} = $${idx++}`);
|
|
170
|
+
values.push(v);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
text: clauses.length > 0 ? clauses.join(" AND ") : "TRUE",
|
|
176
|
+
values,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function snakeCase(s: string): string {
|
|
181
|
+
// Common MongoDB field → Postgres column mappings
|
|
182
|
+
const map: Record<string, string> = {
|
|
183
|
+
_id: "_id",
|
|
184
|
+
sessionId: "session_id",
|
|
185
|
+
userId: "user_id",
|
|
186
|
+
hfUserId: "hf_user_id",
|
|
187
|
+
createdAt: "created_at",
|
|
188
|
+
updatedAt: "updated_at",
|
|
189
|
+
deletedAt: "deleted_at",
|
|
190
|
+
expiresAt: "expires_at",
|
|
191
|
+
deleteAt: "delete_at",
|
|
192
|
+
conversationId: "conversation_id",
|
|
193
|
+
assistantId: "assistant_id",
|
|
194
|
+
createdById: "created_by_id",
|
|
195
|
+
createdByName: "created_by_name",
|
|
196
|
+
modelId: "model_id",
|
|
197
|
+
userCount: "user_count",
|
|
198
|
+
useCount: "use_count",
|
|
199
|
+
searchTokens: "search_tokens",
|
|
200
|
+
last24HoursCount: "last24_hours_count",
|
|
201
|
+
last24HoursUseCount: "last24_hours_use_count",
|
|
202
|
+
rootMessageId: "root_message_id",
|
|
203
|
+
tokenHash: "token_hash",
|
|
204
|
+
avatarUrl: "avatar_url",
|
|
205
|
+
isAdmin: "is_admin",
|
|
206
|
+
isEarlyAccess: "is_early_access",
|
|
207
|
+
contentId: "content_id",
|
|
208
|
+
eventType: "event_type",
|
|
209
|
+
messageId: "message_id",
|
|
210
|
+
dateField: "date_field",
|
|
211
|
+
dateSpan: "date_span",
|
|
212
|
+
dateAt: "date_at",
|
|
213
|
+
};
|
|
214
|
+
return map[s] ?? s.replace(/([A-Z])/g, "_$1").toLowerCase();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function jsonbPath(dotPath: string): string {
|
|
218
|
+
const parts = dotPath.split(".");
|
|
219
|
+
const col = `"${snakeCase(parts[0])}"`;
|
|
220
|
+
if (parts.length === 1) return col;
|
|
221
|
+
// JSONB deep access: data->'messages'->>'from'
|
|
222
|
+
const jsonParts = parts.slice(1);
|
|
223
|
+
const last = jsonParts.pop()!;
|
|
224
|
+
let expr = col;
|
|
225
|
+
for (const p of jsonParts) {
|
|
226
|
+
expr += `->'${p}'`;
|
|
227
|
+
}
|
|
228
|
+
expr += `->>'${last}'`;
|
|
229
|
+
return expr;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
// MongoDB-compatible update → SQL SET
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
interface UpdateOp {
|
|
237
|
+
setClauses: string[];
|
|
238
|
+
values: unknown[];
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function updateToSet(
|
|
242
|
+
update: Record<string, unknown>,
|
|
243
|
+
startIdx: number
|
|
244
|
+
): UpdateOp {
|
|
245
|
+
const setClauses: string[] = [];
|
|
246
|
+
const values: unknown[] = [];
|
|
247
|
+
let idx = startIdx;
|
|
248
|
+
|
|
249
|
+
const setFields =
|
|
250
|
+
(update.$set as Record<string, unknown>) ?? update;
|
|
251
|
+
|
|
252
|
+
// If update has no operators, treat the whole thing as $set
|
|
253
|
+
const hasOperators = Object.keys(update).some((k) => k.startsWith("$"));
|
|
254
|
+
const fields = hasOperators
|
|
255
|
+
? (update.$set as Record<string, unknown>) ?? {}
|
|
256
|
+
: update;
|
|
257
|
+
|
|
258
|
+
for (const [key, val] of Object.entries(fields)) {
|
|
259
|
+
if (key === "_id") continue; // never update PK
|
|
260
|
+
const col = snakeCase(key);
|
|
261
|
+
const v = val instanceof ObjectId ? val.toString() : val;
|
|
262
|
+
if (typeof v === "object" && v !== null && !Array.isArray(v) && !(v instanceof Date)) {
|
|
263
|
+
setClauses.push(`"${col}" = $${idx++}::jsonb`);
|
|
264
|
+
values.push(JSON.stringify(v));
|
|
265
|
+
} else {
|
|
266
|
+
setClauses.push(`"${col}" = $${idx++}`);
|
|
267
|
+
values.push(v);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Handle $push (append to JSONB array)
|
|
272
|
+
if (update.$push) {
|
|
273
|
+
for (const [key, val] of Object.entries(
|
|
274
|
+
update.$push as Record<string, unknown>
|
|
275
|
+
)) {
|
|
276
|
+
const col = snakeCase(key);
|
|
277
|
+
if (typeof val === "object" && val !== null && "$each" in (val as Record<string, unknown>)) {
|
|
278
|
+
const each = (val as Record<string, unknown>).$each as unknown[];
|
|
279
|
+
setClauses.push(
|
|
280
|
+
`"${col}" = "${col}" || $${idx++}::jsonb`
|
|
281
|
+
);
|
|
282
|
+
values.push(JSON.stringify(each));
|
|
283
|
+
} else {
|
|
284
|
+
setClauses.push(
|
|
285
|
+
`"${col}" = COALESCE("${col}", '[]'::jsonb) || $${idx++}::jsonb`
|
|
286
|
+
);
|
|
287
|
+
values.push(JSON.stringify([val]));
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Handle $inc
|
|
293
|
+
if (update.$inc) {
|
|
294
|
+
for (const [key, val] of Object.entries(
|
|
295
|
+
update.$inc as Record<string, number>
|
|
296
|
+
)) {
|
|
297
|
+
const col = snakeCase(key);
|
|
298
|
+
setClauses.push(`"${col}" = COALESCE("${col}", 0) + $${idx++}`);
|
|
299
|
+
values.push(val);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Handle $unset
|
|
304
|
+
if (update.$unset) {
|
|
305
|
+
for (const key of Object.keys(update.$unset as Record<string, unknown>)) {
|
|
306
|
+
const col = snakeCase(key);
|
|
307
|
+
setClauses.push(`"${col}" = NULL`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Always update updated_at
|
|
312
|
+
if (!setClauses.some((c) => c.includes('"updated_at"'))) {
|
|
313
|
+
setClauses.push(`"updated_at" = NOW()`);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return { setClauses, values };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ---------------------------------------------------------------------------
|
|
320
|
+
// Sort/limit/skip helpers
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
322
|
+
|
|
323
|
+
function sortToOrderBy(sort: Record<string, 1 | -1>): string {
|
|
324
|
+
const parts = Object.entries(sort).map(([key, dir]) => {
|
|
325
|
+
const col = key.includes(".")
|
|
326
|
+
? jsonbPath(key)
|
|
327
|
+
: `"${snakeCase(key)}"`;
|
|
328
|
+
return `${col} ${dir === -1 ? "DESC" : "ASC"}`;
|
|
329
|
+
});
|
|
330
|
+
return parts.length > 0 ? `ORDER BY ${parts.join(", ")}` : "";
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ---------------------------------------------------------------------------
|
|
334
|
+
// PostgresCollection — MongoDB Collection interface
|
|
335
|
+
// ---------------------------------------------------------------------------
|
|
336
|
+
|
|
337
|
+
export interface FindOptions {
|
|
338
|
+
sort?: Record<string, 1 | -1>;
|
|
339
|
+
limit?: number;
|
|
340
|
+
skip?: number;
|
|
341
|
+
projection?: Record<string, 0 | 1>;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export class PostgresCollection<T extends Record<string, unknown>> {
|
|
345
|
+
constructor(public readonly tableName: string) {}
|
|
346
|
+
|
|
347
|
+
private get pool() {
|
|
348
|
+
return getPool();
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Convert Postgres row (snake_case) back to camelCase for app
|
|
352
|
+
private rowToDoc(row: Record<string, unknown>): T {
|
|
353
|
+
// For now, return as-is — the app code uses camelCase field names
|
|
354
|
+
// but we store snake_case. We rely on column aliases or a transform.
|
|
355
|
+
// Since HF Chat UI accesses fields via MongoDB collection refs,
|
|
356
|
+
// we need the row to look like a MongoDB document.
|
|
357
|
+
const doc: Record<string, unknown> = {};
|
|
358
|
+
for (const [key, val] of Object.entries(row)) {
|
|
359
|
+
doc[camelCase(key)] = val;
|
|
360
|
+
}
|
|
361
|
+
return doc as T;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async findOne(filter: Record<string, unknown> = {}): Promise<T | null> {
|
|
365
|
+
const w = filterToWhere(filter);
|
|
366
|
+
const sql = `SELECT * FROM "${this.tableName}" WHERE ${w.text} LIMIT 1`;
|
|
367
|
+
const result = await this.pool.query(sql, w.values);
|
|
368
|
+
return result.rows.length > 0 ? this.rowToDoc(result.rows[0]) : null;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
find(
|
|
372
|
+
filter: Record<string, unknown> = {},
|
|
373
|
+
options: FindOptions = {}
|
|
374
|
+
): PostgresCursor<T> {
|
|
375
|
+
return new PostgresCursor<T>(this, filter, options);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async insertOne(
|
|
379
|
+
doc: Partial<T> & Record<string, unknown>
|
|
380
|
+
): Promise<{ insertedId: ObjectId; acknowledged: boolean }> {
|
|
381
|
+
const id = doc._id
|
|
382
|
+
? typeof doc._id === "string"
|
|
383
|
+
? doc._id
|
|
384
|
+
: (doc._id as ObjectId).toString()
|
|
385
|
+
: randomUUID();
|
|
386
|
+
|
|
387
|
+
const entries = Object.entries(doc).filter(([k]) => k !== "_id");
|
|
388
|
+
const cols = ["_id", ...entries.map(([k]) => `"${snakeCase(k)}"`)];
|
|
389
|
+
const placeholders = [
|
|
390
|
+
"$1",
|
|
391
|
+
...entries.map((_, i) => `$${i + 2}`),
|
|
392
|
+
];
|
|
393
|
+
const values: unknown[] = [
|
|
394
|
+
id,
|
|
395
|
+
...entries.map(([, v]) => {
|
|
396
|
+
if (v instanceof ObjectId) return v.toString();
|
|
397
|
+
if (typeof v === "object" && v !== null && !(v instanceof Date) && !Array.isArray(v))
|
|
398
|
+
return JSON.stringify(v);
|
|
399
|
+
if (Array.isArray(v)) return JSON.stringify(v);
|
|
400
|
+
return v;
|
|
401
|
+
}),
|
|
402
|
+
];
|
|
403
|
+
|
|
404
|
+
const sql = `INSERT INTO "${this.tableName}" (${cols.join(", ")}) VALUES (${placeholders.join(", ")}) ON CONFLICT DO NOTHING RETURNING _id`;
|
|
405
|
+
await this.pool.query(sql, values);
|
|
406
|
+
return { insertedId: new ObjectId(id), acknowledged: true };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async insertMany(
|
|
410
|
+
docs: Array<Partial<T> & Record<string, unknown>>
|
|
411
|
+
): Promise<{ insertedIds: ObjectId[]; acknowledged: boolean }> {
|
|
412
|
+
const ids: ObjectId[] = [];
|
|
413
|
+
for (const doc of docs) {
|
|
414
|
+
const result = await this.insertOne(doc);
|
|
415
|
+
ids.push(result.insertedId);
|
|
416
|
+
}
|
|
417
|
+
return { insertedIds: ids, acknowledged: true };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async updateOne(
|
|
421
|
+
filter: Record<string, unknown>,
|
|
422
|
+
update: Record<string, unknown>
|
|
423
|
+
): Promise<{ matchedCount: number; modifiedCount: number; acknowledged: boolean }> {
|
|
424
|
+
const w = filterToWhere(filter);
|
|
425
|
+
const u = updateToSet(update, w.values.length + 1);
|
|
426
|
+
if (u.setClauses.length === 0) {
|
|
427
|
+
return { matchedCount: 0, modifiedCount: 0, acknowledged: true };
|
|
428
|
+
}
|
|
429
|
+
const sql = `UPDATE "${this.tableName}" SET ${u.setClauses.join(", ")} WHERE ${w.text}`;
|
|
430
|
+
const result = await this.pool.query(sql, [...w.values, ...u.values]);
|
|
431
|
+
const count = result.rowCount ?? 0;
|
|
432
|
+
return { matchedCount: count, modifiedCount: count, acknowledged: true };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async updateMany(
|
|
436
|
+
filter: Record<string, unknown>,
|
|
437
|
+
update: Record<string, unknown>
|
|
438
|
+
): Promise<{ matchedCount: number; modifiedCount: number; acknowledged: boolean }> {
|
|
439
|
+
return this.updateOne(filter, update); // same SQL, no LIMIT 1
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
async deleteOne(
|
|
443
|
+
filter: Record<string, unknown>
|
|
444
|
+
): Promise<{ deletedCount: number; acknowledged: boolean }> {
|
|
445
|
+
const w = filterToWhere(filter);
|
|
446
|
+
const sql = `DELETE FROM "${this.tableName}" WHERE ${w.text}`;
|
|
447
|
+
const result = await this.pool.query(sql, w.values);
|
|
448
|
+
return { deletedCount: result.rowCount ?? 0, acknowledged: true };
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
async deleteMany(
|
|
452
|
+
filter: Record<string, unknown>
|
|
453
|
+
): Promise<{ deletedCount: number; acknowledged: boolean }> {
|
|
454
|
+
return this.deleteOne(filter);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
async countDocuments(
|
|
458
|
+
filter: Record<string, unknown> = {}
|
|
459
|
+
): Promise<number> {
|
|
460
|
+
const w = filterToWhere(filter);
|
|
461
|
+
const sql = `SELECT COUNT(*)::int AS count FROM "${this.tableName}" WHERE ${w.text}`;
|
|
462
|
+
const result = await this.pool.query(sql, w.values);
|
|
463
|
+
return result.rows[0]?.count ?? 0;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
async distinct(
|
|
467
|
+
field: string,
|
|
468
|
+
filter: Record<string, unknown> = {}
|
|
469
|
+
): Promise<unknown[]> {
|
|
470
|
+
const col = `"${snakeCase(field)}"`;
|
|
471
|
+
const w = filterToWhere(filter);
|
|
472
|
+
const sql = `SELECT DISTINCT ${col} FROM "${this.tableName}" WHERE ${w.text}`;
|
|
473
|
+
const result = await this.pool.query(sql, w.values);
|
|
474
|
+
return result.rows.map((r) => r[snakeCase(field)]);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
async aggregate(pipeline: Record<string, unknown>[]): Promise<T[]> {
|
|
478
|
+
// Basic aggregation support — handle common patterns
|
|
479
|
+
// For complex pipelines, we'd need a full translator.
|
|
480
|
+
// For now, log a warning and return empty.
|
|
481
|
+
logger.warn(
|
|
482
|
+
{ pipeline, table: this.tableName },
|
|
483
|
+
"aggregate() called — basic translation only"
|
|
484
|
+
);
|
|
485
|
+
return [];
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
async createIndex(
|
|
489
|
+
_spec: Record<string, unknown>,
|
|
490
|
+
_options?: Record<string, unknown>
|
|
491
|
+
): Promise<void> {
|
|
492
|
+
// Indexes are pre-created in the migration. This is a no-op.
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
async findOneAndUpdate(
|
|
496
|
+
filter: Record<string, unknown>,
|
|
497
|
+
update: Record<string, unknown>,
|
|
498
|
+
options?: { upsert?: boolean; returnDocument?: "before" | "after" }
|
|
499
|
+
): Promise<{ value: T | null }> {
|
|
500
|
+
if (options?.upsert) {
|
|
501
|
+
const existing = await this.findOne(filter);
|
|
502
|
+
if (!existing) {
|
|
503
|
+
const doc = { ...filter, ...((update.$set as Record<string, unknown>) ?? update) };
|
|
504
|
+
await this.insertOne(doc as Partial<T> & Record<string, unknown>);
|
|
505
|
+
const inserted = await this.findOne(filter);
|
|
506
|
+
return { value: inserted };
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
await this.updateOne(filter, update);
|
|
510
|
+
const updated = await this.findOne(filter);
|
|
511
|
+
return { value: updated };
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
async findOneAndDelete(
|
|
515
|
+
filter: Record<string, unknown>
|
|
516
|
+
): Promise<{ value: T | null }> {
|
|
517
|
+
const doc = await this.findOne(filter);
|
|
518
|
+
if (doc) await this.deleteOne(filter);
|
|
519
|
+
return { value: doc };
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// RuVector extension: semantic search via pgvector
|
|
523
|
+
async semanticSearch(
|
|
524
|
+
queryEmbedding: number[],
|
|
525
|
+
limit = 10,
|
|
526
|
+
filter: Record<string, unknown> = {}
|
|
527
|
+
): Promise<Array<T & { similarity: number }>> {
|
|
528
|
+
const w = filterToWhere(filter);
|
|
529
|
+
const embIdx = w.values.length + 1;
|
|
530
|
+
const limIdx = embIdx + 1;
|
|
531
|
+
const sql = `
|
|
532
|
+
SELECT *, 1 - (embedding <=> $${embIdx}::vector) AS similarity
|
|
533
|
+
FROM "${this.tableName}"
|
|
534
|
+
WHERE ${w.text} AND embedding IS NOT NULL
|
|
535
|
+
ORDER BY embedding <=> $${embIdx}::vector
|
|
536
|
+
LIMIT $${limIdx}
|
|
537
|
+
`;
|
|
538
|
+
const result = await this.pool.query(sql, [
|
|
539
|
+
...w.values,
|
|
540
|
+
`[${queryEmbedding.join(",")}]`,
|
|
541
|
+
limit,
|
|
542
|
+
]);
|
|
543
|
+
return result.rows.map((r) => ({ ...this.rowToDoc(r), similarity: r.similarity }));
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// ---------------------------------------------------------------------------
|
|
548
|
+
// Cursor — implements MongoDB-like chaining (sort/limit/skip/toArray)
|
|
549
|
+
// ---------------------------------------------------------------------------
|
|
550
|
+
|
|
551
|
+
export class PostgresCursor<T extends Record<string, unknown>> {
|
|
552
|
+
private _sort: Record<string, 1 | -1> = {};
|
|
553
|
+
private _limit?: number;
|
|
554
|
+
private _skip?: number;
|
|
555
|
+
private _projection?: Record<string, 0 | 1>;
|
|
556
|
+
|
|
557
|
+
constructor(
|
|
558
|
+
private collection: PostgresCollection<T>,
|
|
559
|
+
private filter: Record<string, unknown>,
|
|
560
|
+
options: FindOptions = {}
|
|
561
|
+
) {
|
|
562
|
+
if (options.sort) this._sort = options.sort;
|
|
563
|
+
if (options.limit) this._limit = options.limit;
|
|
564
|
+
if (options.skip) this._skip = options.skip;
|
|
565
|
+
if (options.projection) this._projection = options.projection;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
sort(spec: Record<string, 1 | -1>): this {
|
|
569
|
+
this._sort = { ...this._sort, ...spec };
|
|
570
|
+
return this;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
limit(n: number): this {
|
|
574
|
+
this._limit = n;
|
|
575
|
+
return this;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
skip(n: number): this {
|
|
579
|
+
this._skip = n;
|
|
580
|
+
return this;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
project(spec: Record<string, 0 | 1>): this {
|
|
584
|
+
this._projection = spec;
|
|
585
|
+
return this;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
async toArray(): Promise<T[]> {
|
|
589
|
+
const w = filterToWhere(this.filter);
|
|
590
|
+
const order = sortToOrderBy(this._sort);
|
|
591
|
+
let sql = `SELECT * FROM "${this.collection.tableName}" WHERE ${w.text} ${order}`;
|
|
592
|
+
const values = [...w.values];
|
|
593
|
+
if (this._limit !== undefined) {
|
|
594
|
+
sql += ` LIMIT $${values.length + 1}`;
|
|
595
|
+
values.push(this._limit);
|
|
596
|
+
}
|
|
597
|
+
if (this._skip !== undefined) {
|
|
598
|
+
sql += ` OFFSET $${values.length + 1}`;
|
|
599
|
+
values.push(this._skip);
|
|
600
|
+
}
|
|
601
|
+
const pool = getPool();
|
|
602
|
+
const result = await pool.query(sql, values);
|
|
603
|
+
return result.rows.map((row) => {
|
|
604
|
+
const doc: Record<string, unknown> = {};
|
|
605
|
+
for (const [key, val] of Object.entries(row)) {
|
|
606
|
+
doc[camelCase(key)] = val;
|
|
607
|
+
}
|
|
608
|
+
return doc as T;
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// Async iterable support
|
|
613
|
+
async *[Symbol.asyncIterator](): AsyncGenerator<T> {
|
|
614
|
+
const rows = await this.toArray();
|
|
615
|
+
for (const row of rows) {
|
|
616
|
+
yield row;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// ---------------------------------------------------------------------------
|
|
622
|
+
// GridFS replacement — stores files as BYTEA in a `files` table
|
|
623
|
+
// ---------------------------------------------------------------------------
|
|
624
|
+
|
|
625
|
+
export class PostgresGridFSBucket {
|
|
626
|
+
private readonly tableName = "files";
|
|
627
|
+
|
|
628
|
+
async openUploadStream(
|
|
629
|
+
filename: string,
|
|
630
|
+
options?: { metadata?: Record<string, unknown>; contentType?: string }
|
|
631
|
+
) {
|
|
632
|
+
const id = randomUUID();
|
|
633
|
+
const chunks: Buffer[] = [];
|
|
634
|
+
|
|
635
|
+
return {
|
|
636
|
+
id: new ObjectId(id),
|
|
637
|
+
write(chunk: Buffer) {
|
|
638
|
+
chunks.push(chunk);
|
|
639
|
+
},
|
|
640
|
+
async end() {
|
|
641
|
+
const data = Buffer.concat(chunks);
|
|
642
|
+
const pool = getPool();
|
|
643
|
+
await pool.query(
|
|
644
|
+
`INSERT INTO files (_id, filename, content_type, length, data, metadata) VALUES ($1, $2, $3, $4, $5, $6)`,
|
|
645
|
+
[
|
|
646
|
+
id,
|
|
647
|
+
filename,
|
|
648
|
+
options?.contentType ?? "application/octet-stream",
|
|
649
|
+
data.length,
|
|
650
|
+
data,
|
|
651
|
+
JSON.stringify(options?.metadata ?? {}),
|
|
652
|
+
]
|
|
653
|
+
);
|
|
654
|
+
},
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
openDownloadStream(id: ObjectId | string) {
|
|
659
|
+
const fileId = typeof id === "string" ? id : id.toString();
|
|
660
|
+
// Return a readable-like object
|
|
661
|
+
return {
|
|
662
|
+
async toArray(): Promise<Buffer[]> {
|
|
663
|
+
const pool = getPool();
|
|
664
|
+
const result = await pool.query(
|
|
665
|
+
`SELECT data FROM files WHERE _id = $1`,
|
|
666
|
+
[fileId]
|
|
667
|
+
);
|
|
668
|
+
if (result.rows.length === 0) throw new Error("File not found");
|
|
669
|
+
return [result.rows[0].data];
|
|
670
|
+
},
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
async delete(id: ObjectId | string) {
|
|
675
|
+
const fileId = typeof id === "string" ? id : id.toString();
|
|
676
|
+
const pool = getPool();
|
|
677
|
+
await pool.query(`DELETE FROM files WHERE _id = $1`, [fileId]);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
async find(filter: Record<string, unknown> = {}) {
|
|
681
|
+
const w = filterToWhere(filter);
|
|
682
|
+
const pool = getPool();
|
|
683
|
+
const result = await pool.query(
|
|
684
|
+
`SELECT _id, filename, content_type, length, metadata, created_at FROM files WHERE ${w.text}`,
|
|
685
|
+
w.values
|
|
686
|
+
);
|
|
687
|
+
return {
|
|
688
|
+
toArray: async () => result.rows,
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// ---------------------------------------------------------------------------
|
|
694
|
+
// Helpers
|
|
695
|
+
// ---------------------------------------------------------------------------
|
|
696
|
+
|
|
697
|
+
function camelCase(s: string): string {
|
|
698
|
+
if (s === "_id") return "_id";
|
|
699
|
+
return s.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
700
|
+
}
|