Transactions and change events
Transactions are the commit boundary. Change events are emitted only after the IndexedDB transaction completes, so subscribers never react to data that later rolls back.
Multi-store transaction
type Category = {
id: string;
name: string;
color: string;
createdAt: number;
updatedAt: number;
};
type Task = {
id: string;
title: string;
categoryId: string;
completed: boolean;
priority: 'low' | 'medium' | 'high';
createdAt: number;
updatedAt: number;
};
const result = await client.transaction('readwrite', ['categories', 'tasks'], async (scope) => {
const now = Date.now();
await scope.table<Category>('categories').put({
id: 'ops',
name: 'Operations',
color: '#f97316',
createdAt: now,
updatedAt: now
});
await scope.table<Task>('tasks').put({
id: 'ops-1',
title: 'Create the operations category',
categoryId: 'ops',
completed: false,
priority: 'high',
createdAt: now,
updatedAt: now
});
return { categoryId: 'ops', taskId: 'ops-1' };
});
console.log({
outcome: result.outcome,
result: result.result,
events: result.events
});Do not wait for network, timers or UI work inside the transaction body. The
IndexedDB transaction auto-commits when the event loop yields with no pending
IndexedDB request. Cana reports that as TransactionInactive.
Listen to committed events
import type { CanaChangeEvent } from '@jumentix/cana';
const events: Array<Pick<CanaChangeEvent, 'cursor' | 'type' | 'store' | 'key'>> = [];
const stop = client.subscribe((event) => {
events.push({
cursor: event.cursor,
type: event.type,
store: event.store,
key: event.key
});
});
await client.table<Task>('tasks').update('ops-1', {
completed: true,
updatedAt: Date.now()
});
stop();
console.log(events);Replay after the UI was gone
import { isCanaErrorCode, type CanaChangeEvent } from '@jumentix/cana';
let lastCursor = Number(localStorage.getItem('tasks:lastCursor') ?? 0);
function applyEventToUiStore(event: CanaChangeEvent) {
lastCursor = event.cursor;
localStorage.setItem('tasks:lastCursor', String(lastCursor));
console.log(`${event.cursor}: ${event.type} ${event.store}/${String(event.key)}`);
}
try {
const stop = client.subscribe(applyEventToUiStore, { sinceCursor: lastCursor });
window.addEventListener('beforeunload', () => stop(), { once: true });
} catch (error) {
if (isCanaErrorCode(error, 'NotFound')) {
const categories = await client.table<Category>('categories').query({ index: 'byName' });
const tasks = await client.table<Task>('tasks').query({ index: 'byUpdatedAt' });
console.log({ reloadRequired: true, categories, tasks });
} else {
throw error;
}
}Replay is bounded by retainedEvents. When the cursor is too old, Cana refuses
to pretend the replay is complete. Reload the table and then subscribe again.
Run it here
Transactions
Create one Category and its first Task in a single commit.
### Transactions
```ts
const client = cana.createClient({
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' }
]
}
]
}
});
await client.open();
const tx = await client.transaction('readwrite', ['categories', 'tasks'], async (scope) => {
const now = Date.now();
await scope.table('categories').put({
id: 'ops',
name: 'Operations',
color: '#f97316',
createdAt: now,
updatedAt: now
});
await scope.table('tasks').put({
id: 'ops-1',
title: 'Created with the category',
categoryId: 'ops',
completed: false,
priority: 'high',
createdAt: now,
updatedAt: now
});
return 'ok';
});
return {
outcome: tx.outcome,
result: tx.result,
categories: await client.table('categories').query(),
tasks: await client.table('tasks').query()
};
```const client = cana.createClient({
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' }
]
}
]
}
});
await client.open();
const tx = await client.transaction('readwrite', ['categories', 'tasks'], async (scope) => {
const now = Date.now();
await scope.table('categories').put({
id: 'ops',
name: 'Operations',
color: '#f97316',
createdAt: now,
updatedAt: now
});
await scope.table('tasks').put({
id: 'ops-1',
title: 'Created with the category',
categoryId: 'ops',
completed: false,
priority: 'high',
createdAt: now,
updatedAt: now
});
return 'ok';
});
return {
outcome: tx.outcome,
result: tx.result,
categories: await client.table('categories').query(),
tasks: await client.table('tasks').query()
};Change events
Subscribe and collect committed Task events.
### Change events
```ts
const client = cana.createClient({
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' }
]
}
]
}
});
await client.open();
const seen = [];
const stop = client.subscribe((event) => {
seen.push({ cursor: event.cursor, type: event.type, store: event.store, key: event.key });
});
await client.table('categories').add({ id: 'docs', name: 'Docs', color: '#0f766e' });
await client.table('tasks').add({
id: 'docs-1',
title: 'Listen to Cana events',
categoryId: 'docs',
completed: false,
priority: 'medium',
createdAt: 1,
updatedAt: 1
});
await client.table('tasks').update('docs-1', { completed: true, updatedAt: 2 });
stop();
return { events: seen };
```const client = cana.createClient({
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' }
]
}
]
}
});
await client.open();
const seen = [];
const stop = client.subscribe((event) => {
seen.push({ cursor: event.cursor, type: event.type, store: event.store, key: event.key });
});
await client.table('categories').add({ id: 'docs', name: 'Docs', color: '#0f766e' });
await client.table('tasks').add({
id: 'docs-1',
title: 'Listen to Cana events',
categoryId: 'docs',
completed: false,
priority: 'medium',
createdAt: 1,
updatedAt: 1
});
await client.table('tasks').update('docs-1', { completed: true, updatedAt: 2 });
stop();
return { events: seen };Next
Continue to hooks and errors.