Cana com Vue 3 e Pinia
Construa o sistema de tarefas categorizadas com Vue 3 e Pinia. O Cana possui o armazenamento durável no browser, enquanto o Pinia possui o estado reativo que os componentes renderizam.
1. Comece do zero
bun create vite cana-vue-pinia --template vue-ts
cd cana-vue-pinia
bun add @jumentix/cana @jumentix/cana-vue piniaRegistre Pinia em main.ts, crie src/cana.ts e então defina stores em
src/stores.
2. Implementação simples
A action simples init() abre o Cana, carrega as tabelas atuais e retorna a
função de unsubscribe de client.subscribe.
Componentes chamam actions da store:
tasks.addTask(title, categoryId)tasks.toggleTask(task)
A store atualiza seus arrays a partir de CanaChangeEvent, então todo
componente que usa a store renderiza novamente com o estado confirmado no
storage.
Vue 3 + Pinia: store atualizada pelo Cana
Actions Pinia escrevem no Cana e atualizam estado por eventos confirmados.
### Vue 3 + Pinia: store atualizada pelo Cana
Actions Pinia escrevem no Cana e atualizam estado por eventos confirmados.
#### package.json
```json
{
"name": "cana-vue-pinia-tasks",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@jumentix/cana": "^0.1.0",
"@jumentix/cana-vue": "^0.1.0",
"@vitejs/plugin-vue": "^6.0.5",
"pinia": "^4.0.3",
"typescript": "^6.0.3",
"vite": "^7.2.7",
"vue": "^3.5.41",
"vue-tsc": "^3.1.8"
}
}
```
#### tsconfig.json
```json
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}
```
#### index.html
```html
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
```
#### vite.config.ts
```typescript
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()]
});
```
#### src/vite-env.d.ts
```typescript
/// <reference types="vite/client" />
```
#### src/cana.ts
```typescript
import { createClient, type CanaChangeEvent, type CanaSchema } from '@jumentix/cana';
export type Category = {
id: string;
name: string;
color: string;
createdAt: number;
updatedAt: number;
};
export type Task = {
id: string;
title: string;
categoryId: string;
completed: boolean;
priority: 'low' | 'medium' | 'high';
notes?: string;
createdAt: number;
updatedAt: number;
};
export const taskSchema: CanaSchema = {
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' }
]
}
]
};
export const cana = createClient({
name: 'tutorial-cana-tasks',
schema: taskSchema,
originId: 'tasks-ui',
retainedEvents: 100
});
export async function openTaskDatabase() {
await cana.open();
return cana;
}
export async function loadAll() {
const [categories, tasks] = await Promise.all([
cana.table<Category>('categories').query({ index: 'byName' }),
cana.table<Task>('tasks').query({ index: 'byUpdatedAt' })
]);
return { categories: [...categories], tasks: [...tasks] };
}
export async function seedInitialData() {
await openTaskDatabase();
if (await cana.table<Category>('categories').count()) return;
const now = Date.now();
await cana.transaction('readwrite', ['categories', 'tasks'], async (scope) => {
await scope.table<Category>('categories').bulkAdd([
{ id: 'work', name: 'Work', color: '#2563eb', createdAt: now, updatedAt: now },
{ id: 'home', name: 'Home', color: '#16a34a', createdAt: now, updatedAt: now }
]);
await scope.table<Task>('tasks').bulkAdd([
{
id: 'task-1',
title: 'Write the Cana tutorial',
categoryId: 'work',
completed: false,
priority: 'high',
createdAt: now,
updatedAt: now
},
{
id: 'task-2',
title: 'Review category filters',
categoryId: 'home',
completed: true,
priority: 'medium',
createdAt: now,
updatedAt: now
}
]);
});
}
export function formatEvent(event: CanaChangeEvent) {
return event.cursor + ': ' + event.type + ' ' + event.store + '/' + String(event.key ?? 'all');
}
```
#### src/stores/tasks.ts
```typescript
import { defineStore } from 'pinia';
import { applyCanaEventToRecords, connectCanaToPinia } from '@jumentix/cana-vue';
import { cana, loadAll, seedInitialData, formatEvent, type Category, type Task } from '../cana';
export const useTasksStore = defineStore('tasks', {
state: () => ({
categories: [] as Category[],
tasks: [] as Task[],
events: [] as string[]
}),
getters: {
byCategory: (state) => (categoryId: string) =>
state.tasks.filter((task) => task.categoryId === categoryId)
},
actions: {
async init() {
await seedInitialData();
Object.assign(this, await loadAll());
const bridge = connectCanaToPinia({
client: cana,
apply: (event) => this.applyEvent(event)
});
return bridge.stop;
},
applyEvent(event: Parameters<typeof formatEvent>[0]) {
this.events = [...this.events, formatEvent(event)].slice(-8);
this.categories = applyCanaEventToRecords(this.categories, event, {
store: 'categories',
getKey: (category) => category.id,
sort: (a, b) => a.name.localeCompare(b.name)
});
this.tasks = applyCanaEventToRecords(this.tasks, event, {
store: 'tasks',
getKey: (task) => task.id,
sort: (a, b) => a.updatedAt - b.updatedAt
});
},
async addTask(title: string, categoryId: string) {
const now = Date.now();
await cana.table<Task>('tasks').add({
id: crypto.randomUUID(),
title,
categoryId,
completed: false,
priority: 'medium',
createdAt: now,
updatedAt: now
});
},
async toggleTask(task: Task) {
await cana.table<Task>('tasks').update(task.id, {
completed: !task.completed,
updatedAt: Date.now()
});
}
}
});
```
#### src/App.vue
```vue
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue';
import { useTasksStore } from './stores/tasks';
const tasks = useTasksStore();
let stop = () => {};
onMounted(async () => {
stop = await tasks.init();
});
onUnmounted(() => stop());
</script>
<template>
<main class="app">
<h1>Cana + Vue 3 + Pinia</h1>
<div class="toolbar">
<button @click="tasks.addTask('New Pinia task', 'work')">Add task</button>
</div>
<section class="board">
<article v-for="category in tasks.categories" :key="category.id" class="category">
<h2>{{ category.name }}</h2>
<button
v-for="task in tasks.byCategory(category.id)"
:key="task.id"
class="task"
@click="tasks.toggleTask(task)"
>
{{ task.completed ? 'Done: ' : '' }}{{ task.title }}
</button>
</article>
</section>
<pre class="events">{{ tasks.events.join('\n') || 'No events yet.' }}</pre>
</main>
</template>
```
#### src/main.ts
```typescript
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import './styles.css';
createApp(App).use(createPinia()).mount('#app');
```
#### src/styles.css
```css
body {
margin: 0;
font-family: Inter, system-ui, sans-serif;
background: #f8fafc;
color: #0f172a;
}
button {
cursor: pointer;
}
.app {
max-width: 960px;
margin: 0 auto;
padding: 32px;
}
.toolbar {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin: 16px 0;
}
.board {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 16px;
}
.category {
border: 1px solid #dbe3ef;
border-radius: 8px;
padding: 16px;
background: white;
}
.task {
display: block;
width: 100%;
margin-top: 8px;
border: 1px solid #dbe3ef;
border-radius: 6px;
padding: 8px;
text-align: left;
background: #f8fafc;
}
.events {
margin-top: 16px;
border: 1px solid #dbe3ef;
border-radius: 8px;
padding: 12px;
background: #0f172a;
color: #dbeafe;
white-space: pre-wrap;
}
```{
"name": "cana-vue-pinia-tasks",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@jumentix/cana": "^0.1.0",
"@jumentix/cana-vue": "^0.1.0",
"@vitejs/plugin-vue": "^6.0.5",
"pinia": "^4.0.3",
"typescript": "^6.0.3",
"vite": "^7.2.7",
"vue": "^3.5.41",
"vue-tsc": "^3.1.8"
}
}3. Implementação avançada
A store avançada mantém lastCursor em localStorage, assina com
sinceCursor e recarrega todas as tabelas quando o cursor de replay não está
mais disponível. Ela também grava categoria e primeira tarefa em uma transação
Cana.
Use onUnmounted(() => stop()) em componentes ou lógica de teardown para que
views antigas não continuem aplicando eventos depois da navegação.
Vue 3 + Pinia: fluxo transacional com replay
A store Pinia guarda o ultimo cursor do Cana e recarrega quando o replay nao esta disponivel.
### Vue 3 + Pinia: fluxo transacional com replay
A store Pinia guarda o ultimo cursor do Cana e recarrega quando o replay nao esta disponivel.
#### package.json
```json
{
"name": "cana-vue-pinia-tasks",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@jumentix/cana": "^0.1.0",
"@jumentix/cana-vue": "^0.1.0",
"@vitejs/plugin-vue": "^6.0.5",
"pinia": "^4.0.3",
"typescript": "^6.0.3",
"vite": "^7.2.7",
"vue": "^3.5.41",
"vue-tsc": "^3.1.8"
}
}
```
#### tsconfig.json
```json
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}
```
#### index.html
```html
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
```
#### vite.config.ts
```typescript
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()]
});
```
#### src/vite-env.d.ts
```typescript
/// <reference types="vite/client" />
```
#### src/cana.ts
```typescript
import { createClient, type CanaChangeEvent, type CanaSchema } from '@jumentix/cana';
export type Category = {
id: string;
name: string;
color: string;
createdAt: number;
updatedAt: number;
};
export type Task = {
id: string;
title: string;
categoryId: string;
completed: boolean;
priority: 'low' | 'medium' | 'high';
notes?: string;
createdAt: number;
updatedAt: number;
};
export const taskSchema: CanaSchema = {
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' }
]
}
]
};
export const cana = createClient({
name: 'tutorial-cana-tasks',
schema: taskSchema,
originId: 'tasks-ui',
retainedEvents: 100
});
export async function openTaskDatabase() {
await cana.open();
return cana;
}
export async function loadAll() {
const [categories, tasks] = await Promise.all([
cana.table<Category>('categories').query({ index: 'byName' }),
cana.table<Task>('tasks').query({ index: 'byUpdatedAt' })
]);
return { categories: [...categories], tasks: [...tasks] };
}
export async function seedInitialData() {
await openTaskDatabase();
if (await cana.table<Category>('categories').count()) return;
const now = Date.now();
await cana.transaction('readwrite', ['categories', 'tasks'], async (scope) => {
await scope.table<Category>('categories').bulkAdd([
{ id: 'work', name: 'Work', color: '#2563eb', createdAt: now, updatedAt: now },
{ id: 'home', name: 'Home', color: '#16a34a', createdAt: now, updatedAt: now }
]);
await scope.table<Task>('tasks').bulkAdd([
{
id: 'task-1',
title: 'Write the Cana tutorial',
categoryId: 'work',
completed: false,
priority: 'high',
createdAt: now,
updatedAt: now
},
{
id: 'task-2',
title: 'Review category filters',
categoryId: 'home',
completed: true,
priority: 'medium',
createdAt: now,
updatedAt: now
}
]);
});
}
export function formatEvent(event: CanaChangeEvent) {
return event.cursor + ': ' + event.type + ' ' + event.store + '/' + String(event.key ?? 'all');
}
```
#### src/stores/advancedTasks.ts
```typescript
import { defineStore } from 'pinia';
import { isCanaErrorCode } from '@jumentix/cana';
import { applyCanaEventToRecords, connectCanaToPinia } from '@jumentix/cana-vue';
import { cana, loadAll, seedInitialData, formatEvent, type Category, type Task } from '../cana';
let lastCursor = Number(localStorage.getItem('pinia:lastCursor') ?? 0);
export const useTasksStore = defineStore('advancedTasks', {
state: () => ({
categories: [] as Category[],
tasks: [] as Task[],
events: [] as string[],
canaError: null as unknown
}),
getters: {
byCategory: (state) => (categoryId: string) =>
state.tasks.filter((task) => task.categoryId === categoryId)
},
actions: {
async init() {
await seedInitialData();
Object.assign(this, await loadAll());
try {
const bridge = connectCanaToPinia({
client: cana,
sinceCursor: lastCursor,
apply: (event) => this.applyEvent(event)
});
return bridge.stop;
} catch (error) {
if (isCanaErrorCode(error, 'NotFound')) {
Object.assign(this, await loadAll());
const bridge = connectCanaToPinia({
client: cana,
apply: (event) => this.applyEvent(event)
});
return bridge.stop;
}
this.canaError = error;
throw error;
}
},
applyEvent(event: Parameters<typeof formatEvent>[0]) {
lastCursor = event.cursor;
localStorage.setItem('pinia:lastCursor', String(lastCursor));
this.events = [...this.events, formatEvent(event)].slice(-8);
this.categories = applyCanaEventToRecords(this.categories, event, {
store: 'categories',
getKey: (category) => category.id,
sort: (a, b) => a.name.localeCompare(b.name)
});
this.tasks = applyCanaEventToRecords(this.tasks, event, {
store: 'tasks',
getKey: (task) => task.id,
sort: (a, b) => a.updatedAt - b.updatedAt
});
},
async createCategoryWithFirstTask(name: string, title: string) {
const now = Date.now();
const categoryId = name.toLowerCase().replace(/\s+/g, '-');
await cana.transaction('readwrite', ['categories', 'tasks'], async (scope) => {
await scope.table<Category>('categories').put({
id: categoryId,
name,
color: '#dc2626',
createdAt: now,
updatedAt: now
});
await scope.table<Task>('tasks').put({
id: crypto.randomUUID(),
title,
categoryId,
completed: false,
priority: 'high',
createdAt: now,
updatedAt: now
});
});
},
async toggleTask(task: Task) {
await cana.table<Task>('tasks').update(task.id, {
completed: !task.completed,
updatedAt: Date.now()
});
}
}
});
```
#### src/App.vue
```vue
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue';
import { useTasksStore } from './stores/advancedTasks';
const tasks = useTasksStore();
let stop = () => {};
onMounted(async () => {
stop = await tasks.init();
});
onUnmounted(() => stop());
</script>
<template>
<main class="app">
<h1>Cana + Vue 3 + Pinia advanced</h1>
<div class="toolbar">
<button @click="tasks.createCategoryWithFirstTask('QA', 'Created in one transaction')">
Create category + task
</button>
</div>
<section class="board">
<article v-for="category in tasks.categories" :key="category.id" class="category">
<h2>{{ category.name }}</h2>
<button
v-for="task in tasks.byCategory(category.id)"
:key="task.id"
class="task"
@click="tasks.toggleTask(task)"
>
{{ task.completed ? 'Done: ' : '' }}{{ task.title }}
</button>
</article>
</section>
<pre class="events">{{ tasks.events.join('\n') || 'No events yet.' }}</pre>
</main>
</template>
```
#### src/main.ts
```typescript
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import './styles.css';
createApp(App).use(createPinia()).mount('#app');
```
#### src/styles.css
```css
body {
margin: 0;
font-family: Inter, system-ui, sans-serif;
background: #f8fafc;
color: #0f172a;
}
button {
cursor: pointer;
}
.app {
max-width: 960px;
margin: 0 auto;
padding: 32px;
}
.toolbar {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin: 16px 0;
}
.board {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 16px;
}
.category {
border: 1px solid #dbe3ef;
border-radius: 8px;
padding: 16px;
background: white;
}
.task {
display: block;
width: 100%;
margin-top: 8px;
border: 1px solid #dbe3ef;
border-radius: 6px;
padding: 8px;
text-align: left;
background: #f8fafc;
}
.events {
margin-top: 16px;
border: 1px solid #dbe3ef;
border-radius: 8px;
padding: 12px;
background: #0f172a;
color: #dbeafe;
white-space: pre-wrap;
}
```{
"name": "cana-vue-pinia-tasks",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@jumentix/cana": "^0.1.0",
"@jumentix/cana-vue": "^0.1.0",
"@vitejs/plugin-vue": "^6.0.5",
"pinia": "^4.0.3",
"typescript": "^6.0.3",
"vite": "^7.2.7",
"vue": "^3.5.41",
"vue-tsc": "^3.1.8"
}
}4. Formato final do app
src/
cana.ts
main.ts
App.vue
stores/
tasks.ts
advancedTasks.tsGetters do Pinia devem conter visões derivadas, como tarefas por categoria, não cópias duplicadas dos dados do Cana.
Baixe o app Vite completo usado pelo exemplo avançado: cana-vue-pinia.zip.
5. Checklist
-
createPinia()é instalado antes dos componentes usarem stores. -
init()abre o Cana, carrega tabelas e então assina eventos. -
onUnmountedcancela listeners. - Actions da store escrevem no Cana e estado atualiza por eventos confirmados.
- Fluxos avançados recarregam tabelas quando replay não está disponível.
Próximo
Compare o mesmo modelo em React Context e React Redux.