Workers and testing
Workers move persistence work away from the page thread. The boundary is message based, so every request and response must be structured-cloneable plain data.
Worker request flow
Complete same-thread worker-host demo
This playground uses MessageChannel so the docs can run the worker protocol
without creating a separate Worker file. A production app would call
createWorkerHost() inside the Worker module and keep createRouter() on the
page thread.
Worker client flow
Drive Cana through createWorkerHost, createRouter and createWorkerClient using MessageChannel.
### Worker client flow
```ts
const channel = new MessageChannel();
channel.port1.start?.();
channel.port2.start?.();
const broadcasts = [];
const router = cana.createRouter({
port: channel.port1,
timeoutMs: 5000,
onBroadcast(event) {
broadcasts.push({
cursor: event.cursor,
type: event.type,
store: event.store,
key: event.key
});
}
});
const host = cana.createWorkerHost({
port: channel.port2,
name: dbName,
schema: {
version: 1,
stores: [
{ name: 'categories', keyPath: 'id', indexes: [{ name: 'byName', keyPath: 'name', unique: true }] },
{
name: 'tasks',
keyPath: 'id',
indexes: [
{ name: 'byCategory', keyPath: 'categoryId' },
{ name: 'byCompleted', keyPath: 'completed' },
{ name: 'byUpdatedAt', keyPath: 'updatedAt' }
]
}
]
},
originId: 'docs-worker-host',
retainedEvents: 20,
operationLedger: true
});
const workerClient = cana.createWorkerClient(router);
try {
await workerClient.open();
const now = Date.now();
await workerClient.put('categories', {
id: 'work',
name: 'Work',
color: '#2563eb',
createdAt: now,
updatedAt: now
});
await workerClient.add('tasks', {
id: 'task-worker-1',
title: 'Persist through the worker boundary',
categoryId: 'work',
completed: false,
priority: 'medium',
createdAt: now,
updatedAt: now
});
const tasks = await workerClient.query('tasks', {
index: 'byCategory',
equals: 'work'
});
const count = await workerClient.count('tasks');
return {
ping: await workerClient.ping(),
count,
tasks,
broadcasts
};
} finally {
await workerClient.close();
router.dispose();
await host.dispose();
channel.port1.close();
channel.port2.close();
}
```const channel = new MessageChannel();
channel.port1.start?.();
channel.port2.start?.();
const broadcasts = [];
const router = cana.createRouter({
port: channel.port1,
timeoutMs: 5000,
onBroadcast(event) {
broadcasts.push({
cursor: event.cursor,
type: event.type,
store: event.store,
key: event.key
});
}
});
const host = cana.createWorkerHost({
port: channel.port2,
name: dbName,
schema: {
version: 1,
stores: [
{ name: 'categories', keyPath: 'id', indexes: [{ name: 'byName', keyPath: 'name', unique: true }] },
{
name: 'tasks',
keyPath: 'id',
indexes: [
{ name: 'byCategory', keyPath: 'categoryId' },
{ name: 'byCompleted', keyPath: 'completed' },
{ name: 'byUpdatedAt', keyPath: 'updatedAt' }
]
}
]
},
originId: 'docs-worker-host',
retainedEvents: 20,
operationLedger: true
});
const workerClient = cana.createWorkerClient(router);
try {
await workerClient.open();
const now = Date.now();
await workerClient.put('categories', {
id: 'work',
name: 'Work',
color: '#2563eb',
createdAt: now,
updatedAt: now
});
await workerClient.add('tasks', {
id: 'task-worker-1',
title: 'Persist through the worker boundary',
categoryId: 'work',
completed: false,
priority: 'medium',
createdAt: now,
updatedAt: now
});
const tasks = await workerClient.query('tasks', {
index: 'byCategory',
equals: 'work'
});
const count = await workerClient.count('tasks');
return {
ping: await workerClient.ping(),
count,
tasks,
broadcasts
};
} finally {
await workerClient.close();
router.dispose();
await host.dispose();
channel.port1.close();
channel.port2.close();
}Production file layout
src/cana.worker.ts
import { createWorkerHost, type CanaSchema } from '@jumentix/cana';
const schema: CanaSchema = {
version: 1,
stores: [
{ name: 'categories', keyPath: 'id', indexes: [{ name: 'byName', keyPath: 'name' }] },
{
name: 'tasks',
keyPath: 'id',
indexes: [
{ name: 'byCategory', keyPath: 'categoryId' },
{ name: 'byUpdatedAt', keyPath: 'updatedAt' }
]
}
]
};
createWorkerHost({
port: self,
name: 'tasks-worker-db',
schema,
originId: 'tasks-worker',
retainedEvents: 100,
operationLedger: true
});src/cana-client.ts
import {
createRouter,
createWorkerClient,
type CanaChangeEvent
} from '@jumentix/cana';
const worker = new Worker(new URL('./cana.worker.ts', import.meta.url), {
type: 'module'
});
const events: CanaChangeEvent[] = [];
const router = createRouter({
port: worker,
timeoutMs: 15_000,
onBroadcast(event) {
events.push(event as CanaChangeEvent);
}
});
export const canaWorker = createWorkerClient(router);
export const canaWorkerEvents = events;
export async function stopCanaWorker() {
await canaWorker.close();
router.dispose();
worker.terminate();
}src/tasks.ts
import { canaWorker } from './cana-client';
export async function createTaskInWorker() {
await canaWorker.open();
await canaWorker.put('categories', {
id: 'work',
name: 'Work',
color: '#2563eb',
createdAt: Date.now(),
updatedAt: Date.now()
});
await canaWorker.add('tasks', {
id: crypto.randomUUID(),
title: 'Persist through the worker',
categoryId: 'work',
completed: false,
priority: 'medium',
createdAt: Date.now(),
updatedAt: Date.now()
});
return canaWorker.query('tasks', { index: 'byCategory', equals: 'work' });
}Testing strategy
| Layer | What to test | Tooling |
|---|---|---|
| Unit | Schema builders, record mappers, event reducers. | Bun/Jest without a browser. |
| Browser component | React/Vue components update from CanaChangeEvent. | Testing Library with injected events. |
| IndexedDB integration | Open, upgrade, query, transaction, storage assessment, export/import. | Cypress or browser automation. |
| Worker integration | Router timeout, broadcasts, structured-clone failures. | Real Worker in a browser test. |
| Performance | Query ratios, count vs full read, deep pagination. | Browser performance suite. |
Next
Continue to the API reference.