Skip to main content

Documentation Index

Fetch the complete documentation index at: https://zenbulabs.mintlify.app/llms.txt

Use this file to discover all available pages before exploring further.

Zenbu.js includes a JSON database that syncs across every process. Every process (main and renderer) holds an in-memory copy (also known as a replica) of the database. Reads are always local and synchronous. When a write happens in any process, it syncs to all other replicas automatically. This means both the main process and the renderer read and write through the same interface. The only difference is how you access the client: through DbService in a service, or through React hooks in the renderer.

Schema

Each plugin defines a schema using zod that describes the shape of its data.
src/main/schema.ts
import { createSchema, z } from "@zenbujs/core/db"

export default createSchema({
  todos: z
    .array(
      z.object({
        id: z.string(),
        title: z.string(),
        done: z.boolean(),
      })
    )
    .default([]),
  settings: z
    .object({
      theme: z.string(),
      fontSize: z.number(),
    })
    .default({ theme: "dark", fontSize: 14 }),
})
Fields without a default() will be undefined initially. Use .default() to set an initial value for a field when the database is first created.

Reading data

The database is a single JSON object called the root. Each plugin’s data lives under its name, so root.app holds everything defined by the app plugin. In the renderer, use the useDb hook to read from the database.
import { useDb } from "@zenbujs/core/react"

function TodoList() {
  const todos = useDb(root => root.app.todos)

  return (
    <ul>
      {todos.map(todo => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  )
}
useDb is a subscription. When the selected value changes, the component re-renders automatically. Unrelated changes don’t trigger re-renders. In a service, read through DbService.client:
const root = this.ctx.db.client.readRoot()
const todos = root.app.todos
readRoot() returns a synchronous snapshot since the entire root is held in memory. You can also subscribe to a specific field to react when it changes:
const unsubscribe = this.ctx.db.client.app.todos.subscribe(todos => {
  console.log("todos changed", todos)
})
The returned function unsubscribes. Wrap it in this.setup() so it cleans up on hot reload.

Writing data

In the renderer, use useDbClient to get a client that can write to the database:
import { useDbClient } from "@zenbujs/core/react"

function AddTodo() {
  const client = useDbClient()

  const add = () => {
    client.update(root => {
      root.app.todos.push({
        id: crypto.randomUUID(),
        title: "New todo",
        done: false,
      })
    })
  }

  return <button onClick={add}>Add</button>
}
In a service, write through DbService.client:
await this.ctx.db.client.update(root => {
  root.app.todos.push({
    id: crypto.randomUUID(),
    title: "New todo",
    createdAt: Date.now(),
  })
})
Inside update(), you mutate the root object directly, the same way you would with a regular JavaScript object. The database tracks these mutations and syncs them to other processes in the background.

Collections

Regular data fields are always held in memory across every process. Collections are for data that can grow large (like agent messages or logs) and should only be loaded into memory when needed. Define a collection in your schema with f.collection():
src/main/schema.ts
import { createSchema, f, z } from "@zenbujs/core/db"

export default createSchema({
  messages: f.collection(
    z.object({
      text: z.string(),
      author: z.string(),
    }),
    { debugName: "messages" }
  ),
})
Collections are append-only. In a service, use concat to add items:
await this.ctx.db.client.app.messages.concat([
  { text: "Hello", author: "alice" },
])
In the renderer, use useCollection to subscribe to a collection’s data:
import { useDb, useCollection } from "@zenbujs/core/react"

function Messages() {
  const messagesRef = useDb(root => root.app.messages)
  const { items, concat } = useCollection(messagesRef)
  const [text, setText] = useState("")

  return (
    <div>
      {items.map((msg, i) => (
        <div key={i}>{msg.author}: {msg.text}</div>
      ))}
      <input value={text} onChange={e => setText(e.target.value)} />
      <button onClick={() => {
        concat([{ text, author: "alice" }])
        setText("")
      }}>Send</button>
    </div>
  )
}
Collection data is only loaded into memory when you subscribe or read from it. This keeps the in-memory footprint small for data that can grow without bound.

Blobs

Blobs store binary data (Uint8Array) like files or images. Like collections, they live on disk and are only loaded into memory when you read them. Define a blob in your schema with f.blob():
src/main/schema.ts
import { createSchema, f, z } from "@zenbujs/core/db"

export default createSchema({
  avatar: f.blob({ debugName: "avatar" }),
})
Write binary data with set:
const data = new TextEncoder().encode("hello")
await this.ctx.db.client.app.avatar.set(data)
Read it back with read:
const data = await this.ctx.db.client.app.avatar.read()

Migrations

When you change your schema, existing databases need to be updated to match the new shape. Running pnpm run db:generate compares your current schema to the previous version and creates a migration file that describes what changed.
pnpm run db:generate
The generated file is placed in your plugin’s migrations/ directory. Here’s an example of what one looks like after adding a new activeTabId field to the schema:
migrations/0003.ts
import type { KyjuMigration } from "@zenbu/kyju"

export const migration: KyjuMigration = {
  // Each migration increments the version number.
  version: 3,
  // The operations array describes the changes to apply.
  operations: [
    // "add" introduces a new key with a default value.
    { op: "add", key: "activeTabId", kind: "data", hasDefault: true, default: null },
  ],
}
Three operation types cover most schema changes:
  • add: introduces a new key, optionally with a default value.
  • remove: drops an existing key.
  • alter: updates the metadata of an existing key, like changing its default.
For more complex changes, add a migrate function to transform the data with custom logic. Use ctx.apply to run the declared operations first, then modify the result:
migrations/0004.ts
import type { KyjuMigration } from "@zenbu/kyju"

export const migration: KyjuMigration = {
  version: 4,
  operations: [
    { op: "add", key: "fullName", kind: "data", hasDefault: true, default: "" },
  ],
  migrate: (prev, { apply }) => {
    // Runs the auto-generated operations above
    const result = apply(prev)
    result.fullName = `${prev.firstName} ${prev.lastName}`
    return result
  },
}
Migrations run automatically when the app starts. Each migration runs at most once, and during development, adding or editing a migration file triggers a reload without restarting the app.