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 */}
-
+
+ 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 && (
+
+
+
+ {pendingUpdates}{' '}
+ {pendingUpdates === 1
+ ? 'update available'
+ : 'updates available'}
+
+
+
+ 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.
+
+
+
+
+
+
+ Enable live updates
+
+
+ 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.
+
+ )}
+
+
+
+
+
+
+ New → Socket.IO Request. Postman has no Handshake Auth field —
+ put the token on Headers instead.
+
+
+
+
+
+ Settings: handshake path{' '}
+ {postman.path}, client
+ version {postman.clientVersion}.
+
+
+ Headers:
+
+ {postman.headers.map(header => (
+
+ ))}
+
+
+
+ Listen for{' '}
+ {postman.listenFor.map(event => (
+
+ {event}
+
+ ))}
+ , click Connect, then emit:
+
+
+
+
+ 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 (
+
+ );
+}
+
+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 (
+
+
+ void handleCopy()}
+ aria-label={copied ? `${label} copied` : `Copy ${label}`}
+ >
+ {copied ? (
+
+ ) : (
+
+ )}
+
+
+
+ {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
+ ),
+ };
+}