RTDB (Real-Time Database)

The Cloudillo RTDB provides a Firebase-like real-time database with TypeScript support.

RTDB vs CRDT

RTDB is best for structured data with queries (todos, settings, lists). For collaborative document editing where multiple users edit simultaneously, see CRDT. Compare both in Data Storage & Access.

Installation

pnpm add @cloudillo/rtdb

Quick Start

import { getAppBus } from '@cloudillo/core'
import { RtdbClient } from '@cloudillo/rtdb'
import { getRtdbUrl } from '@cloudillo/core'

// Initialize Cloudillo
const bus = getAppBus()
await bus.init('my-app')

// Create RTDB client
const rtdb = new RtdbClient({
  dbId: 'my-database-id',
  auth: {
    getToken: () => bus.accessToken
  },
  serverUrl: getRtdbUrl(bus.idTag!, 'my-database-id', bus.accessToken!)
})

// Connect to the database
await rtdb.connect()

// Get collection reference
const todos = rtdb.collection('todos')

// Create document
const batch = rtdb.batch()
batch.create(todos, {
  title: 'Learn Cloudillo RTDB',
  completed: false,
  createdAt: Date.now()
})
await batch.commit()

// Subscribe to changes
todos.onSnapshot((snapshot) => {
  console.log('Todos:', snapshot.docs.map(doc => doc.data()))
})

RtdbClient Constructor

interface RtdbClientOptions {
  dbId: string                    // Database/file ID
  auth: {
    getToken: () => string | undefined | Promise<string | undefined>
  }
  serverUrl: string               // WebSocket URL
  options?: {
    enableCache?: boolean         // Default: false
    reconnect?: boolean           // Default: true
    reconnectDelay?: number       // Default: 1000ms
    maxReconnectDelay?: number    // Default: 30000ms
    debug?: boolean               // Default: false
  }
}

Example with all options:

import { RtdbClient } from '@cloudillo/rtdb'
import { getRtdbUrl } from '@cloudillo/core'

const rtdb = new RtdbClient({
  dbId: 'my-database-id',
  auth: {
    // Token provider function - called when connection needs auth
    getToken: async () => {
      // Can be sync or async
      return bus.accessToken
    }
  },
  serverUrl: getRtdbUrl(bus.idTag!, 'my-database-id', bus.accessToken!),
  options: {
    enableCache: true,        // Enable local caching
    reconnect: true,          // Auto-reconnect on disconnect
    reconnectDelay: 1000,     // Initial reconnect delay
    maxReconnectDelay: 30000, // Max reconnect delay (exponential backoff)
    debug: false              // Enable debug logging
  }
})

Connection Management

// Connect to database
await rtdb.connect()

// Disconnect
await rtdb.disconnect()

// Check connection status
if (rtdb.isConnected()) {
  console.log('Connected')
}

// Diagnostics
console.log('Pending requests:', rtdb.getPendingRequests())
console.log('Active subscriptions:', rtdb.getActiveSubscriptions())

Core Concepts

Collections

Collections are groups of documents, similar to tables in SQL.

const users = rtdb.collection('users')
const posts = rtdb.collection('posts')
const comments = rtdb.collection('comments')

// Typed collections
interface Todo {
  title: string
  completed: boolean
  createdAt: number
}
const todos = rtdb.collection<Todo>('todos')

Documents

Documents are individual records accessed by path.

// Reference a document by path
const userDoc = rtdb.ref('users/alice')

// Get document data
const snapshot = await userDoc.get()
if (snapshot.exists) {
  console.log(snapshot.data())
}

CRUD Operations

Create

Use batch operations to create documents:

const todos = rtdb.collection('todos')
const batch = rtdb.batch()

// Create with auto-generated ID
batch.create(todos, {
  title: 'New task',
  completed: false
})

// Create with ref for tracking
batch.create(todos, {
  title: 'Another task',
  completed: false
}, { ref: 'task-ref' })

// Commit returns results with IDs
const results = await batch.commit()
console.log('Created IDs:', results.map(r => r.id))

Read

// Get single document
const doc = rtdb.ref('todos/task_123')
const snapshot = await doc.get()
if (snapshot.exists) {
  console.log(snapshot.data())
}

// Query collection
const todos = rtdb.collection('todos')
const results = await todos.get()
results.docs.forEach(doc => {
  console.log(doc.id, doc.data())
})

Update

const batch = rtdb.batch()
const todoRef = rtdb.ref('todos/task_123')

// Partial update
batch.update(todoRef, {
  completed: true
})

await batch.commit()

Delete

const batch = rtdb.batch()
const todoRef = rtdb.ref('todos/task_123')

batch.delete(todoRef)

await batch.commit()

Queries

Query Options

interface QueryOptions {
  filter?: {
    equals?: Record<string, any>
  }
  sort?: Array<{ field: string; ascending: boolean }>
  limit?: number
  offset?: number
}

Filtering

const todos = rtdb.collection('todos')

// Filter by field value
const incomplete = await todos.query({
  filter: {
    equals: { completed: false }
  }
})

Sorting

// Sort ascending
const sorted = await todos.query({
  sort: [{ field: 'createdAt', ascending: true }]
})

// Sort descending
const newest = await todos.query({
  sort: [{ field: 'createdAt', ascending: false }]
})

// Multiple sorts
const prioritized = await todos.query({
  sort: [
    { field: 'priority', ascending: false },
    { field: 'createdAt', ascending: true }
  ]
})

Pagination

// Limit results
const first10 = await todos.query({
  limit: 10
})

// Pagination with offset
const page2 = await todos.query({
  limit: 20,
  offset: 20
})

Combined Queries

const results = await todos.query({
  filter: {
    equals: { completed: false }
  },
  sort: [{ field: 'createdAt', ascending: false }],
  limit: 10
})

Real-Time Subscriptions

Collection Subscriptions

const todos = rtdb.collection('todos')

// Subscribe to all documents
const unsubscribe = todos.onSnapshot((snapshot) => {
  console.log('Total todos:', snapshot.size)

  snapshot.docs.forEach(doc => {
    console.log(doc.id, doc.data())
  })

  // Track changes
  const changes = snapshot.docChanges()
  changes.forEach(change => {
    switch (change.type) {
      case 'added':
        console.log('New:', change.doc.id)
        break
      case 'modified':
        console.log('Updated:', change.doc.id)
        break
      case 'removed':
        console.log('Deleted:', change.doc.id)
        break
    }
  })
})

// Unsubscribe later
unsubscribe()

Document Subscriptions

const todoRef = rtdb.ref('todos/task_123')

const unsubscribe = todoRef.onSnapshot((snapshot) => {
  if (snapshot.exists) {
    console.log('Todo updated:', snapshot.data())
  } else {
    console.log('Todo deleted')
  }
})

Filtered Subscriptions

const unsubscribe = todos.subscribe({
  filter: {
    equals: { completed: false }
  }
}, (snapshot) => {
  console.log('Incomplete todos:', snapshot.size)
})

Batch Operations

Perform multiple operations atomically:

const todos = rtdb.collection('todos')
const batch = rtdb.batch()

// Create multiple
batch.create(todos, { title: 'Task 1', completed: false })
batch.create(todos, { title: 'Task 2', completed: false })

// Update existing
batch.update(rtdb.ref('todos/task_123'), { completed: true })

// Delete
batch.delete(rtdb.ref('todos/task_456'))

// Commit all operations atomically
const results = await batch.commit()
console.log('Batch results:', results)

BatchResult:

interface BatchResult {
  ref?: string   // Reference ID if provided
  id?: string    // Generated document ID
}

Indexes

Create indexes for efficient queries:

// Create index on a field
await rtdb.createIndex('todos', 'createdAt')
await rtdb.createIndex('todos', 'completed')

TypeScript Support

Full type safety with generics:

interface Todo {
  title: string
  completed: boolean
  createdAt: number
  tags?: string[]
}

const todos = rtdb.collection<Todo>('todos')

// TypeScript knows the shape
const snapshot = await todos.get()
snapshot.docs.forEach(doc => {
  const data = doc.data()
  console.log(data.title)     // string
  console.log(data.completed) // boolean
  // @ts-error: Property 'invalid' does not exist
  // console.log(data.invalid)
})

React Integration

import { useEffect, useState } from 'react'
import { useAuth } from '@cloudillo/react'
import { RtdbClient } from '@cloudillo/rtdb'
import { getRtdbUrl } from '@cloudillo/core'

interface Todo {
  title: string
  completed: boolean
}

function TodoList({ dbId }: { dbId: string }) {
  const [auth] = useAuth()
  const [todos, setTodos] = useState<Todo[]>([])
  const [rtdb, setRtdb] = useState<RtdbClient | null>(null)

  // Initialize RTDB client
  useEffect(() => {
    if (!auth?.token || !auth?.idTag) return

    const client = new RtdbClient({
      dbId,
      auth: { getToken: () => auth.token },
      serverUrl: getRtdbUrl(auth.idTag, dbId, auth.token!)
    })

    setRtdb(client)

    return () => {
      client.disconnect()
    }
  }, [auth?.token, auth?.idTag, dbId])

  // Subscribe to todos
  useEffect(() => {
    if (!rtdb) return

    const todos = rtdb.collection<Todo>('todos')
    const unsubscribe = todos.onSnapshot((snapshot) => {
      setTodos(snapshot.docs.map(doc => ({
        id: doc.id,
        ...doc.data()
      })))
    })

    return () => unsubscribe()
  }, [rtdb])

  return (
    <ul>
      {todos.map(todo => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  )
}

Error Handling

The RTDB client provides typed errors:

import {
  RtdbError,
  ConnectionError,
  AuthError,
  PermissionError,
  NotFoundError,
  ValidationError,
  TimeoutError
} from '@cloudillo/rtdb'

try {
  await rtdb.connect()
} catch (error) {
  if (error instanceof ConnectionError) {
    console.log('Connection failed:', error.message)
  } else if (error instanceof AuthError) {
    console.log('Authentication failed:', error.message)
  } else if (error instanceof PermissionError) {
    console.log('Permission denied:', error.message)
  } else if (error instanceof NotFoundError) {
    console.log('Not found:', error.message)
  } else if (error instanceof TimeoutError) {
    console.log('Request timed out:', error.message)
  }
}

Best Practices

1. Use Connection Management

// Initialize once, reuse
const rtdb = new RtdbClient({ ... })
await rtdb.connect()

// Use throughout your app
const todos = rtdb.collection('todos')

2. Clean Up Subscriptions

// Always unsubscribe to prevent memory leaks
useEffect(() => {
  const unsubscribe = todos.onSnapshot(callback)
  return () => unsubscribe()
}, [])

3. Use Batch for Multiple Operations

// Good: Atomic batch operation
const batch = rtdb.batch()
batch.create(todos, { title: 'Task 1' })
batch.create(todos, { title: 'Task 2' })
await batch.commit()

// Avoid: Multiple separate requests
// await create({ title: 'Task 1' })
// await create({ title: 'Task 2' })

4. Use Typed Collections

// Define your types
interface Todo {
  title: string
  completed: boolean
}

// Get type safety
const todos = rtdb.collection<Todo>('todos')

5. Handle Connection State

// Check connection before operations
if (!rtdb.isConnected()) {
  await rtdb.connect()
}

// Handle reconnection in UI
const [connected, setConnected] = useState(false)

See Also