diff --git a/.cursor/conduit-backend/docker-compose.yml b/.cursor/conduit-backend/docker-compose.yml index e7bf83366..6d2081e09 100644 --- a/.cursor/conduit-backend/docker-compose.yml +++ b/.cursor/conduit-backend/docker-compose.yml @@ -21,8 +21,32 @@ services: MONGO_INITDB_DATABASE: 'conduit' MONGO_INITDB_ROOT_USERNAME: 'conduit' MONGO_INITDB_ROOT_PASSWORD: 'pass' + entrypoint: + - bash + - -c + - | + cp /mongo-keyfile /tmp/keyfile + chmod 400 /tmp/keyfile + chown mongodb:mongodb /tmp/keyfile + exec docker-entrypoint.sh mongod --replSet rs0 --bind_ip_all --keyFile /tmp/keyfile volumes: - mongo:/data/db + - ./mongo/keyfile:/mongo-keyfile:ro + + mongo-init-replica: + container_name: 'conduit-mongo-init' + image: 'docker.io/library/mongo:4.4.15' + restart: on-failure + network_mode: 'host' + depends_on: + - mongodb + environment: + MONGO_HOST: '127.0.0.1' + MONGO_INITDB_ROOT_USERNAME: 'conduit' + MONGO_INITDB_ROOT_PASSWORD: 'pass' + volumes: + - ./mongo/init-replica.sh:/init-replica.sh:ro + command: ['bash', '/init-replica.sh'] redis: container_name: 'conduit-redis' @@ -47,7 +71,7 @@ services: CLIENT_HTTP_PORT: '3000' CLIENT_SOCKET_PORT: '3001' __DEFAULT_HOST_URL: '${ADMIN_DEFAULT_HOST_URL:-http://localhost:3030}' - DB_CONN_URI: 'mongodb://conduit:pass@127.0.0.1:27017/conduit?authSource=admin' + DB_CONN_URI: 'mongodb://conduit:pass@127.0.0.1:27017/conduit?authSource=admin&replicaSet=rs0' volumes: mongo: diff --git a/.cursor/conduit-backend/mongo/init-replica.sh b/.cursor/conduit-backend/mongo/init-replica.sh new file mode 100644 index 000000000..e4bb536d2 --- /dev/null +++ b/.cursor/conduit-backend/mongo/init-replica.sh @@ -0,0 +1,20 @@ +#!/bin/bash +set -euo pipefail +HOST="${MONGO_HOST:-conduit-mongo}" +USER="${MONGO_INITDB_ROOT_USERNAME:-conduit}" +PASS="${MONGO_INITDB_ROOT_PASSWORD:-pass}" + +until mongo --host "$HOST" -u "$USER" -p "$PASS" --authenticationDatabase admin --quiet --eval 'db.adminCommand({ ping: 1 })' >/dev/null 2>&1; do + sleep 2 +done + +mongo --host "$HOST" -u "$USER" -p "$PASS" --authenticationDatabase admin --quiet --eval ' + try { + rs.status(); + } catch (err) { + rs.initiate({ + _id: "rs0", + members: [{ _id: 0, host: "'"$HOST"':27017" }] + }); + } +' diff --git a/.cursor/conduit-backend/mongo/keyfile b/.cursor/conduit-backend/mongo/keyfile new file mode 100644 index 000000000..8d7ffaf78 --- /dev/null +++ b/.cursor/conduit-backend/mongo/keyfile @@ -0,0 +1 @@ +conduit-dev-mongo-replica-keyfile-do-not-use-in-production-replace-me-before-any-real-deployment-0123456789abcdefghijklmnopqrstuvwxyz diff --git a/apps/.DS_Store b/apps/.DS_Store deleted file mode 100644 index 7d5804ed1..000000000 Binary files a/apps/.DS_Store and /dev/null differ diff --git a/package.json b/package.json index e5f90b63c..f970c6e73 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,8 @@ "start": "next start -p 8080", "lint": "eslint .", "test:semantic-colors": "node --experimental-strip-types --test src/lib/semantic-colors.test.ts src/lib/reactflow-edge-colors.test.ts", + "test:event-relays": "node --experimental-strip-types --test src/lib/event-relays/preview.test.ts", + "test:realtime": "node --experimental-strip-types --test src/lib/realtime/adminSocketUrl.test.ts src/lib/realtime/change-events.test.ts src/lib/realtime/connection-snippets.test.ts", "test:schema-fields": "node --experimental-strip-types --test src/lib/database/schema-field-definition.test.ts src/lib/database/system-schema-fields.test.ts", "build:docker": "docker build --platform linux/amd64 -t ghcr.io/conduitplatform/conduit-ui:latest .", "prepare": "husky", @@ -80,6 +82,7 @@ "recharts": "^3.8.1", "rehype-prism-plus": "^2.0.2", "server-only": "^0.0.1", + "socket.io-client": "^4.8.3", "tailwind-merge": "^3.4.0", "tailwindcss": "^4.2.2", "tailwindcss-animate": "^1.0.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 44a6c2ade..6f27a7453 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -198,6 +198,9 @@ importers: server-only: specifier: ^0.0.1 version: 0.0.1 + socket.io-client: + specifier: ^4.8.3 + version: 4.8.3 tailwind-merge: specifier: ^3.4.0 version: 3.5.0 @@ -4603,6 +4606,12 @@ packages: integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==, } + engine.io-client@6.6.6: + resolution: + { + integrity: sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==, + } + engine.io-parser@5.2.3: resolution: { @@ -8119,6 +8128,13 @@ packages: integrity: sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==, } + socket.io-client@4.8.3: + resolution: + { + integrity: sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==, + } + engines: { node: '>=10.0.0' } + socket.io-parser@4.2.6: resolution: { @@ -8971,6 +8987,21 @@ packages: utf-8-validate: optional: true + ws@8.21.3: + resolution: + { + integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==, + } + engines: { node: '>=10.0.0' } + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + wsl-utils@0.1.0: resolution: { @@ -8978,6 +9009,13 @@ packages: } engines: { node: '>=18' } + xmlhttprequest-ssl@2.1.2: + resolution: + { + integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==, + } + engines: { node: '>=0.4.0' } + xtend@4.0.2: resolution: { @@ -11989,6 +12027,18 @@ snapshots: emoji-regex@9.2.2: {} + engine.io-client@6.6.6: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.3 + engine.io-parser: 5.2.3 + ws: 8.21.3 + xmlhttprequest-ssl: 2.1.2 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + engine.io-parser@5.2.3: {} engine.io@6.6.6: @@ -14371,6 +14421,17 @@ snapshots: - supports-color - utf-8-validate + socket.io-client@4.8.3: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.3 + engine.io-client: 6.6.6 + socket.io-parser: 4.2.6 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + socket.io-parser@4.2.6: dependencies: '@socket.io/component-emitter': 3.1.2 @@ -14936,10 +14997,14 @@ snapshots: ws@8.20.0: {} + ws@8.21.3: {} + wsl-utils@0.1.0: dependencies: is-wsl: 3.1.0 + xmlhttprequest-ssl@2.1.2: {} + xtend@4.0.2: {} y18n@5.0.8: {} diff --git a/src/app/(dashboard)/(modules)/database/settings/page.tsx b/src/app/(dashboard)/(modules)/database/settings/page.tsx index 20308cca0..7b8b50c19 100644 --- a/src/app/(dashboard)/(modules)/database/settings/page.tsx +++ b/src/app/(dashboard)/(modules)/database/settings/page.tsx @@ -1,14 +1,19 @@ import { Settings } from '@/components/database/settings/settings'; -import { getDatabaseSettings, getDatabaseType } from '@/lib/api/database'; +import { + getDatabaseRealtimeStatus, + getDatabaseSettings, + getDatabaseType, +} from '@/lib/api/database'; export default async function DatabaseSettingsPage() { - const [settingsResult, typeResult] = await Promise.allSettled([ - getDatabaseSettings(), - getDatabaseType(), - ]); + const [settingsResult, typeResult, realtimeResult] = await Promise.allSettled( + [getDatabaseSettings(), getDatabaseType(), getDatabaseRealtimeStatus()] + ); const databaseType = typeResult.status === 'fulfilled' ? typeResult.value.result : 'Unknown'; + const realtimeStatus = + realtimeResult.status === 'fulfilled' ? realtimeResult.value : null; if (settingsResult.status === 'rejected') { return ( @@ -25,6 +30,10 @@ export default async function DatabaseSettingsPage() { } return ( - + ); } diff --git a/src/app/(dashboard)/(modules)/router/event-relays/page.tsx b/src/app/(dashboard)/(modules)/router/event-relays/page.tsx new file mode 100644 index 000000000..509f7094a --- /dev/null +++ b/src/app/(dashboard)/(modules)/router/event-relays/page.tsx @@ -0,0 +1,32 @@ +import { getEventRelays, getRouterSettings } from '@/lib/api/router'; +import { EventRelayList } from '@/components/router/event-relays/event-relay-list'; + +export default async function EventRelaysPage(props: { + searchParams: Promise<{ + skip?: string; + limit?: string; + search?: string; + }>; +}) { + const searchParams = await props.searchParams; + const skip = Number(searchParams.skip ?? 0); + const limit = Number(searchParams.limit ?? 10); + const [{ relays, count }, { config }] = await Promise.all([ + getEventRelays({ + skip, + limit, + search: searchParams.search, + }), + getRouterSettings(), + ]); + + return ( +
+ +
+ ); +} diff --git a/src/app/(dashboard)/(modules)/router/page.tsx b/src/app/(dashboard)/(modules)/router/page.tsx index 64eaa8897..11e104ea1 100644 --- a/src/app/(dashboard)/(modules)/router/page.tsx +++ b/src/app/(dashboard)/(modules)/router/page.tsx @@ -1,5 +1,12 @@ import React from 'react'; -import { BarChart3, Network, Route, Settings, Shield } from 'lucide-react'; +import { + BarChart3, + Network, + Radio, + Route, + Settings, + Shield, +} from 'lucide-react'; import { ModuleDashboard } from '@/components/dashboard/ModuleDashboard'; import { getModuleStatus, @@ -66,6 +73,12 @@ export default async function RouterDashboard() { icon: , href: '/router/vizualize', }, + { + title: 'Event Relays', + description: 'Forward bus events to socket subscribers', + icon: , + href: '/router/event-relays', + }, { title: 'Settings', description: 'Router module configuration', diff --git a/src/components/database/models/data-explorer/index.tsx b/src/components/database/models/data-explorer/index.tsx index f87754449..59ceb830d 100644 --- a/src/components/database/models/data-explorer/index.tsx +++ b/src/components/database/models/data-explorer/index.tsx @@ -31,6 +31,8 @@ import { import { Badge } from '@/components/ui/badge'; import { toast } from '@/lib/hooks/use-toast'; import { deleteSchemaDocument } from '@/lib/api/database'; +import { useDatabaseLiveUpdates } from '@/lib/hooks/use-database-live-updates'; +import { LiveUpdatesStatus } from './live-updates-status'; import { analyzeModelDataPermissions, getExtensionFieldNames, @@ -74,6 +76,11 @@ export function DataExplorer({ schema, documents }: DataExplorerProps) { () => analyzeModelDataPermissions(schema), [schema] ); + const liveEnabled = schema.modelOptions?.conduit?.realtime?.enabled === true; + const liveUpdates = useDatabaseLiveUpdates({ + schemaName: schema.name, + enabled: liveEnabled, + }); // Get schema fields for column definitions const schemaFields = React.useMemo(() => { @@ -160,6 +167,7 @@ export function DataExplorer({ schema, documents }: DataExplorerProps) { }; const handleRefresh = () => { + liveUpdates.consumePendingUpdates(); router.refresh(); toast({ title: 'Data refreshed' }); }; @@ -185,6 +193,7 @@ export function DataExplorer({ schema, documents }: DataExplorerProps) { const handleDocumentSaved = () => { setSelectedDocument(null); setIsCreatingNew(false); + liveUpdates.consumePendingUpdates(); router.refresh(); }; @@ -372,9 +381,22 @@ export function DataExplorer({ schema, documents }: DataExplorerProps) { + + {/* Refresh */} - {/* Create New */} diff --git a/src/components/database/models/data-explorer/live-updates-status.tsx b/src/components/database/models/data-explorer/live-updates-status.tsx new file mode 100644 index 000000000..a8fb8ea16 --- /dev/null +++ b/src/components/database/models/data-explorer/live-updates-status.tsx @@ -0,0 +1,93 @@ +'use client'; + +import * as React from 'react'; +import { Button } from '@/components/ui/button'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; +import type { LiveConnectionState } from '@/lib/hooks/use-database-live-updates'; + +type LiveUpdatesStatusProps = { + connectionState: LiveConnectionState; + pendingUpdates: number; + errorMessage?: string | null; + onApplyUpdates: () => void; +}; + +const STATE_LABEL: Record, string> = { + connecting: 'Connecting', + live: 'Live', + reconnecting: 'Reconnecting', + error: 'Unavailable', +}; + +export function LiveUpdatesStatus({ + connectionState, + pendingUpdates, + errorMessage, + onApplyUpdates, +}: LiveUpdatesStatusProps) { + if (connectionState === 'idle') return null; + + let statusHint = 'Connecting to live updates.'; + if (connectionState === 'error') { + statusHint = errorMessage || 'Live updates are unavailable'; + } else if (connectionState === 'live') { + statusHint = + 'Listening for document changes. The table does not refresh until you apply updates.'; + } + + return ( + +
+ + + + + {STATE_LABEL[connectionState]} + + + {statusHint} + + + {pendingUpdates > 0 && ( + + + + + + Refresh the current filtered page. Open documents stay as they + are. + + + )} +
+
+ ); +} diff --git a/src/components/database/models/settings-panel/index.tsx b/src/components/database/models/settings-panel/index.tsx index aeed051f9..a1fd7f0fb 100644 --- a/src/components/database/models/settings-panel/index.tsx +++ b/src/components/database/models/settings-panel/index.tsx @@ -15,6 +15,7 @@ import { CrudPermissions } from './crud-permissions'; import { deriveCrudOperationsFromSchema } from './crud-state'; import { AuthSettings } from './auth-settings'; import { IndicesConfig } from './indices-config'; +import { LiveUpdateSnippets } from './live-update-snippets'; import { Button } from '@/components/ui/button'; import { Card, @@ -25,6 +26,7 @@ import { } from '@/components/ui/card'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Badge } from '@/components/ui/badge'; +import { Switch } from '@/components/ui/switch'; import { Label } from '@/components/ui/label'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { @@ -45,7 +47,7 @@ import { AlertDialogTitle, AlertDialogTrigger, } from '@/components/ui/alert-dialog'; -import { Save, Trash2, AlertTriangle, Info } from 'lucide-react'; +import { Save, Trash2, AlertTriangle, Info, Radio } from 'lucide-react'; import { toast } from '@/lib/hooks/use-toast'; /** Radix Select rejects empty string values */ @@ -98,7 +100,7 @@ function getIndexSignature(index: SchemaIndex) { } function isPrimaryIndex(index: SchemaIndex) { - return index.fields.length === 1 && index.fields[0] === '_id'; + return index.fields?.length === 1 && index.fields[0] === '_id'; } function getSchemaReadPreference(schema: DeclaredSchema) { @@ -141,6 +143,12 @@ export function SettingsPanel({ const [initialReadPreference, setInitialReadPreference] = React.useState(() => getSchemaReadPreference(schema) ); + const [realtimeEnabled, setRealtimeEnabled] = React.useState( + schema.modelOptions?.conduit?.realtime?.enabled ?? false + ); + const [initialRealtimeEnabled, setInitialRealtimeEnabled] = React.useState( + schema.modelOptions?.conduit?.realtime?.enabled ?? false + ); const isOwnedByDatabase = !schema.ownerModule || schema.ownerModule === 'database'; @@ -150,6 +158,8 @@ export function SettingsPanel({ const nextAuthEnabled = schema.modelOptions?.conduit?.authorization?.enabled ?? false; const nextReadPreference = getSchemaReadPreference(schema); + const nextRealtimeEnabled = + schema.modelOptions?.conduit?.realtime?.enabled ?? false; setCrudOperations(nextCrudOperations); setInitialCrudOperations(nextCrudOperations); @@ -157,13 +167,15 @@ export function SettingsPanel({ setInitialAuthEnabled(nextAuthEnabled); setMongoReadPreference(nextReadPreference); setInitialReadPreference(nextReadPreference); + setRealtimeEnabled(nextRealtimeEnabled); + setInitialRealtimeEnabled(nextRealtimeEnabled); }, [schema]); const loadIndexes = React.useCallback(async () => { setIsLoadingIndices(true); try { const result = await getSchemaIndexes(schema._id); - const customIndexes = (result.indexes as SchemaIndex[]).filter( + const customIndexes = result.indexes.filter( index => !isPrimaryIndex(index) ); setIndices(customIndexes); @@ -199,6 +211,7 @@ export function SettingsPanel({ JSON.stringify(crudOperations) !== JSON.stringify(initialCrudOperations) || authEnabled !== initialAuthEnabled || mongoReadPreference !== initialReadPreference || + realtimeEnabled !== initialRealtimeEnabled || JSON.stringify(indices.map(getIndexSignature)) !== JSON.stringify(initialIndices.map(getIndexSignature)); @@ -243,6 +256,9 @@ export function SettingsPanel({ }, ...(databaseType === 'MongoDB' ? { + realtime: { + enabled: realtimeEnabled, + }, readPreference: mongoReadPreference !== MONGO_READ_PREF_DEFAULT ? mongoReadPreference @@ -256,6 +272,7 @@ export function SettingsPanel({ setInitialCrudOperations(crudOperations); setInitialAuthEnabled(authEnabled); setInitialReadPreference(mongoReadPreference); + setInitialRealtimeEnabled(realtimeEnabled); toast({ title: 'Settings saved' }); onSave(); } catch (error: any) { @@ -273,6 +290,7 @@ export function SettingsPanel({ loadIndexes, mongoReadPreference, onSave, + realtimeEnabled, schema._id, syncIndexes, ]); @@ -406,6 +424,55 @@ export function SettingsPanel({

+ {databaseType === 'MongoDB' && ( + + + + + Live updates + + + Opt this schema into MongoDB change streams. The Data Explorer + shows an updates-available badge instead of auto-refreshing. + + + +
+
+ +

+ Requires the module-level live updates switch and a + replica set. +

+
+ +
+ {realtimeEnabled && crudOperations.read?.enabled !== true && ( +

+ CMS read is off, so client apps cannot subscribe. Admin Data + Explorer can still listen. +

+ )} + +
+
+ )} + {databaseType === 'MongoDB' && ( diff --git a/src/components/database/models/settings-panel/live-update-snippets.tsx b/src/components/database/models/settings-panel/live-update-snippets.tsx new file mode 100644 index 000000000..c1d6556a5 --- /dev/null +++ b/src/components/database/models/settings-panel/live-update-snippets.tsx @@ -0,0 +1,321 @@ +'use client'; + +import * as React from 'react'; +import { Check, Copy } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { toast } from '@/lib/hooks/use-toast'; +import { getAdminSettings } from '@/lib/api/settings'; +import { getRouterSettings } from '@/lib/api/router'; +import { + deriveAdminSocketUrl, + deriveClientSocketUrl, +} from '@/lib/realtime/adminSocketUrl'; +import { + DATABASE_SOCKET_LISTEN_EVENTS, + DATABASE_SOCKET_PATH, + buildAdminJavascriptSnippet, + buildClientJavascriptSnippet, + buildPostmanConnection, + socketEndpoint, +} from '@/lib/realtime/connection-snippets'; + +const DEFAULT_ADMIN_SOCKET = 'http://localhost:3031'; +const DEFAULT_CLIENT_SOCKET = 'http://localhost:3001'; + +type LiveUpdateSnippetsProps = { + schemaName: string; + realtimeEnabled: boolean; + cmsReadEnabled: boolean; + authorizationEnabled: boolean; +}; + +export function LiveUpdateSnippets({ + schemaName, + realtimeEnabled, + cmsReadEnabled, + authorizationEnabled, +}: LiveUpdateSnippetsProps) { + const [adminSocketUrl, setAdminSocketUrl] = + React.useState(DEFAULT_ADMIN_SOCKET); + const [clientSocketUrl, setClientSocketUrl] = React.useState( + DEFAULT_CLIENT_SOCKET + ); + + React.useEffect(() => { + let cancelled = false; + void Promise.all([getAdminSettings(), getRouterSettings()]) + .then(([admin, router]) => { + if (cancelled) return; + setAdminSocketUrl( + safeSocketUrl( + admin?.config?.hostUrl, + deriveAdminSocketUrl, + DEFAULT_ADMIN_SOCKET + ) + ); + setClientSocketUrl( + safeSocketUrl( + router?.config?.hostUrl, + deriveClientSocketUrl, + DEFAULT_CLIENT_SOCKET + ) + ); + }) + .catch(() => { + if (cancelled) return; + setAdminSocketUrl(DEFAULT_ADMIN_SOCKET); + setClientSocketUrl(DEFAULT_CLIENT_SOCKET); + }); + return () => { + cancelled = true; + }; + }, []); + + const clientSnippet = buildClientJavascriptSnippet({ + socketUrl: clientSocketUrl, + schemaName, + documentScoped: authorizationEnabled, + }); + const adminSnippet = buildAdminJavascriptSnippet({ + socketUrl: adminSocketUrl, + schemaName, + }); + const postman = buildPostmanConnection({ + adminSocketUrl, + schemaName, + }); + + return ( + +
+
+

Connect to change events

+

+ Subscribe this schema on the{' '} + /database/ Socket.IO namespace. + Events are metadata only — no document body. +

+
+ + {!realtimeEnabled && ( +

+ Enable live updates and save before this socket will receive events. +

+ )} + +
+ + + + +
+ + + + + JavaScript + + + Postman + + + Admin + + + + + {!cmsReadEnabled && ( +

+ CMS read is off, so client apps cannot subscribe. Turn on CRUD + read, or use the Admin tab. +

+ )} + {authorizationEnabled && ( +

+ Authorization is on — client subscribe must include a document + the user can read. +

+ )} + +
+ + +
    +
  1. + New → Socket.IO Request. Postman has no Handshake Auth field — + put the token on Headers instead. +
  2. +
  3. + +
  4. +
  5. + Settings: handshake path{' '} + {postman.path}, client + version {postman.clientVersion}. +
  6. +
  7. + Headers: +
    + {postman.headers.map(header => ( + + ))} +
    +
  8. +
  9. + Listen for{' '} + {postman.listenFor.map(event => ( + + {event} + + ))} + , click Connect, then emit: +
  10. +
+ +

+ Event name must be exactly{' '} + {postman.subscribeEvent}, + format JSON. Run Admin login first so{' '} + adminToken is set. +

+
+ + +

+ Same handshake as Data Explorer. Use an admin JWT, or a 30-second{' '} + POST /realtime/ticket token in{' '} + auth.token. +

+ +
+
+
+
+ ); +} + +function CopyRow({ + label, + value, + display, +}: { + label: string; + value: string; + display?: string; +}) { + return ( +
+
+

{label}

+

+ {display ?? value} +

+
+ +
+ ); +} + +function SnippetBlock({ + label, + value, + language, +}: { + label: string; + value: string; + language: 'javascript' | 'json'; +}) { + return ( +
+
+

{label}

+ +
+
+        {value}
+      
+
+ ); +} + +function CopyButton({ value, label }: { value: string; label: string }) { + const [copied, setCopied] = React.useState(false); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(value); + setCopied(true); + toast({ title: `${label} copied` }); + window.setTimeout(() => setCopied(false), 1500); + } catch { + toast({ + title: 'Copy failed', + variant: 'destructive', + }); + } + }; + + return ( + + + + + +

{copied ? 'Copied' : `Copy ${label}`}

+
+
+ ); +} + +function safeSocketUrl( + hostUrl: unknown, + derive: (url: string) => string, + fallback: string +): string { + if (typeof hostUrl !== 'string' || hostUrl.trim() === '') return fallback; + try { + return derive(hostUrl); + } catch { + return fallback; + } +} diff --git a/src/components/database/settings/settings.tsx b/src/components/database/settings/settings.tsx index 85596ca5d..f7e726428 100644 --- a/src/components/database/settings/settings.tsx +++ b/src/components/database/settings/settings.tsx @@ -1,6 +1,6 @@ 'use client'; import { z } from 'zod'; -import { DatabaseConfig } from '@/lib/models/database'; +import { DatabaseConfig, DatabaseRealtimeStatus } from '@/lib/models/database'; import { useState } from 'react'; import { useForm } from 'react-hook-form'; import { rhfZodResolver } from '@/lib/zod-form'; @@ -13,14 +13,18 @@ import { useSettingsSave } from '@/lib/hooks/use-settings-save'; interface Props { data: DatabaseConfig; databaseType: string; + realtimeStatus?: DatabaseRealtimeStatus | null; } -export const Settings = ({ data, databaseType }: Props) => { +export const Settings = ({ data, databaseType, realtimeStatus }: Props) => { const [edit, setEdit] = useState(false); const { save, isSaving } = useSettingsSave('Database'); const form = useForm>({ resolver: rhfZodResolver(DatabaseSettingsSchema), - defaultValues: data, + defaultValues: { + ...data, + realtime: { enabled: data.realtime?.enabled ?? false }, + }, }); const { reset, control, handleSubmit } = form; @@ -37,10 +41,10 @@ export const Settings = ({ data, databaseType }: Props) => {
-

Database Settings

+

Database Settings

{databaseType === 'MongoDB' - ? 'Configure MongoDB replica set read preferences, write concern, and read concern levels for your deployment.' + ? 'Configure live updates, replica set read preferences, write concern, and read concern.' : 'Database replication settings for your deployment.'}

@@ -53,6 +57,7 @@ export const Settings = ({ data, databaseType }: Props) => { setEdit={setEdit} reset={reset} databaseType={databaseType} + realtimeStatus={realtimeStatus} /> diff --git a/src/components/database/settings/settingsForm.tsx b/src/components/database/settings/settingsForm.tsx index 570567f9c..ff07ca7b2 100644 --- a/src/components/database/settings/settingsForm.tsx +++ b/src/components/database/settings/settingsForm.tsx @@ -14,7 +14,9 @@ import { SelectValue, } from '@/components/ui/select'; import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Switch } from '@/components/ui/switch'; import { SettingsFormActions } from '@/components/settings/SettingsFormActions'; +import type { DatabaseRealtimeStatus } from '@/lib/models/database'; interface Props { control: any; @@ -23,6 +25,36 @@ interface Props { setEdit: (arg0: boolean) => void; reset: any; databaseType: string; + realtimeStatus?: DatabaseRealtimeStatus | null; +} + +function realtimeStatusCopy(status: DatabaseRealtimeStatus | null | undefined) { + if (!status) { + return 'Runtime status is unavailable for this backend.'; + } + switch (status.status) { + case 'live': + return `Live · ${status.activeSchemaCount} opted-in ${ + status.activeSchemaCount === 1 ? 'schema' : 'schemas' + }`; + case 'starting': + return 'Starting the change-stream listener.'; + case 'idle': + return ( + status.message ?? + 'Waiting for a replica set and at least one opted-in schema.' + ); + case 'disabled': + return 'Live updates are turned off for this module.'; + case 'degraded': + return status.message ?? 'The change-stream listener is degraded.'; + case 'unsupported': + return status.message ?? 'Live updates require MongoDB.'; + default: { + const _exhaustive: never = status.status; + return _exhaustive; + } + } } export const SettingsForm = ({ @@ -32,13 +64,14 @@ export const SettingsForm = ({ setEdit, reset, databaseType, + realtimeStatus, }: Props) => { if (databaseType !== 'MongoDB') { return ( - Replica set read preferences are only available for MongoDB - deployments. Your current database type is{' '} + Live updates and replica set read preferences are only available for + MongoDB deployments. Your current database type is{' '} {databaseType}. @@ -47,7 +80,37 @@ export const SettingsForm = ({ return ( <> -
+
+ ( + +
+ + Live updates + + + Watch opted-in schemas through MongoDB change streams. Schemas + must also enable live updates. Data Explorer never + auto-refreshes. + +

+ {realtimeStatusCopy(realtimeStatus)} +

+
+ + + + +
+ )} + />
{}); +socket.emit('unsubscribe', relayId, resourceId);`; + +const STEPS = [ + { + title: 'Bus event', + body: 'A module publishes JSON on an exact Redis channel — for example database realtime or a custom module.', + }, + { + title: 'Active relay', + body: 'The matching relay reads the resource id from the payload and renders the message template.', + }, + { + title: 'ReBAC subscribe', + body: 'Clients never pick a room name. Subscribe succeeds only if the user has the relay permission on that resource.', + }, + { + title: 'Socket emit', + body: 'Router emits socketEvent to the hashed /events/ room. Delivery is ephemeral — missed events are gone.', + }, +] as const; + +const SCOPE = [ + { + title: 'Use when', + body: 'You already publish JSON on an exact Redis bus channel and need live, per-resource UI updates with a ReBAC check.', + }, + { + title: 'Skip when', + body: 'You need replay, history, guaranteed delivery, wildcard channels, or a broadcast with no permission check. Relays are not a queue.', + }, + { + title: 'Requires', + body: 'Router sockets enabled, the Authorization module available, and something publishing on that exact channel.', + }, +] as const; + +interface EventRelayDocsProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function EventRelayDocs({ open, onOpenChange }: EventRelayDocsProps) { + return ( + + + svg]:rotate-180' + )} + > + + + How Event Relays work + + + Forward an exact bus event to permission-scoped socket + subscribers. Not a queue, and not a generic websocket broadcast. + + + + + +
+
+

Scope

+
+ {SCOPE.map(item => ( +
+

+ {item.title} +

+

+ {item.body} +

+
+ ))} +
+
+ +
+

+ How a message moves +

+
    + {STEPS.map((step, index) => ( +
  1. + + {index + 1} + +
    +

    + {step.title} +

    +

    + {step.body} +

    +
    +
  2. + ))} +
+
+ +
+

+ Configure a relay +

+

+ Example: notify clients when an Order document changes. +

+
+ + + + + +
+
+ +
+

+ Subscribe from a client +

+

+ Connect to {'/events/'} with{' '} + {'path: /realtime'} and a user bearer token. Then + subscribe with the relay id and resource id. +

+
+                {CLIENT_SNIPPET}
+              
+
+ +
+

Limits

+
    +
  • + Bus channels must match exactly. Patterns like{' '} + {'database:change:*'} are not supported. +
  • +
  • + Subscribe fails closed if Authorization is unavailable or the + user lacks permission. +
  • +
  • + Turn a relay off with Active to stop forwarding without + deleting it. Deleting drops current subscribers immediately. +
  • +
+
+
+
+
+
+ ); +} + +function Field({ + name, + value, + hint, +}: { + name: string; + value: string; + hint: string; +}) { + return ( +
+
+ {name} +
+
+ {value} +

{hint}

+
+
+ ); +} + +function Code({ children }: { children: string }) { + return ( + + {children} + + ); +} diff --git a/src/components/router/event-relays/event-relay-form.tsx b/src/components/router/event-relays/event-relay-form.tsx new file mode 100644 index 000000000..92694b7e3 --- /dev/null +++ b/src/components/router/event-relays/event-relay-form.tsx @@ -0,0 +1,213 @@ +'use client'; + +import { useMemo } from 'react'; +import { useForm, useWatch } from 'react-hook-form'; +import { rhfZodResolver } from '@/lib/zod-form'; +import { Form } from '@/components/ui/form'; +import { InputField } from '@/components/ui/form-inputs/InputField'; +import SwitchField from '@/components/ui/form-inputs/SwitchField'; +import { CodeField } from '@/components/ui/form-inputs/CodeField'; +import { Button } from '@/components/ui/button'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { + EventRelayFormSchema, + EventRelayFormValues, + parseJsonField, +} from '@/components/router/event-relays/zod'; +import { previewEventRelay } from '@/lib/event-relays/preview'; +import { EventRelay, EventRelayWriteRequest } from '@/lib/models/Router'; + +const DEFAULT_TEMPLATE = '{\n "id": "{{payload._id}}"\n}'; +const DEFAULT_SAMPLE = + '{\n "_id": "64f1c0a2b4d0e1f2a3b4c5d6",\n "status": "paid"\n}'; + +interface EventRelayFormProps { + relay?: EventRelay | null; + onSubmit: (data: EventRelayWriteRequest) => Promise; + onCancel: () => void; + isSaving?: boolean; +} + +export function EventRelayForm({ + relay, + onSubmit, + onCancel, + isSaving, +}: EventRelayFormProps) { + const form = useForm({ + resolver: rhfZodResolver(EventRelayFormSchema), + defaultValues: { + name: relay?.name ?? '', + notes: relay?.notes ?? '', + active: relay?.active ?? true, + busEvent: relay?.busEvent ?? '', + socketEvent: relay?.socketEvent ?? '', + resourceType: relay?.resourceType ?? '', + resourceIdPath: relay?.resourceIdPath ?? '_id', + permission: relay?.permission ?? 'read', + messageTemplate: relay + ? JSON.stringify(relay.messageTemplate, null, 2) + : DEFAULT_TEMPLATE, + samplePayload: DEFAULT_SAMPLE, + }, + }); + + const watched = useWatch({ control: form.control }); + const preview = useMemo(() => { + try { + const template = parseJsonField( + watched.messageTemplate ?? '', + 'Message template' + ); + const sample = parseJsonField( + watched.samplePayload?.trim() || '{}', + 'Sample payload' + ); + return previewEventRelay({ + resourceIdPath: watched.resourceIdPath || '_id', + messageTemplate: template, + samplePayload: sample, + }); + } catch (err) { + return { + error: err instanceof Error ? err.message : String(err), + }; + } + }, [watched.messageTemplate, watched.samplePayload, watched.resourceIdPath]); + + const handleSubmit = form.handleSubmit(async values => { + const messageTemplate = parseJsonField( + values.messageTemplate, + 'Message template' + ); + const notes = values.notes?.trim(); + await onSubmit({ + name: values.name, + notes: notes || undefined, + active: values.active, + busEvent: values.busEvent, + socketEvent: values.socketEvent, + resourceType: values.resourceType, + resourceIdPath: values.resourceIdPath, + permission: values.permission, + messageTemplate, + }); + }); + + return ( +
+ +
+ + +
+ +
+ + +
+
+ + + +
+ + +
+

Preview

+

+ Local only. Nothing is published to the bus. +

+ {preview.error ? ( +

{preview.error}

+ ) : ( +
+

+ Resource{' '} + + {watched.resourceType || 'Type'}:{preview.resourceId} + +

+
+                {JSON.stringify(preview.payload, null, 2)}
+              
+
+ )} +
+ + Client contract + +

+ Connect to /events/ with{' '} + path: /realtime and a bearer + token. Then emit{' '} + subscribe(relayId, resourceId). +

+
+              {`const socket = io(\`\${SOCKET_URL}/events/\`, {
+  path: '/realtime',
+  extraHeaders: { authorization: \`Bearer \${accessToken}\` },
+});
+socket.emit('subscribe', relayId, resourceId);
+socket.on('${watched.socketEvent || 'your-event'}', payload => {});
+socket.emit('unsubscribe', relayId, resourceId);`}
+            
+
+
+
+ + +
+ + + ); +} diff --git a/src/components/router/event-relays/event-relay-list.tsx b/src/components/router/event-relays/event-relay-list.tsx new file mode 100644 index 000000000..c64e5a456 --- /dev/null +++ b/src/components/router/event-relays/event-relay-list.tsx @@ -0,0 +1,304 @@ +'use client'; + +import { useCallback, useMemo, useState } from 'react'; +import { ColumnDef } from '@tanstack/react-table'; +import { BookOpen, Plus, Radio, Zap } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { DataTable } from '@/components/ui/data-table'; +import { EmptyState } from '@/components/ui/empty-state'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { + PageActions, + PageDescription, + PageHeader, + PageTitle, +} from '@/components/ui/page-header'; +import { SearchInput } from '@/components/ui/form-inputs/SearchInput'; +import { DeleteAlert } from '@/components/helpers/delete'; +import { EventRelayDocs } from '@/components/router/event-relays/event-relay-docs'; +import { EventRelayForm } from '@/components/router/event-relays/event-relay-form'; +import { EventRelay, EventRelayWriteRequest } from '@/lib/models/Router'; +import { + createEventRelay, + deleteEventRelay, + patchEventRelay, +} from '@/lib/api/router'; +import { useSettingsSave } from '@/lib/hooks/use-settings-save'; +import { useRouter } from 'next/navigation'; + +interface EventRelayListProps { + relays: EventRelay[]; + count: number; + socketsEnabled: boolean; +} + +export function EventRelayList({ + relays, + count, + socketsEnabled, +}: EventRelayListProps) { + const router = useRouter(); + const [isCreateOpen, setIsCreateOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [docsOpen, setDocsOpen] = useState(count === 0); + const { save, isSaving } = useSettingsSave('Event Relay'); + + const toggleDocs = useCallback(() => { + setDocsOpen(open => { + const next = !open; + if (next) { + requestAnimationFrame(() => { + document + .getElementById('event-relay-docs') + ?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }); + } + return next; + }); + }, []); + + const refresh = useCallback(() => { + router.refresh(); + }, [router]); + + const handleCreate = async (data: EventRelayWriteRequest) => { + const result = await save({ + action: async () => { + await createEventRelay(data); + await refresh(); + }, + successMessage: 'Event relay created', + }); + if (result.ok) { + setIsCreateOpen(false); + } + }; + + const handleUpdate = async (data: EventRelayWriteRequest) => { + if (!editing) return; + const result = await save({ + action: async () => { + await patchEventRelay(editing._id, data); + await refresh(); + }, + successMessage: 'Event relay updated', + }); + if (result.ok) { + setEditing(null); + } + }; + + const columns = useMemo[]>( + () => [ + { + accessorKey: 'name', + header: 'Name', + cell: ({ row }) => ( +
+

{row.original.name}

+ {row.original.notes ? ( +

+ {row.original.notes} +

+ ) : null} +
+ ), + }, + { + accessorKey: 'busEvent', + header: 'Bus event', + cell: ({ row }) => ( + + {row.original.busEvent} + + ), + }, + { + accessorKey: 'socketEvent', + header: 'Socket event', + cell: ({ row }) => ( + + {row.original.socketEvent} + + ), + }, + { + accessorKey: 'resourceType', + header: 'Resource', + cell: ({ row }) => ( + + {row.original.resourceType}:{row.original.permission} + + ), + }, + { + accessorKey: 'active', + header: 'Status', + cell: ({ row }) => ( + + {row.original.active ? 'Active' : 'Disabled'} + + ), + }, + { + id: 'actions', + cell: ({ row }) => ( +
+ + + save({ + action: async () => { + await deleteEventRelay(row.original._id); + await refresh(); + }, + successMessage: 'Event relay deleted', + }) + } + /> +
+ ), + }, + ], + [save, refresh] + ); + + return ( +
+ +
+ Event Relays + + Send a templated socket message when an exact bus event arrives. + Subscribers join resource rooms on /events/ after a ReBAC check. + +
+ + + + + + + + {docsOpen + ? 'Hide how Event Relays work' + : 'Show how Event Relays work'} + + + + + +
+ + {!socketsEnabled ? ( + + + WebSockets are disabled + + Enable sockets in Router Settings before clients can subscribe to + relays. + + + ) : null} + + + +
+ +
+ + {relays.length === 0 ? ( + setIsCreateOpen(true)}> + + New relay + + } + /> + ) : ( + + )} + + + + + Create event relay + + Match one bus channel and emit a JSON template to the related + resource room. + + + setIsCreateOpen(false)} + isSaving={isSaving} + /> + + + + { + if (!open) setEditing(null); + }} + > + + + Edit event relay + + Changes apply immediately to new bus events. Delivery is ephemeral + and is not replayed. + + + {editing ? ( + setEditing(null)} + isSaving={isSaving} + /> + ) : null} + + +
+ ); +} diff --git a/src/components/router/event-relays/zod.ts b/src/components/router/event-relays/zod.ts new file mode 100644 index 000000000..bbffddbc4 --- /dev/null +++ b/src/components/router/event-relays/zod.ts @@ -0,0 +1,72 @@ +import { z } from 'zod'; +import { parseDotPath, RESERVED_SOCKET_EVENTS } from '@/lib/event-relays/path'; + +export const EventRelayFormSchema = z.object({ + name: z + .string() + .trim() + .min(1, 'Name is required') + .max(64, 'Name must be at most 64 characters') + .regex( + /^[A-Za-z0-9][A-Za-z0-9 _.-]{0,63}$/, + 'Name must start with a letter or number' + ), + notes: z.string().max(256).optional(), + active: z.boolean(), + busEvent: z + .string() + .trim() + .min(1, 'Bus event is required') + .max(128) + .regex( + /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/, + 'Use an exact channel name with no wildcards' + ) + .refine(value => !value.includes('*'), 'Wildcards are not supported'), + socketEvent: z + .string() + .trim() + .min(1, 'Socket event is required') + .max(64) + .regex(/^[A-Za-z][A-Za-z0-9_:-]{0,63}$/, 'Socket event name is invalid') + .refine( + value => !RESERVED_SOCKET_EVENTS.has(value), + 'This socket event name is reserved' + ), + resourceType: z + .string() + .trim() + .min(1, 'Resource type is required') + .regex(/^[A-Za-z][A-Za-z0-9_]{0,63}$/, 'Resource type is invalid'), + resourceIdPath: z + .string() + .trim() + .min(1, 'Resource ID path is required') + .superRefine((value, ctx) => { + try { + parseDotPath(value); + } catch (err) { + ctx.addIssue({ + code: 'custom', + message: err instanceof Error ? err.message : 'Invalid path', + }); + } + }), + permission: z + .string() + .trim() + .min(1, 'Permission is required') + .regex(/^[A-Za-z][A-Za-z0-9_]{0,63}$/, 'Permission is invalid'), + messageTemplate: z.string().trim().min(1, 'Message template is required'), + samplePayload: z.string().optional(), +}); + +export type EventRelayFormValues = z.infer; + +export function parseJsonField(value: string, label: string): unknown { + try { + return JSON.parse(value); + } catch { + throw new Error(`${label} must be valid JSON`); + } +} diff --git a/src/lib/api/database/index.ts b/src/lib/api/database/index.ts index e9c0cebe0..effca7244 100644 --- a/src/lib/api/database/index.ts +++ b/src/lib/api/database/index.ts @@ -4,12 +4,14 @@ import { getApiClient } from '@/lib/api'; import { CreateSchemaRequest, DatabaseConfig, + DatabaseRealtimeStatus, DeclaredSchema, PatchSchemaRequest, PendingSchemas, SchemaOptions, } from '@/lib/models/database'; import { CustomEndpoint } from '@/lib/models/database/custom-endpoints'; +import { normalizeSchemaIndexResponse } from '@/lib/database/schema-indexes'; export const getPendingSchemas = async (args: { skip?: number; @@ -341,9 +343,10 @@ export const createSchemaIndexes = async ( }; export const getSchemaIndexes = async (schemaId: string) => { - return await (await getApiClient()) - .get<{ indexes: unknown[] }>(`/database/schemas/${schemaId}/indexes`) + const data = await (await getApiClient()) + .get(`/database/schemas/${schemaId}/indexes`) .then(res => res.data); + return { indexes: normalizeSchemaIndexResponse(data) }; }; export const deleteSchemaIndexes = async ( @@ -366,6 +369,13 @@ export const getDatabaseSettings = async () => { return res.data; }; +export const getDatabaseRealtimeStatus = async () => { + const res = await ( + await getApiClient() + ).get('/database/realtime/status'); + return res.data; +}; + export const patchDatabaseSettings = async (data: Partial) => { await ( await getApiClient() diff --git a/src/lib/api/realtime/ticket.ts b/src/lib/api/realtime/ticket.ts new file mode 100644 index 000000000..9a83359be --- /dev/null +++ b/src/lib/api/realtime/ticket.ts @@ -0,0 +1,28 @@ +'use server'; + +import { getApiClient } from '@/lib/api'; +import { getCurrentEnvironment } from '@/lib/logic/EnvManager'; +import { deriveAdminSocketUrl } from '@/lib/realtime/adminSocketUrl'; + +export type AdminRealtimeTicket = { + token: string; + expiresIn: number; + socketUrl: string; + namespace: string; + path: string; +}; + +export async function issueAdminRealtimeTicket(): Promise { + const env = await getCurrentEnvironment(); + const { data } = await ( + await getApiClient() + ).post<{ token: string; expiresIn: number }>('/realtime/ticket'); + + return { + token: data.token, + expiresIn: data.expiresIn, + socketUrl: deriveAdminSocketUrl(env.baseUrl, env.socketUrl), + namespace: '/database/', + path: '/realtime', + }; +} diff --git a/src/lib/api/router/index.ts b/src/lib/api/router/index.ts index 006227974..5077e9b75 100644 --- a/src/lib/api/router/index.ts +++ b/src/lib/api/router/index.ts @@ -1,6 +1,11 @@ 'use server'; import { getApiClient } from '@/lib/api'; -import { RouterSettings } from '@/lib/models/Router'; +import { + EventRelay, + EventRelaysResponse, + EventRelayWriteRequest, + RouterSettings, +} from '@/lib/models/Router'; import { afterPatchServing } from '@/lib/api/modules/afterPatchServing'; import { PatchSettingsOptions } from '@/lib/api/modules/patch-settings-options'; @@ -94,3 +99,35 @@ export const patchAppRouteMiddlewares = async ( ); return res.data; }; + +export const getEventRelays = async (params?: { + skip?: number; + limit?: number; + search?: string; +}) => { + const res = await ( + await getApiClient() + ).get('/router/event-relays', { params }); + return res.data; +}; + +export const createEventRelay = async (data: EventRelayWriteRequest) => { + const res = await ( + await getApiClient() + ).post('/router/event-relays', data); + return res.data; +}; + +export const patchEventRelay = async ( + id: string, + data: Partial +) => { + const res = await ( + await getApiClient() + ).patch(`/router/event-relays/${id}`, data); + return res.data; +}; + +export const deleteEventRelay = async (id: string) => { + await (await getApiClient()).delete(`/router/event-relays/${id}`); +}; diff --git a/src/lib/database/schema-indexes.test.ts b/src/lib/database/schema-indexes.test.ts new file mode 100644 index 000000000..a5b5441f3 --- /dev/null +++ b/src/lib/database/schema-indexes.test.ts @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { normalizeSchemaIndexResponse } from './schema-indexes.ts'; + +const idIndex = { + fields: ['_id'], + types: [1], + options: { name: '_id_' }, +}; + +const customIndex = { + fields: ['owner'], + types: [1], + options: { name: 'owner_1' }, +}; + +describe('normalizeSchemaIndexResponse', () => { + it('accepts the raw array returned by the Database module', () => { + assert.deepEqual(normalizeSchemaIndexResponse([idIndex, customIndex]), [ + idIndex, + customIndex, + ]); + }); + + it('accepts the documented { indexes } envelope', () => { + assert.deepEqual( + normalizeSchemaIndexResponse({ indexes: [idIndex, customIndex] }), + [idIndex, customIndex] + ); + }); + + it('returns an empty array when indexes are missing', () => { + assert.deepEqual(normalizeSchemaIndexResponse(undefined), []); + assert.deepEqual(normalizeSchemaIndexResponse(null), []); + assert.deepEqual(normalizeSchemaIndexResponse({}), []); + assert.deepEqual(normalizeSchemaIndexResponse({ indexes: undefined }), []); + }); +}); diff --git a/src/lib/database/schema-indexes.ts b/src/lib/database/schema-indexes.ts new file mode 100644 index 000000000..798b9be0e --- /dev/null +++ b/src/lib/database/schema-indexes.ts @@ -0,0 +1,56 @@ +export type SchemaIndexPayload = { + fields: string[]; + options?: Record; + types?: string[] | string; +}; + +function isIndexRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function toIndexPayload(value: Record): SchemaIndexPayload { + return { + ...value, + fields: Array.isArray(value.fields) + ? value.fields.filter( + (field): field is string => typeof field === 'string' + ) + : [], + options: + value.options && + typeof value.options === 'object' && + !Array.isArray(value.options) + ? (value.options as Record) + : undefined, + types: + typeof value.types === 'string' || Array.isArray(value.types) + ? (value.types as string[] | string) + : undefined, + }; +} + +function collectIndexRecords(data: unknown): Record[] { + if (Array.isArray(data)) { + return data.filter(isIndexRecord); + } + + if ( + data && + typeof data === 'object' && + Array.isArray((data as { indexes?: unknown }).indexes) + ) { + return (data as { indexes: unknown[] }).indexes.filter(isIndexRecord); + } + + return []; +} + +/** + * GET /database/schemas/:id/indexes is documented as `{ indexes: [...] }`, + * but the Database module handler returns the index array itself. + */ +export function normalizeSchemaIndexResponse( + data: unknown +): SchemaIndexPayload[] { + return collectIndexRecords(data).map(toIndexPayload); +} diff --git a/src/lib/event-relays/path.ts b/src/lib/event-relays/path.ts new file mode 100644 index 000000000..e364ada57 --- /dev/null +++ b/src/lib/event-relays/path.ts @@ -0,0 +1,56 @@ +export const RESERVED_SOCKET_EVENTS = new Set([ + 'connect', + 'disconnect', + 'connect_error', + 'error', + 'join-room', + 'leave-room', + 'conduit_error', + 'subscribe', + 'unsubscribe', + 'ping', + 'pong', +]); + +const FORBIDDEN_PATH_SEGMENTS = new Set([ + '__proto__', + 'constructor', + 'prototype', +]); + +const MAX_PATH_SEGMENTS = 8; +export const MAX_TEMPLATE_BYTES = 16 * 1024; + +const PATH_SEGMENT = /^[A-Za-z_][A-Za-z0-9_]*$/; + +export function parseDotPath(path: string): string[] { + const trimmed = path.trim(); + if (!trimmed) { + throw new Error('Path is required'); + } + const segments = trimmed.split('.'); + if (segments.length > MAX_PATH_SEGMENTS) { + throw new Error(`Path exceeds ${MAX_PATH_SEGMENTS} segments`); + } + for (const segment of segments) { + if (FORBIDDEN_PATH_SEGMENTS.has(segment) || !PATH_SEGMENT.test(segment)) { + throw new Error('Path contains an invalid segment'); + } + } + return segments; +} + +export function lookupOwnPath(source: unknown, path: string): unknown { + const segments = parseDotPath(path); + let current: unknown = source; + for (const segment of segments) { + if (current === null || typeof current !== 'object') { + return undefined; + } + if (!Object.prototype.hasOwnProperty.call(current, segment)) { + return undefined; + } + current = (current as Record)[segment]; + } + return current; +} diff --git a/src/lib/event-relays/preview.test.ts b/src/lib/event-relays/preview.test.ts new file mode 100644 index 000000000..50ecbd9df --- /dev/null +++ b/src/lib/event-relays/preview.test.ts @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { lookupOwnPath } from './path.ts'; +import { renderMessageTemplate } from './template.ts'; +import { previewEventRelay } from './preview.ts'; + +describe('event relay preview helpers', () => { + it('resolves the resource id and rendered payload', () => { + const result = previewEventRelay({ + resourceIdPath: '_id', + messageTemplate: { id: '{{payload._id}}', status: '{{payload.status}}' }, + samplePayload: { _id: 'order-1', status: 'paid' }, + }); + assert.equal(result.error, undefined); + assert.equal(result.resourceId, 'order-1'); + assert.deepEqual(result.payload, { id: 'order-1', status: 'paid' }); + }); + + it('fails closed on missing fields and prototype paths', () => { + assert.equal(lookupOwnPath({ a: 1 }, 'b'), undefined); + assert.throws(() => + renderMessageTemplate({ x: '{{payload.missing}}' }, {}) + ); + const preview = previewEventRelay({ + resourceIdPath: '__proto__', + messageTemplate: { id: '{{payload._id}}' }, + samplePayload: { _id: '1' }, + }); + assert.equal(typeof preview.error, 'string'); + }); +}); diff --git a/src/lib/event-relays/preview.ts b/src/lib/event-relays/preview.ts new file mode 100644 index 000000000..c085cbced --- /dev/null +++ b/src/lib/event-relays/preview.ts @@ -0,0 +1,33 @@ +import { lookupOwnPath } from './path.ts'; +import { renderMessageTemplate } from './template.ts'; + +export type RelayPreview = { + resourceId?: string; + payload?: unknown; + error?: string; +}; + +export function previewEventRelay(options: { + resourceIdPath: string; + messageTemplate: unknown; + samplePayload: unknown; +}): RelayPreview { + try { + const resourceId = lookupOwnPath( + options.samplePayload, + options.resourceIdPath + ); + if (resourceId === undefined) { + return { + error: `Resource ID path '${options.resourceIdPath}' was not found`, + }; + } + const payload = renderMessageTemplate( + options.messageTemplate, + options.samplePayload + ); + return { resourceId: String(resourceId), payload }; + } catch (err) { + return { error: err instanceof Error ? err.message : String(err) }; + } +} diff --git a/src/lib/event-relays/template.ts b/src/lib/event-relays/template.ts new file mode 100644 index 000000000..06b0808c7 --- /dev/null +++ b/src/lib/event-relays/template.ts @@ -0,0 +1,59 @@ +import { lookupOwnPath, MAX_TEMPLATE_BYTES } from './path.ts'; + +const PLACEHOLDER = /\{\{\s*payload\.([A-Za-z_][A-Za-z0-9_.]*)\s*\}\}/g; +const EXACT_PLACEHOLDER = /^\{\{\s*payload\.([A-Za-z_][A-Za-z0-9_.]*)\s*\}\}$/; +const MAX_DEPTH = 10; + +export function renderMessageTemplate( + template: unknown, + payload: unknown +): unknown { + const serialized = JSON.stringify(template); + if (!serialized || serialized.length > MAX_TEMPLATE_BYTES) { + throw new Error('Message template is invalid or too large'); + } + return renderValue(template, payload, 0); +} + +function renderValue(value: unknown, payload: unknown, depth: number): unknown { + if (depth > MAX_DEPTH) { + throw new Error('Message template is nested too deeply'); + } + if (typeof value === 'string') { + return interpolateString(value, payload); + } + if (Array.isArray(value)) { + return value.map(item => renderValue(item, payload, depth + 1)); + } + if (value !== null && typeof value === 'object') { + const record = value as Record; + const output: Record = {}; + for (const key of Object.keys(record)) { + output[key] = renderValue(record[key], payload, depth + 1); + } + return output; + } + return value; +} + +function interpolateString(value: string, payload: unknown): unknown { + const exact = value.trim().match(EXACT_PLACEHOLDER); + if (exact) { + const resolved = lookupOwnPath(payload, exact[1]); + if (resolved === undefined) { + throw new Error(`Placeholder payload.${exact[1]} was not found`); + } + return resolved; + } + + return value.replace(PLACEHOLDER, (_match, path: string) => { + const resolved = lookupOwnPath(payload, path); + if (resolved === undefined) { + throw new Error(`Placeholder payload.${path} was not found`); + } + if (resolved === null || typeof resolved !== 'object') { + return String(resolved); + } + return JSON.stringify(resolved); + }); +} diff --git a/src/lib/hooks/use-database-live-updates.ts b/src/lib/hooks/use-database-live-updates.ts new file mode 100644 index 000000000..a1fd0489d --- /dev/null +++ b/src/lib/hooks/use-database-live-updates.ts @@ -0,0 +1,145 @@ +'use client'; + +import * as React from 'react'; +import { io, type Socket } from 'socket.io-client'; +import { issueAdminRealtimeTicket } from '@/lib/api/realtime/ticket'; +import { + parseDatabaseChangeEvent, + rememberResumeToken, + shouldCountChange, +} from '@/lib/realtime/change-events'; + +export type LiveConnectionState = + | 'idle' + | 'connecting' + | 'live' + | 'reconnecting' + | 'error'; + +const RETRY_BASE_MS = 1_000; +const RETRY_MAX_MS = 15_000; + +export function useDatabaseLiveUpdates(options: { + schemaName: string; + enabled: boolean; +}) { + const { schemaName, enabled } = options; + const [connectionState, setConnectionState] = + React.useState('idle'); + const [pendingUpdates, setPendingUpdates] = React.useState(0); + const [errorMessage, setErrorMessage] = React.useState(null); + + React.useEffect(() => { + if (!enabled || schemaName.trim() === '') { + setConnectionState('idle'); + setPendingUpdates(0); + setErrorMessage(null); + return; + } + + let cancelled = false; + let socket: Socket | undefined; + let retryTimer: ReturnType | undefined; + let attempt = 0; + let everConnected = false; + + const disconnectSocket = () => { + if (!socket) return; + socket.removeAllListeners(); + socket.disconnect(); + socket = undefined; + }; + + const connect = async () => { + if (cancelled) return; + setConnectionState(everConnected ? 'reconnecting' : 'connecting'); + try { + const ticket = await issueAdminRealtimeTicket(); + if (cancelled) return; + disconnectSocket(); + socket = io(`${ticket.socketUrl}${ticket.namespace}`, { + path: ticket.path, + auth: { token: ticket.token }, + transports: ['websocket', 'polling'], + reconnection: false, + }); + + socket.on('connect', () => { + everConnected = true; + attempt = 0; + setErrorMessage(null); + setConnectionState('live'); + socket?.emit('subscribe', { schema: schemaName }); + }); + + const seenResumeTokens = new Set(); + socket.on('change', (data: unknown) => { + const event = parseDatabaseChangeEvent(data); + if (!event || !shouldCountChange(event, schemaName)) return; + if (!rememberResumeToken(seenResumeTokens, event.resumeToken)) return; + setPendingUpdates(count => count + 1); + }); + + socket.on('conduit_error', (data: unknown) => { + const message = + data && + typeof data === 'object' && + 'message' in data && + typeof data.message === 'string' + ? data.message + : 'Live updates failed'; + setErrorMessage(message); + setConnectionState('error'); + }); + + socket.on('connect_error', err => { + setErrorMessage( + err instanceof Error ? err.message : 'Live updates failed' + ); + scheduleReconnect(); + }); + + socket.on('disconnect', reason => { + if (cancelled || reason === 'io client disconnect') return; + scheduleReconnect(); + }); + } catch (err) { + setErrorMessage( + err instanceof Error ? err.message : 'Live updates failed' + ); + scheduleReconnect(); + } + }; + + const scheduleReconnect = () => { + if (cancelled || retryTimer) return; + disconnectSocket(); + setConnectionState(everConnected ? 'reconnecting' : 'error'); + const delay = Math.min(RETRY_MAX_MS, RETRY_BASE_MS * 2 ** attempt); + attempt += 1; + retryTimer = setTimeout(() => { + retryTimer = undefined; + void connect(); + }, delay); + }; + + void connect(); + + return () => { + cancelled = true; + if (retryTimer) clearTimeout(retryTimer); + disconnectSocket(); + }; + }, [enabled, schemaName]); + + const consumePendingUpdates = React.useCallback(() => { + setPendingUpdates(0); + }, []); + + return { + connectionState, + pendingUpdates, + errorMessage, + consumePendingUpdates, + }; +} diff --git a/src/lib/logic/EnvManager.ts b/src/lib/logic/EnvManager.ts index f74d69060..005d7c584 100644 --- a/src/lib/logic/EnvManager.ts +++ b/src/lib/logic/EnvManager.ts @@ -7,6 +7,7 @@ export interface Environment { name: string; baseUrl: string; masterKey: string; + socketUrl?: string; lokiUrl?: string; promUrl?: string; namespace?: string; @@ -43,13 +44,14 @@ function getConfigFingerprint(): string { mode, defaultEnvironment: process.env.DEFAULT_ENVIRONMENT, apiBaseUrl: process.env.API_BASE_URL, + adminSocketUrl: process.env.ADMIN_SOCKET_URL, masterKeyHash: hashSecret(process.env.MASTER_KEY), }); } const environments: Record< string, - { baseUrl?: string; masterKeyHash: string } + { baseUrl?: string; socketUrl?: string; masterKeyHash: string } > = {}; Object.keys(envVars) .filter(key => key.endsWith('_API_BASE_URL')) @@ -58,6 +60,7 @@ function getConfigFingerprint(): string { const prefix = key.replace('_API_BASE_URL', '').toUpperCase(); environments[prefix] = { baseUrl: envVars[`${prefix}_API_BASE_URL`], + socketUrl: envVars[`${prefix}_ADMIN_SOCKET_URL`], masterKeyHash: hashSecret(envVars[`${prefix}_MASTER_KEY`]), }; }); @@ -110,6 +113,7 @@ async function getSingleEnvironmentConfig( name: envName, baseUrl: process.env.API_BASE_URL!, masterKey: process.env.MASTER_KEY!, + socketUrl: process.env.ADMIN_SOCKET_URL, lokiUrl: process.env.LOKI_URL, promUrl: process.env.PROMETHEUS_URL, namespace: process.env.NAMESPACE, @@ -158,6 +162,7 @@ async function getMultiEnvironmentConfig( name: envName, baseUrl: envVars[`${prefix}_API_BASE_URL`]!, masterKey: envVars[`${prefix}_MASTER_KEY`]!, + socketUrl: envVars[`${prefix}_ADMIN_SOCKET_URL`], lokiUrl: envVars[`${prefix}_LOKI_URL`], promUrl: envVars[`${prefix}_PROMETHEUS_URL`], namespace: envVars[`${prefix}_NAMESPACE`], diff --git a/src/lib/models/Router.ts b/src/lib/models/Router.ts index 9360c8aa3..1aa55cfbb 100644 --- a/src/lib/models/Router.ts +++ b/src/lib/models/Router.ts @@ -86,3 +86,35 @@ export type UpdateSecurityClientRequest = { alias?: string; notes?: string; }; + +export type EventRelay = { + _id: string; + name: string; + notes?: string; + active: boolean; + busEvent: string; + socketEvent: string; + resourceType: string; + resourceIdPath: string; + permission: string; + messageTemplate: unknown; + createdAt: string; + updatedAt: string; +}; + +export type EventRelaysResponse = { + relays: EventRelay[]; + count: number; +}; + +export type EventRelayWriteRequest = { + name: string; + notes?: string; + active?: boolean; + busEvent: string; + socketEvent: string; + resourceType: string; + resourceIdPath: string; + permission: string; + messageTemplate: unknown; +}; diff --git a/src/lib/models/database/schemas.ts b/src/lib/models/database/schemas.ts index 34601f427..7c80f4b8d 100644 --- a/src/lib/models/database/schemas.ts +++ b/src/lib/models/database/schemas.ts @@ -33,6 +33,9 @@ export type SchemaOptions = { authorization?: { enabled: boolean; }; + realtime?: { + enabled: boolean; + }; /** MongoDB only: overrides module default read preference for this schema's reads */ readPreference?: string; }; @@ -65,6 +68,9 @@ export type PatchSchemaRequest = Partial> & { cms?: Record; authorization?: Record; permissions?: Record; + realtime?: { + enabled?: boolean; + }; /** MongoDB only; empty string clears schema-level override */ readPreference?: string; }; diff --git a/src/lib/models/database/settings.ts b/src/lib/models/database/settings.ts index a81e88187..7c33d5b51 100644 --- a/src/lib/models/database/settings.ts +++ b/src/lib/models/database/settings.ts @@ -12,8 +12,27 @@ export type ReadConcern = | 'linearizable' | 'snapshot'; +export type DatabaseRealtimeStatusCode = + | 'unsupported' + | 'disabled' + | 'idle' + | 'starting' + | 'live' + | 'degraded'; + +export type DatabaseRealtimeStatus = { + status: DatabaseRealtimeStatusCode; + engine: string; + activeSchemaCount: number; + lastEventAt?: string; + message?: string; +}; + export type DatabaseConfig = { readPreference: ReadPreference; writeConcern: WriteConcern; readConcern: ReadConcern; + realtime?: { + enabled: boolean; + }; }; diff --git a/src/lib/realtime/adminSocketUrl.test.ts b/src/lib/realtime/adminSocketUrl.test.ts new file mode 100644 index 000000000..e74d44f47 --- /dev/null +++ b/src/lib/realtime/adminSocketUrl.test.ts @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + deriveAdminSocketUrl, + deriveClientSocketUrl, +} from './adminSocketUrl.ts'; + +describe('deriveAdminSocketUrl', () => { + it('uses an explicit socket URL when provided', () => { + assert.equal( + deriveAdminSocketUrl( + 'http://localhost:3030', + 'http://sockets.example:4000/' + ), + 'http://sockets.example:4000' + ); + }); + + it('maps the default admin API port 3030 to 3031', () => { + assert.equal( + deriveAdminSocketUrl('http://localhost:3030'), + 'http://localhost:3031' + ); + }); + + it('keeps proxied origins that are not on port 3030', () => { + assert.equal( + deriveAdminSocketUrl('https://admin.example.com'), + 'https://admin.example.com' + ); + assert.equal( + deriveAdminSocketUrl('http://localhost:8080/v1'), + 'http://localhost:8080' + ); + }); +}); + +describe('deriveClientSocketUrl', () => { + it('maps the default client API port 3000 to 3001', () => { + assert.equal( + deriveClientSocketUrl('http://localhost:3000'), + 'http://localhost:3001' + ); + }); + + it('maps standalone router hostUrl on 3030 to client socket 3001', () => { + assert.equal( + deriveClientSocketUrl('http://localhost:3030'), + 'http://localhost:3001' + ); + }); + + it('keeps proxied client origins that are not on port 3000', () => { + assert.equal( + deriveClientSocketUrl('https://api.example.com'), + 'https://api.example.com' + ); + }); +}); diff --git a/src/lib/realtime/adminSocketUrl.ts b/src/lib/realtime/adminSocketUrl.ts new file mode 100644 index 000000000..a6a509eb1 --- /dev/null +++ b/src/lib/realtime/adminSocketUrl.ts @@ -0,0 +1,42 @@ +export function deriveAdminSocketUrl( + apiBaseUrl: string, + explicit?: string | null +): string { + return deriveSocketUrl(apiBaseUrl, { '3030': '3031' }, explicit); +} + +/** Maps Client REST `:3000` (and standalone Router hostUrl on `:3030`) to Socket.IO `:3001`. */ +export function deriveClientSocketUrl( + apiBaseUrl: string, + explicit?: string | null +): string { + return deriveSocketUrl( + apiBaseUrl, + { '3000': '3001', '3030': '3001' }, + explicit + ); +} + +export function deriveSocketUrl( + apiBaseUrl: string, + restToSocketPort: Record, + explicit?: string | null +): string { + if (explicit && explicit.trim() !== '') { + return trimTrailingSlash(explicit.trim()); + } + + const url = new URL(apiBaseUrl); + const mapped = restToSocketPort[url.port]; + if (mapped) { + url.port = mapped; + } + url.pathname = ''; + url.search = ''; + url.hash = ''; + return trimTrailingSlash(url.toString()); +} + +function trimTrailingSlash(value: string): string { + return value.replace(/\/+$/, ''); +} diff --git a/src/lib/realtime/change-events.test.ts b/src/lib/realtime/change-events.test.ts new file mode 100644 index 000000000..004af6436 --- /dev/null +++ b/src/lib/realtime/change-events.test.ts @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + parseDatabaseChangeEvent, + rememberResumeToken, + shouldCountChange, +} from './change-events.ts'; + +const validEvent = { + version: 1, + operation: 'insert', + schema: 'Order', + documentId: '64b64c4c4c4c4c4c4c4c4c4c', + occurredAt: '2026-01-02T00:00:00.000Z', + resumeToken: '{"_data":"token"}', +}; + +describe('parseDatabaseChangeEvent', () => { + it('accepts a metadata-only payload', () => { + assert.deepEqual(parseDatabaseChangeEvent(validEvent), validEvent); + assert.deepEqual( + parseDatabaseChangeEvent(JSON.stringify(validEvent)), + validEvent + ); + }); + + it('rejects document fields, wrong versions, and malformed payloads', () => { + assert.equal(parseDatabaseChangeEvent(null), null); + assert.equal(parseDatabaseChangeEvent('{'), null); + assert.equal(parseDatabaseChangeEvent({ ...validEvent, version: 2 }), null); + assert.equal( + parseDatabaseChangeEvent({ ...validEvent, operation: 'drop' }), + null + ); + assert.equal(parseDatabaseChangeEvent({ ...validEvent, schema: '' }), null); + }); +}); + +describe('shouldCountChange', () => { + it('counts only events for the subscribed schema', () => { + const event = parseDatabaseChangeEvent(validEvent)!; + assert.equal(shouldCountChange(event, 'Order'), true); + assert.equal(shouldCountChange(event, 'User'), false); + }); +}); + +describe('rememberResumeToken', () => { + it('counts a token once and evicts the oldest past the limit', () => { + const seen = new Set(); + assert.equal(rememberResumeToken(seen, 'a', 2), true); + assert.equal(rememberResumeToken(seen, 'a', 2), false); + assert.equal(rememberResumeToken(seen, 'b', 2), true); + assert.equal(rememberResumeToken(seen, 'c', 2), true); + assert.equal(seen.has('a'), false); + assert.equal(rememberResumeToken(seen, 'a', 2), true); + }); +}); diff --git a/src/lib/realtime/change-events.ts b/src/lib/realtime/change-events.ts new file mode 100644 index 000000000..3f1f8b3a7 --- /dev/null +++ b/src/lib/realtime/change-events.ts @@ -0,0 +1,89 @@ +const DATABASE_CHANGE_EVENT_VERSION = 1; + +const DATABASE_CHANGE_OPERATIONS = [ + 'insert', + 'update', + 'replace', + 'delete', +] as const; + +export type DatabaseChangeOperation = + (typeof DATABASE_CHANGE_OPERATIONS)[number]; + +export type DatabaseChangeEvent = { + version: number; + operation: DatabaseChangeOperation; + schema: string; + documentId: string; + occurredAt: string; + resumeToken: string; +}; + +export function parseDatabaseChangeEvent( + data: unknown +): DatabaseChangeEvent | null { + const raw = typeof data === 'string' ? safeParseJson(data) : data; + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return null; + } + + const event = raw as Record; + if (event.version !== DATABASE_CHANGE_EVENT_VERSION) return null; + if ( + typeof event.operation !== 'string' || + !DATABASE_CHANGE_OPERATIONS.includes( + event.operation as DatabaseChangeOperation + ) + ) { + return null; + } + if (typeof event.schema !== 'string' || event.schema.trim() === '') { + return null; + } + if (typeof event.documentId !== 'string' || event.documentId.trim() === '') { + return null; + } + if (typeof event.occurredAt !== 'string') return null; + if (typeof event.resumeToken !== 'string') return null; + + return { + version: DATABASE_CHANGE_EVENT_VERSION, + operation: event.operation as DatabaseChangeOperation, + schema: event.schema, + documentId: event.documentId, + occurredAt: event.occurredAt, + resumeToken: event.resumeToken, + }; +} + +export function shouldCountChange( + event: DatabaseChangeEvent, + schemaName: string +): boolean { + return event.schema === schemaName; +} + +const DEFAULT_SEEN_TOKEN_LIMIT = 500; + +/** Returns true the first time `token` is seen. Oldest entries are dropped past `limit`. */ +export function rememberResumeToken( + seen: Set, + token: string, + limit: number = DEFAULT_SEEN_TOKEN_LIMIT +): boolean { + if (seen.has(token)) return false; + seen.add(token); + if (seen.size > limit) { + const oldest = seen.values().next().value; + if (oldest !== undefined) seen.delete(oldest); + } + return true; +} + +function safeParseJson(value: string): unknown { + try { + return JSON.parse(value); + } catch { + return null; + } +} diff --git a/src/lib/realtime/connection-snippets.test.ts b/src/lib/realtime/connection-snippets.test.ts new file mode 100644 index 000000000..e92f9e251 --- /dev/null +++ b/src/lib/realtime/connection-snippets.test.ts @@ -0,0 +1,70 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + buildAdminJavascriptSnippet, + buildClientJavascriptSnippet, + buildPostmanConnection, + socketEndpoint, + subscribePayload, +} from './connection-snippets.ts'; + +describe('subscribePayload', () => { + it('scopes client authorized schemas to a document id', () => { + assert.deepEqual(subscribePayload('Order', true), { + schema: 'Order', + documentId: '', + }); + assert.deepEqual(subscribePayload('Order', false), { schema: 'Order' }); + }); +}); + +describe('buildClientJavascriptSnippet', () => { + it('targets the schema namespace and subscribe payload', () => { + const snippet = buildClientJavascriptSnippet({ + socketUrl: 'http://localhost:3001', + schemaName: 'LiveUpdateProbe', + documentScoped: false, + }); + assert.match(snippet, /http:\/\/localhost:3001\/database\//); + assert.match(snippet, /path: "\/realtime"/); + assert.match(snippet, /"schema":"LiveUpdateProbe"/); + }); +}); + +describe('buildAdminJavascriptSnippet', () => { + it('uses the admin socket origin', () => { + const snippet = buildAdminJavascriptSnippet({ + socketUrl: 'http://localhost:3031', + schemaName: 'LiveUpdateProbe', + }); + assert.match(snippet, /http:\/\/localhost:3031\/database\//); + assert.match(snippet, /adminToken/); + }); +}); + +describe('buildPostmanConnection', () => { + it('matches the Socket.IO handshake used in Data Explorer', () => { + const connection = buildPostmanConnection({ + adminSocketUrl: 'http://localhost:3031', + schemaName: 'LiveUpdateProbe', + }); + assert.equal(connection.serverUrl, 'http://localhost:3031/database/'); + assert.equal(connection.path, '/realtime'); + assert.equal(connection.clientVersion, 'v4'); + assert.equal(connection.subscribeEvent, 'subscribe'); + assert.match(connection.subscribeBody, /LiveUpdateProbe/); + assert.deepEqual( + connection.headers.map(header => header.key), + ['masterkey', 'Authorization'] + ); + }); +}); + +describe('socketEndpoint', () => { + it('always ends with the database namespace', () => { + assert.equal( + socketEndpoint('http://localhost:3031/'), + 'http://localhost:3031/database/' + ); + }); +}); diff --git a/src/lib/realtime/connection-snippets.ts b/src/lib/realtime/connection-snippets.ts new file mode 100644 index 000000000..12aeda524 --- /dev/null +++ b/src/lib/realtime/connection-snippets.ts @@ -0,0 +1,116 @@ +const DATABASE_SOCKET_NAMESPACE = '/database/'; +export const DATABASE_SOCKET_PATH = '/realtime'; +export const DATABASE_SOCKET_LISTEN_EVENTS = [ + 'change', + 'connected', + 'conduit_error', +] as const; + +type SubscribePayload = { + schema: string; + documentId?: string; +}; + +export function socketEndpoint(socketUrl: string): string { + return `${socketUrl.replace(/\/+$/, '')}${DATABASE_SOCKET_NAMESPACE}`; +} + +export function subscribePayload( + schemaName: string, + documentScoped: boolean +): SubscribePayload { + if (documentScoped) { + return { schema: schemaName, documentId: '' }; + } + return { schema: schemaName }; +} + +export function buildClientJavascriptSnippet(options: { + socketUrl: string; + schemaName: string; + documentScoped: boolean; +}): string { + const url = socketEndpoint(options.socketUrl); + const payload = subscribePayload(options.schemaName, options.documentScoped); + return `import { io } from 'socket.io-client'; + +const socket = io(${JSON.stringify(url)}, { + path: ${JSON.stringify(DATABASE_SOCKET_PATH)}, + auth: { token: accessToken }, + transports: ['websocket', 'polling'], +}); + +socket.on('connect', () => { + socket.emit('subscribe', ${JSON.stringify(payload)}); +}); + +socket.on('change', (event) => { + // { version, operation, schema, documentId, occurredAt, resumeToken } + console.log(event); +}); + +socket.on('conduit_error', (error) => { + console.error(error); +}); +`; +} + +export function buildAdminJavascriptSnippet(options: { + socketUrl: string; + schemaName: string; +}): string { + const url = socketEndpoint(options.socketUrl); + const payload = subscribePayload(options.schemaName, false); + return `import { io } from 'socket.io-client'; + +const socket = io(${JSON.stringify(url)}, { + path: ${JSON.stringify(DATABASE_SOCKET_PATH)}, + auth: { token: adminToken }, + transports: ['websocket', 'polling'], +}); + +socket.on('connect', () => { + socket.emit('subscribe', ${JSON.stringify(payload)}); +}); + +socket.on('change', (event) => { + console.log(event); +}); + +socket.on('conduit_error', (error) => { + console.error(error); +}); +`; +} + +type PostmanConnection = { + serverUrl: string; + path: string; + clientVersion: 'v4'; + headers: { key: string; value: string }[]; + listenFor: string[]; + subscribeEvent: 'subscribe'; + subscribeBody: string; +}; + +export function buildPostmanConnection(options: { + adminSocketUrl: string; + schemaName: string; +}): PostmanConnection { + return { + serverUrl: socketEndpoint(options.adminSocketUrl), + path: DATABASE_SOCKET_PATH, + clientVersion: 'v4', + headers: [ + { key: 'masterkey', value: '{{masterKey}}' }, + { key: 'Authorization', value: 'Bearer {{adminToken}}' }, + ], + listenFor: [...DATABASE_SOCKET_LISTEN_EVENTS], + subscribeEvent: 'subscribe', + subscribeBody: JSON.stringify( + subscribePayload(options.schemaName, false), + null, + 2 + ), + }; +}