diff --git a/package.json b/package.json index e5f90b63c..ba16c6837 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "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: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 +81,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/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)} +

+
+ + + + +
+ )} + />
{ - 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/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/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/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 + ), + }; +}