# Workers & multi-tab

> The shipped worker entry, the main-thread client, and bundler setups.

Minnow ships both sides of the worker setup: a ready-made worker entry (`@minnowdb/core/worker`)
and a main-thread client (`@minnowdb/core/client`). Your app contributes one line — the `new
  Worker(...)` call — because that's the only line your bundler needs to see.

## Quick start

```ts
import { MinnowDatabaseClient } from "@minnowdb/core/client";
import { column, schema, table } from "@minnowdb/core";
import { createMinnow, type InferDatabase } from "@minnowdb/client";

const people = table("people", {
  name: column.string().unique(),
  score: column.number(),
});
const appSchema = schema([people]);
interface DB extends InferDatabase<typeof appSchema> {}

const client = new MinnowDatabaseClient(
  new Worker(new URL("@minnowdb/core/worker", import.meta.url), { type: "module" }),
  { store: { kind: "indexeddb", name: "app-db" } },
);
await client.migrate(appSchema);

// The typed facade wraps the client exactly as it wraps an in-worker database.
const db = createMinnow<DB>(client, { schema: appSchema });
await db
  .insertInto("people")
  .values([
    { name: "Ada", score: 10 },
    { name: "Grace", score: 20 },
  ])
  .execute();
const rows = await db
  .selectFrom("people")
  .select(["name", "score"])
  .orderBy("score", "desc")
  .execute(); // Array<{ name: string; score: number }>
```

- The client sends its configuration to the worker on startup, so the stock entry needs none of
  its own.
- The channel is ordered, so you can issue calls immediately. `await client.ready()` exists to
  surface store-open failures eagerly.
- Storage access, decoding, planning, and execution all run in the worker; the main thread holds
  only the proxy.
- The raw layer is there too: `client.query(sql)`, `client.insertBatch(...)`,
  `client.createTable(...)` — the full database API.

## Why you construct the Worker

A worker needs a script URL at runtime, and bundlers only rewrite
`new Worker(new URL("…", import.meta.url), { type: "module" })` correctly when that exact
expression appears in _your_ code — buried inside a library, it breaks differently under every
bundler. Keeping it in your code also keeps lifecycle ownership honest (you decide when the worker
starts and stops) and satisfies `worker-src` content-security policies from your own origin.

## What changes across the boundary

- **Everything is async.** Members that are synchronous in the worker return promises on the
  client, and getters become methods: `writer.stats()`.
- **`snapshot()` pins its version in the worker** for the callback's lifetime — session queries
  cross the channel pinned to that version, so a scope observes one consistent state even while
  other tabs commit.
- **`migrate()` takes the same schema DSL** — it's serialized over the wire automatically.
- **Typed errors survive the trip.** `instanceof UniqueConstraintError` works on the client, with
  its fields; stack traces point into the worker.
- **Functions can't cross.** Construction options like `now`, `createId`, or a custom store need
  a custom entry (below).

The typed facade doesn't care about any of this: compiled plans cross the channel by structured
clone, so `selectFrom`, typed [writes](/docs/sql/dml.md), and `.live()` behave identically.

## Buffered writers and live queries

Stateful handles proxy transparently — the writer's age timer runs on the worker's clock, and
live-query callbacks arrive as events:

```ts
const writer = client.bufferedWriter("people", {
  maxRows: 500,
  onError: (error) => console.error("background flush failed", error),
});
await writer.add({ name: "Edsger", score: 30 });
await writer.close();

const live = client.liveQueries({ channelName: "app-db-commits" });
const subscription = await live.subscribe("SELECT name, score FROM people ORDER BY score DESC", {
  onChange: (result) => render(result.rows),
});
// … later
await subscription.close();
await live.close();
await client.close({ terminateWorker: true });
```

## Bundler setups

**Vite and webpack 5** understand the quick-start pattern as written — they resolve the package
subpath and emit a separate worker chunk. Nothing else needed.

**esbuild** (and Parcel 2 by default) doesn't rewrite `new URL` worker expressions. Bundle the
worker entry separately and point at the output:

```ts
// worker.ts — your one-line worker entry, bundled separately:
import "@minnowdb/core/worker";

// esbuild app.ts worker.ts --bundle --format=esm --outdir=dist --splitting

// app.ts:
const client = new MinnowDatabaseClient(
  new Worker(new URL("./worker.js", import.meta.url), { type: "module" }),
);
```

**No bundler** — module workers can't resolve bare specifiers, so use full URLs from a CDN or
vendored files:

```ts
// db-worker.js — served from your origin:
import "https://cdn.example.com/@minnowdb/core/dist/worker.js";

// main page:
import { MinnowDatabaseClient } from "https://cdn.example.com/@minnowdb/core/dist/client.js";
const client = new MinnowDatabaseClient(
  new Worker(new URL("./db-worker.js", import.meta.url), { type: "module" }),
);
```

## Custom worker entries

The stock entry covers everything a message can carry: the store descriptor (`indexeddb` or
`memory`) plus `compression`, `rowsPerBlock`, `maxCommitRetries`, `spillOwnerLeaseMs`, and
`bufferPoolBytes`. For anything a message can't carry — a custom `BlockStore`, deterministic
`now`/`createId` — write your own entry:

```ts
// my-worker.ts
import { MinnowDatabase, exposeDatabase } from "@minnowdb/core";
import { IndexedDbBlockStore } from "@minnowdb/core/storage";

const store = await IndexedDbBlockStore.open({ name: "app-db", durability: "strict" });
exposeDatabase(new MinnowDatabase(store, { compression: "gzip" }), self, {
  onDispose: () => store.close(),
});
```

`exposeDatabase()` speaks the same protocol as the stock entry, so the main-thread code doesn't
change. Underneath both sits `@minnowdb/core/worker-protocol`: versioned, structured-clone-safe
RPC where each handle answers a fixed list of methods — never arbitrary property access.

> The worker changes where work runs, not the rules: IndexedDB stays authoritative, and
> durability still ends at a committed transaction. See [Architecture](/docs/reference/architecture.md).

---

Minnow 0.1.0 · this page on the site: /docs/engine/workers/
