A UI captura o domínio
Category, Task, relacionamentos, validações e superfícies de API viram dados explícitos da plataforma.
DDD, Arquitetura Hexagonal, Event-Driven Design, SOLID e módulos por feature são restrições operacionais, não apenas rótulos.

Arquitetura hexagonal
Clique em um anel para inspecionar pastas reais e trechos de código em apps/backend-template/src. GUIs web e desktop ficam no lado inbound (driving).
Fluxo de chamada
Fonte: apps/backend-template · contrato Spec Architecture
Caminho do request
Toda interface começa fora do domínio e entra por contratos. Toda chamada de infraestrutura sai por ports. GUIs web e desktop ficam em interface/GUI no lado inbound.
| Capability | Implementation | Status |
|---|---|---|
| Domain isolation | Feature-driven bounded contexts and domain contracts | Implemented |
| Interface portability | Native HTTP, Socket.IO, gRPC, function adapters | Implemented |
| Persistence portability | IDatabaseClient and IStore ports | Implemented |
| Service decoupling | Message Mediator request/response and events | Implemented |
Plataforma pronta para AI
O Jumentix é preparado para AI porque o produto não obriga agentes a inferir arquitetura a partir de código espalhado. A UI captura contextos delimitados, entidades, interfaces, perfis de deploy e requisitos; o repositório expõe docs legíveis por agentes e checagens executáveis que tornam trabalho gerado auditável.
Category, Task, relacionamentos, validações e superfícies de API viram dados explícitos da plataforma.
O agente lê índice de docs, contratos de pacote e blueprint da UI antes de escolher arquivos para alterar.
Casos de uso, controllers, SDKs, exemplos Cana e perfis de runtime preservam seus limites de ownership.
Requisitos, test maps, scripts de arquitetura, checagens de rota e gates prepublish validam a mudança.
Revisores veem o que mudou, por que cabe na arquitetura e quais checks provam que está pronto.
Agentes podem partir do índice de docs, páginas de pacote, metadados de rotas e snippets de código em vez de adivinhar qual arquivo possui um comportamento.
/llms-full.txtA UI de Service Management transforma conceitos de produto em contextos, interfaces e escolhas de deploy que geradores e revisores conseguem inspecionar.
service-management-uiMudanças geradas ou escritas por agentes ainda passam por requisitos, test maps, rotas, limites de pacote e governança de release antes de publicar.
requirements:checkPacotes reutilizáveis dão ao trabalho de AI nomes estáveis para persistência, mediação, clientes, Cana, bootstrap de runtime e limites arquiteturais.
packages/*A UI entrega para a AI um ponto de partida restrito: vocabulário do serviço, limites, interfaces, perfil de runtime e obrigações de qualidade são explícitos antes de qualquer prompt. Isso transforma o agente de um gerador por chute em um contribuidor dentro das regras da plataforma.
| Ação de AI | Fonte da UI | Guarda-corpo de governança | Saída útil |
|---|---|---|---|
| Desenhar com segurança | Contexto delimitado, entidades, relacionamentos e requisitos. | Validação do modelo de serviço e registro de requisitos. | Uma spec de serviço que produto, arquitetura e engenharia revisam juntas. |
| Gerar com segurança | Interfaces, eventos, perfil de deploy e escolhas de pacote. | OpenAPI, AsyncAPI, checagens de rota e limites de workspace. | Contratos, SDKs, handlers e exemplos alinhados ao mesmo modelo. |
| Publicar com evidência | Expectativas de qualidade, caminho de release e índice de docs. | Test map, checagens de arquitetura, sync de docs e gates prepublish. | Uma PR que revisores auditam com prova concreta em vez de confiança narrativa. |
{
"boundedContext": "Tasks",
"entities": [
{ "name": "Category", "fields": ["id", "name", "color"] },
{ "name": "Task", "fields": ["id", "title", "categoryId", "completed"] }
],
"interfaces": ["REST", "WebSocket"],
"deploymentProfiles": ["dev", "staging", "production"],
"requirements": ["REQ-TASK-CATEGORY", "REQ-TASK-LIVE-UPDATES"],
"governanceChecks": [
"requirements:check",
"test-map:check",
"arch:check-workspace-boundaries"
]
}Responsabilidades por camada
O valor da arquitetura está na restrição. O Jumentix torna limites explícitos para que escalar o sistema não transforme toda feature em reescrita transversal.
| Camada | É dona de | Pode depender de | Não deve conter |
|---|---|---|---|
| Adaptador de interface | Clientes GUI (interface/GUI), transporte HTTP/WebSocket/gRPC/function, parsing de request, responses de protocolo. | Contratos OpenAPI/AsyncAPI, contratos de controller, utilitários de runtime. | Regras de negócio, queries de banco, decisões de política tenant. |
| Controller | Mapeamento request-para-caso-de-uso, formato de input, formato de output, exposição de erro. | Casos de uso de aplicação e contratos de validação. | Objetos request de framework ou clientes concretos de repository. |
| Caso de uso de aplicação | Workflow de negócio, chamadas de autorização, orquestração, decisões de fronteira transacional. | Objetos de domínio, serviços de domínio, ports de repository, ports de mediator. | Imports de Express/Fastify/Sequelize/Mongo/BullMQ. |
| Domínio | Entidades, value objects, invariantes, políticas tenant/RBAC, eventos de domínio. | Tipos puros, políticas e regras de negócio dentro do contexto delimitado. | I/O, variáveis de ambiente, logging, status code HTTP. |
| Adaptador de saída | Persistência, filas, provedores externos, cache, arquivos, email e detalhes de broker. | Ports e contratos pertencentes à camada de aplicação. | Decisões de domínio que pertencem a casos de uso ou entidades. |
Um serviço de domínio pode consumir e produzir mensagens como worker independente. Retire-o do processo sem obrigar consumidores a importar a implementação.
export const CreateTaskContract = {
subject: 'tasks.create',
request: CreateTaskInput,
response: TaskOutput,
} as const;
const task = await mediator.request(
CreateTaskContract,
{ title: 'Ship with confidence' },
);Evolução de topologia
Etapa 1
Um deploy, múltiplos contextos delimitados, composition root compartilhada, uma superfície operacional para o primeiro release.
Melhor para primeira prova de produtoEtapa 2
Mova realtime, gRPC ou comportamento background para processo separado enquanto REST permanece como fallback.
Melhor para latência ou trabalho longoEtapa 3
Promova um contexto delimitado para seu próprio serviço com adaptador de banco, perfil de deploy, broker e evidência de CI.
Melhor para ownership de timeGovernança arquitetural
A página de arquitetura só tem valor porque o repo a aplica. O Jumentix entrega testes e scripts que falham quando limites, contratos, cobertura ou docs saem de sincronia.
Scripts de limite hexagonal e workspace rejeitam atalhos controller-para-infra e imports entre pacotes que quebram ownership.
arch:check-*Resolução de rotas OpenAPI, exportações AsyncAPI, docs gerados e caminhos de SDK mantêm interfaces externas sincronizadas.
oas:check-routesGates por branch escolhem suítes focadas para branches de tarefa e suítes completas para release, preservando velocidade e disciplina.
ci:gate:branchQualidade como superfície do produto
O Jumentix trata qualidade como parte da promessa visível do produto. Requisitos são checados, testes são mapeados, regras de arquitetura são executáveis e caminhos de publicação carregam evidências em vez de depender de cerimônia.
O trabalho de entrega conecta comportamento, docs, testes e evidência de release para que “pronto” seja auditável.
requirements:checkUnit, integração, route sweep Cypress, specs de browser e smoke de Storybook cobrem a superfície real de cada camada.
test-map:checkScripts de limite rejeitam imports e atalhos que vazariam frameworks, bancos ou infraestrutura para o domínio.
arch:check-*Sync de conteúdo, checagens de rota, dry-runs de pacote e governança de release rodam antes de artefatos públicos avançarem.
website:test:prepublishbun run requirements:check
bun run docs:consumers:package-scripts
bun run website:test:prepublishPlaygrounds in-memory no browser
Estes exemplos executáveis usam registros Category e Task em adaptadores in-memory compatíveis com contrato e armazenamento local nativo do browser. Tudo roda no browser, para o time inspecionar o comportamento dos pacotes antes de adicionar Redis, RabbitMQ, bancos, processos PM2 ou runtime backend.
Comece pelo caminho do request e pela matriz de camadas para cada playground ter lugar na arquitetura.
Use o browser lab para ver Category e Task passarem por controller, caso de uso, adapter e estado do cliente.
Vá para Message Mediator, SDKs e ports de storage quando o exemplo precisar de dados entre domínios ou contrato consumidor.
Finalize com o playground bulk mutex + DLQ para observar rejeição real de lock, replay e commits IndexedDB no canvas.
O lab completo parte de um modelo visual de serviço, valida o formato do domínio e alimenta contratos de runtime com o mesmo vocabulário Category e Task.
Superfície de appO caminho de request espelha limites de controller, caso de uso, repository, mediator e client sem expor usuários a setup de infraestrutura.
Runtime de appStores in-memory e estado chave/valor mantêm filtros de Category, registros Task e preferências de UI no mesmo formato de resposta dos adaptadores externos.
@jumentix/key-value-storageMessage Mediator, Mutex Service e Dead Letter Queue mostram request/response, eventos, proteção de escrita e rejeição recuperável antes de infraestrutura durável entrar.
@jumentix/message-mediatorO playground de DLQ envia requests Task rejeitados em massa de volta pelo fluxo do controller, então o replay ainda readquire o mutex da Category antes de escrever.
@jumentix/dead-letter-queueDiretório de playgrounds
Todos os code playgrounds registrados aparecem aqui com link direto para o widget executável abaixo. Use como mapa entre Cana, mensageria, persistência, mutex, SDK e exemplos de design.
Fatias completas de produto com Category e Task, do primeiro MVP à recuperação heavy-data.
Uma fatia completa de produto 100% browser usando Category e Task.
Jumentix browser lab · Do zero ao primeiro MVPCaminho de stressEscritas concorrentes, locks, replay DLQ e persistência browser em um cenário heavy-data.
Jumentix browser lab · Mutex + Dead Letter Queue + CanaCaminho MVPUma fatia completa de produto 100% browser usando Category e Task.
Jumentix browser lab · Do zero ao primeiro MVPCaminho MVPUma fatia completa de produto 100% browser usando Category e Task.
Jumentix browser lab · Do zero ao primeiro MVPCaminho MVPUma fatia completa de produto 100% browser usando Category e Task.
Jumentix browser lab · Do zero ao primeiro MVPCaminho MVPUma fatia completa de produto 100% browser usando Category e Task.
Jumentix browser lab · Do zero ao primeiro MVPCaminho MVPUma fatia completa de produto 100% browser usando Category e Task.
Jumentix browser lab · Do zero ao primeiro MVPCaminho MVPUma fatia completa de produto 100% browser usando Category e Task.
Jumentix browser lab · Do zero ao primeiro MVPCaminho MVPUma fatia completa de produto 100% browser usando Category e Task.
Jumentix browser lab · Do zero ao primeiro MVPDados relacionais no browser, workers, listeners de eventos, IndexedDB e integração com estado de UI.
Estado relacional local, workers, eventos e exemplos de UI com IndexedDB.
Cana · Arquitetura CanaDados frontendEstado relacional local, workers, eventos e exemplos de UI com IndexedDB.
Cana · Arquitetura CanaDados frontendEstado relacional local, workers, eventos e exemplos de UI com IndexedDB.
Cana · Arquitetura CanaDados frontendEstado relacional local, workers, eventos e exemplos de UI com IndexedDB.
Cana · Arquitetura CanaDados frontendEstado relacional local, workers, eventos e exemplos de UI com IndexedDB.
Cana · Arquitetura CanaDados frontendEstado relacional local, workers, eventos e exemplos de UI com IndexedDB.
Cana · Arquitetura CanaDados frontendEstado relacional local, workers, eventos e exemplos de UI com IndexedDB.
Cana · Arquitetura CanaDados frontendEstado relacional local, workers, eventos e exemplos de UI com IndexedDB.
Cana · Arquitetura CanaDados frontendEstado relacional local, workers, eventos e exemplos de UI com IndexedDB.
Cana · Arquitetura CanaDados frontendEstado relacional local, workers, eventos e exemplos de UI com IndexedDB.
Cana · Arquitetura CanaDados frontendEstado relacional local, workers, eventos e exemplos de UI com IndexedDB.
Cana · Arquitetura CanaDados frontendEstado relacional local, workers, eventos e exemplos de UI com IndexedDB.
Cana · Arquitetura CanaDados frontendEstado relacional local, workers, eventos e exemplos de UI com IndexedDB.
Cana · Arquitetura CanaDados frontendEstado relacional local, workers, eventos e exemplos de UI com IndexedDB.
Cana · Arquitetura CanaDados frontendEstado relacional local, workers, eventos e exemplos de UI com IndexedDB.
Cana · Arquitetura CanaDados frontendEstado relacional local, workers, eventos e exemplos de UI com IndexedDB.
Cana · Arquitetura CanaDados frontendEstado relacional local, workers, eventos e exemplos de UI com IndexedDB.
Cana · Arquitetura CanaDados frontendEstado relacional local, workers, eventos e exemplos de UI com IndexedDB.
Cana · Arquitetura CanaDados frontendEstado relacional local, workers, eventos e exemplos de UI com IndexedDB.
Cana · Arquitetura CanaExemplos de mediator, mutex e key/value que explicam os limites arquiteturais em volta de trabalho compartilhado.
Composição request/response e eventos entre módulos de domínio independentes.
Message Mediator · Limites orientados a eventosMensageria de domínioComposição request/response e eventos entre módulos de domínio independentes.
Message Mediator · Limites orientados a eventosPort de estado localPequenos registros de estado com o mesmo formato contratual usado por adaptadores substituíveis.
Key/value storage · Ports de persistênciaGuarda de concorrênciaLocks por Category em volta dos fluxos de Task.
Mutex Service · Consistência operacionalExemplos de SDK clients e Designer Core que conectam modelagem de UI a consumidores executáveis.
Um consumidor client-side chamando o mesmo comportamento de Task por uma superfície de API estável.
REST SDK · Clientes contract-firstContrato consumidorUm consumidor client-side chamando o mesmo comportamento de Task por uma superfície de API estável.
WebSocket SDK · Clientes contract-firstModelo de designFormato do domínio, entidades e relações antes do código runtime.
Designer Core · Service Management como fonte de verdadeUma fatia completa de produto 100% browser usando Category e Task.
Execute Category e Task como domínios separados que trocam mensagens pelo mediator para compor um task board sem servidor.
### App Jumentix completo no browser
```ts
const database = api.createInMemoryDatabase({
stores: ['categories', 'tasks']
});
const keyValue = api.createKeyValueStorage();
const mutex = api.createMutex(keyValue);
const mediator = api.createMessageMediator();
const emittedEvents = [];
await database.connect();
await keyValue.connect();
const model = api.createServiceModel({
app: 'service-management',
domain: 'Tasks'
});
const designReport = api.validateDesign(model);
await database.stores.categories.create('work', {
id: 'work',
name: 'Work',
color: '#2563eb'
});
await database.stores.categories.create('home', {
id: 'home',
name: 'Home',
color: '#16a34a'
});
mediator.registerHandler('categories.get.v1', async (message) => {
const category = await database.stores.categories.getOneById(message.payload.id);
return {
ok: Boolean(category.result),
result: category.result,
metadata: {
domain: 'Categories',
servedBy: 'categories.get.v1'
}
};
});
await mediator.subscribe('tasks.created', async (event) => {
emittedEvents.push({
title: event.payload.title,
categoryId: event.payload.categoryId
});
});
mediator.registerHandler('tasks.create.v1', async (message) => {
const task = {
...message.payload,
completed: false,
createdAt: Date.now()
};
const lock = await mutex.lock('category', task.categoryId);
if (!lock.result.locked) {
return { ok: false, error: 'category is busy' };
}
try {
await database.stores.tasks.create(task.id, task);
await keyValue.set(`category:${task.categoryId}:lastTask`, task.id);
await mediator.publish({ name: 'tasks.created', payload: task });
return { ok: true, result: task };
} finally {
await mutex.unlock('category', task.categoryId);
}
});
mediator.registerHandler('tasks.board.v1', async (message) => {
const taskList = await database.stores.tasks.getAll(
{ completed: message.payload.completed },
{ page: 1, size: 20 }
);
const cards = await Promise.all(taskList.result.map(async (task) => {
const category = await mediator.request({
contract: 'categories.get.v1',
payload: { id: task.categoryId },
metadata: {
sourceDomain: 'Tasks',
reason: 'compose task board'
}
});
return {
id: task.id,
title: task.title,
completed: task.completed,
category: category.result
? {
id: category.result.id,
name: category.result.name,
color: category.result.color
}
: null
};
}));
return {
ok: true,
result: {
view: 'task-board',
composedBy: ['Tasks', 'Categories'],
cards
}
};
});
const restClient = api.createRestClient((request) => mediator.request({
contract: 'tasks.create.v1',
payload: request.body,
metadata: { transport: 'rest' }
}));
const websocketClient = api.createWebSocketClient((request) => mediator.request({
contract: 'tasks.create.v1',
payload: request.input,
metadata: { transport: 'websocket' }
}));
const firstTask = await restClient.request({
operationId: 'createTask',
method: 'POST',
path: '/tasks',
body: {
id: 'task-1',
title: 'Publish in-memory playgrounds',
categoryId: 'work'
}
});
await websocketClient.connect();
const secondTask = await websocketClient.request({
operationId: 'tasks.create',
input: {
id: 'task-2',
title: 'Review browser contract flow',
categoryId: 'home'
}
});
await websocketClient.disconnect();
const tasks = await database.stores.tasks.getAll({}, { page: 1, size: 10 });
const lastWorkTask = await keyValue.get('category:work:lastTask');
const taskBoard = await mediator.request({
contract: 'tasks.board.v1',
payload: { completed: false },
metadata: { source: 'browser-playground' }
});
return {
designOk: designReport.ok,
taskCount: tasks.total,
createdByRest: firstTask.result.title,
createdByWebSocket: secondTask.result.title,
lastWorkTask: lastWorkTask.result,
composedDomains: taskBoard.result.composedBy,
taskBoard: taskBoard.result.cards,
emittedEvents
};
```const database = api.createInMemoryDatabase({
stores: ['categories', 'tasks']
});
const keyValue = api.createKeyValueStorage();
const mutex = api.createMutex(keyValue);
const mediator = api.createMessageMediator();
const emittedEvents = [];
await database.connect();
await keyValue.connect();
const model = api.createServiceModel({
app: 'service-management',
domain: 'Tasks'
});
const designReport = api.validateDesign(model);
await database.stores.categories.create('work', {
id: 'work',
name: 'Work',
color: '#2563eb'
});
await database.stores.categories.create('home', {
id: 'home',
name: 'Home',
color: '#16a34a'
});
mediator.registerHandler('categories.get.v1', async (message) => {
const category = await database.stores.categories.getOneById(message.payload.id);
return {
ok: Boolean(category.result),
result: category.result,
metadata: {
domain: 'Categories',
servedBy: 'categories.get.v1'
}
};
});
await mediator.subscribe('tasks.created', async (event) => {
emittedEvents.push({
title: event.payload.title,
categoryId: event.payload.categoryId
});
});
mediator.registerHandler('tasks.create.v1', async (message) => {
const task = {
...message.payload,
completed: false,
createdAt: Date.now()
};
const lock = await mutex.lock('category', task.categoryId);
if (!lock.result.locked) {
return { ok: false, error: 'category is busy' };
}
try {
await database.stores.tasks.create(task.id, task);
await keyValue.set(`category:${task.categoryId}:lastTask`, task.id);
await mediator.publish({ name: 'tasks.created', payload: task });
return { ok: true, result: task };
} finally {
await mutex.unlock('category', task.categoryId);
}
});
mediator.registerHandler('tasks.board.v1', async (message) => {
const taskList = await database.stores.tasks.getAll(
{ completed: message.payload.completed },
{ page: 1, size: 20 }
);
const cards = await Promise.all(taskList.result.map(async (task) => {
const category = await mediator.request({
contract: 'categories.get.v1',
payload: { id: task.categoryId },
metadata: {
sourceDomain: 'Tasks',
reason: 'compose task board'
}
});
return {
id: task.id,
title: task.title,
completed: task.completed,
category: category.result
? {
id: category.result.id,
name: category.result.name,
color: category.result.color
}
: null
};
}));
return {
ok: true,
result: {
view: 'task-board',
composedBy: ['Tasks', 'Categories'],
cards
}
};
});
const restClient = api.createRestClient((request) => mediator.request({
contract: 'tasks.create.v1',
payload: request.body,
metadata: { transport: 'rest' }
}));
const websocketClient = api.createWebSocketClient((request) => mediator.request({
contract: 'tasks.create.v1',
payload: request.input,
metadata: { transport: 'websocket' }
}));
const firstTask = await restClient.request({
operationId: 'createTask',
method: 'POST',
path: '/tasks',
body: {
id: 'task-1',
title: 'Publish in-memory playgrounds',
categoryId: 'work'
}
});
await websocketClient.connect();
const secondTask = await websocketClient.request({
operationId: 'tasks.create',
input: {
id: 'task-2',
title: 'Review browser contract flow',
categoryId: 'home'
}
});
await websocketClient.disconnect();
const tasks = await database.stores.tasks.getAll({}, { page: 1, size: 10 });
const lastWorkTask = await keyValue.get('category:work:lastTask');
const taskBoard = await mediator.request({
contract: 'tasks.board.v1',
payload: { completed: false },
metadata: { source: 'browser-playground' }
});
return {
designOk: designReport.ok,
taskCount: tasks.total,
createdByRest: firstTask.result.title,
createdByWebSocket: secondTask.result.title,
lastWorkTask: lastWorkTask.result,
composedDomains: taskBoard.result.composedBy,
taskBoard: taskBoard.result.cards,
emittedEvents
};Escritas concorrentes, locks, replay DLQ e persistência browser em um cenário heavy-data.
Crie muitos registros Task para uma Category, force contenção de lock, envie requests rejeitados pelo controller para uma dead-letter queue e reprocesse tudo pelo fluxo do controller.
### Criação em massa com mutex + DLQ
```ts
const taskSchema = {
version: 1,
stores: [
{
name: 'categories',
keyPath: 'id',
indexes: [{ name: 'byName', keyPath: 'name', unique: true }]
},
{
name: 'tasks',
keyPath: 'id',
indexes: [
{ name: 'byCategory', keyPath: 'categoryId' },
{ name: 'byUpdatedAt', keyPath: 'updatedAt' },
{ name: 'bySource', keyPath: 'source' },
{ name: 'byClient', keyPath: 'clientId' },
{ name: 'byWorker', keyPath: 'workerId' }
]
}
]
};
const database = api.createCanaDatabaseClient({
name: api.createCanaDatabaseName('bulk-mutex-dlq-workers'),
schema: taskSchema,
operationLedger: true
});
const keyValue = api.createKeyValueStorage();
const mutex = api.createMutex(keyValue);
const mediator = api.createMessageMediator();
const deadLetterQueue = api.createDeadLetterQueue({ maxAttempts: 3 });
const replayInbox = [];
const timeline = [];
let totalTimelineEvents = 0;
const canaEvents = [];
const storageUsageSamples = [];
const workerShards = [];
const React = api.React;
const BulkTaskImportContext = React.createContext(null);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const realtimeMetrics = {
attempted: 0,
processed: 0,
rejected: 0,
replayed: 0
};
const criticalTimelineSteps = new Set([
'client-ingestion-stopped',
'controller-replay-batch',
'controller-replay',
'react-component-render'
]);
function publishRealtimeMetrics(phase, request = {}) {
if (typeof reportPlaygroundProgress === 'function') {
reportPlaygroundProgress({
...realtimeMetrics,
taskId: request.taskId,
clientId: request.clientId,
workerId: request.workerId,
phase,
timestamp: Date.now()
});
}
}
function recordTimeline(entry) {
totalTimelineEvents += 1;
if (timeline.length < 320) {
timeline.push(entry);
} else if (criticalTimelineSteps.has(entry.step)) {
timeline.shift();
timeline.push(entry);
}
}
async function recordIndexedDbQuota(label, tasks = 0) {
const estimate = navigator.storage && navigator.storage.estimate
? await navigator.storage.estimate()
: {};
const usage = Number(estimate.usage ?? 0);
const quota = Number(estimate.quota ?? 0);
const percent = quota > 0 ? (usage / quota) * 100 : 0;
storageUsageSamples.push({ label, usage, quota, percent, tasks });
return storageUsageSamples[storageUsageSamples.length - 1];
}
const stopCanaEvents = database.subscribe((event) => {
canaEvents.push({
cursor: event.cursor,
type: event.type,
store: event.store,
key: event.key,
taskId: event.store === 'tasks' && event.record ? event.record.id : undefined,
categoryId: event.record && event.record.categoryId ? event.record.categoryId : event.key,
clientId: event.record && event.record.clientId ? event.record.clientId : undefined,
workerId: event.record && event.record.workerId ? event.record.workerId : undefined,
source: event.record && event.record.source ? event.record.source : 'category-seed'
});
});
function createCanaWorkerShard(id) {
const channel = new MessageChannel();
channel.port1.start?.();
channel.port2.start?.();
const shard = {
id,
status: 'starting',
handledRequests: 0,
events: 0,
database: database.cana.name,
channel,
router: null,
host: null,
client: null
};
shard.router = api.createCanaRouter({
port: channel.port1,
timeoutMs: 5000,
onBroadcast(event) {
shard.events += 1;
canaEvents.push({
cursor: event.cursor,
type: event.type,
store: event.store,
key: event.key,
taskId: event.store === 'tasks' && event.record ? event.record.id : undefined,
categoryId: event.record && event.record.categoryId ? event.record.categoryId : event.key,
clientId: event.record && event.record.clientId ? event.record.clientId : undefined,
workerId: id,
source: event.record && event.record.source ? event.record.source : 'worker-broadcast'
});
}
});
shard.host = api.createCanaWorkerHost({
port: channel.port2,
name: database.cana.name,
schema: taskSchema,
originId: `docs-${id}`,
retainedEvents: 50,
operationLedger: true
});
shard.client = api.createCanaWorkerClient(shard.router);
return shard;
}
function closeWorkerShard(shard) {
return Promise.resolve()
.then(() => shard.client.close())
.catch(() => undefined)
.then(() => {
shard.router.dispose();
shard.channel.port1.close();
shard.channel.port2.close();
return shard.host.dispose();
});
}
function createBulkTaskImportProvider({ actorId, clientId }) {
const state = {
actorId,
clientId,
submittedBatches: 0,
lastBatchSize: 0
};
const contextValue = {
actorId,
clientId,
getState: () => ({ ...state }),
submitBulkImport: async (tasks, options = {}) => {
state.submittedBatches += 1;
state.lastBatchSize = tasks.length;
recordTimeline({
step: 'react-context-submit',
component: 'BulkTaskImportProvider',
actorId,
clientId,
taskCount: tasks.length
});
const responses = await bulkImportController({
body: { tasks },
actorId,
client: clientId,
stopNewRequestsAfterMs: options.stopNewRequestsAfterMs ?? Number.POSITIVE_INFINITY
});
state.accepted = (state.accepted ?? 0) + responses.filter((response) => response.ok).length;
state.rejected = (state.rejected ?? 0) + responses.filter((response) => response.deadLetterId).length;
state.interrupted = (state.interrupted ?? 0) + responses.filter((response) => response.interrupted).length;
recordTimeline({
step: 'react-context-complete',
component: 'BulkTaskImportProvider',
clientId,
accepted: state.accepted,
rejected: state.rejected,
interrupted: state.interrupted
});
return responses;
}
};
return {
Context: BulkTaskImportContext,
value: contextValue
};
}
function BulkImportPanel({ provider, createNextTask, streamConfig }) {
const previewTree = React.createElement(
provider.Context.Provider,
{ value: provider.value },
'BulkImportButton'
);
return {
component: 'BulkImportPanel',
previewElementType: previewTree.type === provider.Context.Provider
? 'BulkTaskImportContext.Provider'
: 'unknown',
startStream: async () => {
recordTimeline({
step: 'react-component-click',
component: 'BulkImportPanel',
clientId: provider.value.clientId,
mode: 'concurrent-30s-stream',
durationMs: streamConfig.durationMs,
maxConcurrentRequests: streamConfig.maxConcurrentRequests
});
const responses = [];
let stopped = false;
let inFlight = 0;
const startedAt = Date.now();
await new Promise((resolve) => {
const launchNext = () => {
if (Date.now() - startedAt >= streamConfig.durationMs) {
if (!stopped) {
stopped = true;
recordTimeline({
step: 'client-ingestion-stopped',
component: 'BulkImportPanel',
clientId: provider.value.clientId,
elapsedMs: Date.now() - startedAt,
reason: '30 second stream window completed'
});
}
if (inFlight === 0) resolve();
return;
}
while (inFlight < streamConfig.maxConcurrentRequests && Date.now() - startedAt < streamConfig.durationMs) {
const task = createNextTask(provider.value.clientId);
inFlight += 1;
provider.value.submitBulkImport([task])
.then((batchResponses) => {
responses.push(...batchResponses);
})
.finally(() => {
inFlight -= 1;
launchNext();
});
}
};
launchNext();
});
recordTimeline({
step: 'react-component-render',
component: 'BulkImportPanel',
clientId: provider.value.clientId,
state: provider.value.getState()
});
return responses;
}
};
}
await database.connect();
await keyValue.connect();
workerShards.push(
createCanaWorkerShard('worker-a'),
createCanaWorkerShard('worker-b'),
createCanaWorkerShard('worker-c')
);
await Promise.all(workerShards.map(async (shard) => {
await shard.client.open();
shard.status = 'ready';
}));
await recordIndexedDbQuota('opened', 0);
await database.stores.categories.add({
id: 'work',
name: 'Work',
color: '#2563eb',
createdAt: Date.now(),
updatedAt: Date.now()
});
await recordIndexedDbQuota('category seeded', 0);
await mediator.subscribe('dead-letter.enqueued', async (event) => {
replayInbox.push(event.payload.recordId);
recordTimeline({
step: 'dead-letter-listener-received',
taskId: event.payload.taskId,
recordId: event.payload.recordId
});
});
await mediator.subscribe('tasks.created', async (event) => {
recordTimeline({
step: 'task-created-event',
taskId: event.payload.id,
source: event.payload.source
});
});
let committedTaskCount = 0;
async function recordWriteQuota(taskId) {
committedTaskCount += 1;
if (committedTaskCount <= 3 || committedTaskCount % 500 === 0) {
await recordIndexedDbQuota(`${taskId}: ${committedTaskCount} tasks`, committedTaskCount);
}
}
function createTaskRecord(input, source) {
return {
id: input.id,
title: input.title,
categoryId: input.categoryId,
completed: false,
clientId: input.clientId,
workerId: input.workerId,
source,
createdAt: new Date().toISOString(),
updatedAt: Date.now()
};
}
async function writeTasksWithCanaWorkers(tasks, source) {
const groups = new Map();
for (const task of tasks) {
const selectedWorker = workerShards.find((shard) => shard.id === task.workerId)
?? workerShards[groups.size % workerShards.length];
const record = {
...task,
workerId: selectedWorker.id,
source
};
const current = groups.get(selectedWorker) ?? [];
current.push(record);
groups.set(selectedWorker, current);
}
const reports = [];
for (const [worker, records] of groups.entries()) {
worker.status = records.length > 1 ? 'bulk-writing' : 'writing';
const write = records.length === 1
? await worker.client.add('tasks', records[0])
: await worker.client.bulkAdd('tasks', records);
worker.handledRequests += records.length;
worker.status = 'ready';
for (const task of records) {
await recordWriteQuota(task.id);
}
const lastTask = records[records.length - 1];
await keyValue.set(`category:${lastTask.categoryId}:lastTask`, lastTask.id);
reports.push({
workerId: worker.id,
count: records.length,
emittedEvents: write.events ? write.events.length : records.length
});
}
return reports;
}
async function createTaskUseCase(input) {
const lock = await mutex.lock('category', input.categoryId);
if (!lock.result.locked) {
const record = await deadLetterQueue.enqueue({
entityName: 'Task',
resourceId: input.categoryId,
operation: 'tasks.create.v1',
payload: input,
actorId: input.requestedBy
});
await mediator.publish({
name: 'dead-letter.enqueued',
payload: {
recordId: record.id,
taskId: input.id,
categoryId: input.categoryId
},
metadata: {
source: 'tasks.create.use-case',
reason: 'category resource is already locked'
}
});
realtimeMetrics.rejected += 1;
publishRealtimeMetrics('rejected', {
taskId: input.id,
clientId: input.clientId,
workerId: input.workerId
});
return {
ok: false,
status: 409,
error: 'category is locked; request queued for replay',
deadLetterId: record.id
};
}
try {
recordTimeline({
step: 'lock-acquired',
taskId: input.id,
categoryId: input.categoryId
});
publishRealtimeMetrics('accepted', {
taskId: input.id,
clientId: input.clientId,
workerId: input.workerId
});
await sleep(input.processingMs);
const task = createTaskRecord(input, input.source);
const [write] = await writeTasksWithCanaWorkers([task], input.source);
recordTimeline({
step: 'cana-task-written',
taskId: task.id,
categoryId: task.categoryId,
clientId: task.clientId,
workerId: write.workerId,
source: task.source,
emittedEvents: write.emittedEvents
});
await mediator.publish({ name: 'tasks.created', payload: task });
realtimeMetrics.processed += 1;
publishRealtimeMetrics('processed', {
taskId: task.id,
clientId: task.clientId,
workerId: write.workerId
});
return { ok: true, status: 201, result: task };
} finally {
await mutex.unlock('category', input.categoryId);
recordTimeline({
step: 'lock-released',
taskId: input.id,
categoryId: input.categoryId
});
}
}
async function createTaskController(request) {
if (!request.replay) {
realtimeMetrics.attempted += 1;
publishRealtimeMetrics('submitted', {
taskId: request.body.id,
clientId: request.body.clientId,
workerId: request.body.workerId
});
}
recordTimeline({
step: request.replay ? 'controller-replay' : 'controller-create',
taskId: request.body.id,
clientId: request.body.clientId,
workerId: request.body.workerId
});
return createTaskUseCase({
...request.body,
requestedBy: request.actorId,
source: request.replay ? 'dead-letter-replay' : 'bulk-import'
});
}
async function bulkImportController(request) {
const startedAt = Date.now();
let stopRecorded = false;
recordTimeline({
step: 'bulk-import-controller',
component: 'BulkImportController',
actorId: request.actorId,
client: request.client,
clientId: request.client,
taskCount: request.body.tasks.length,
stopNewRequestsAfterMs: request.stopNewRequestsAfterMs
});
return Promise.all(request.body.tasks.map(async (task) => {
if (task.clientDelayMs > 0) {
await sleep(task.clientDelayMs);
}
const elapsedMs = Date.now() - startedAt;
if (elapsedMs > request.stopNewRequestsAfterMs) {
if (!stopRecorded) {
stopRecorded = true;
recordTimeline({
step: 'client-ingestion-stopped',
component: 'BulkImportController',
clientId: request.client,
elapsedMs,
reason: 'stop accepting new client requests'
});
}
recordTimeline({
step: 'client-request-interrupted',
component: 'BulkImportController',
taskId: task.id,
clientId: request.client,
workerId: task.workerId,
elapsedMs
});
publishRealtimeMetrics('interrupted', {
taskId: task.id,
clientId: request.client,
workerId: task.workerId
});
return {
ok: false,
status: 202,
interrupted: true,
error: 'client stopped sending new requests before controller admission',
taskId: task.id
};
}
return createTaskController({
body: task,
actorId: request.actorId
});
}));
}
async function replayDeadLettersController() {
const pendingBefore = await deadLetterQueue.pending();
const replayableRecords = pendingBefore.filter((record) => record.operation === 'tasks.create.v1');
const skippedRecords = pendingBefore.filter((record) => record.operation !== 'tasks.create.v1');
recordTimeline({
step: 'controller-replay-batch',
component: 'ReplayDeadLettersController',
taskCount: replayableRecords.length,
mode: 'worker-bulk-add'
});
const replayTasks = replayableRecords.map((record) => createTaskRecord(
{
...record.payload,
processingMs: 0
},
'dead-letter-replay'
));
const bulkReports = await writeTasksWithCanaWorkers(replayTasks, 'dead-letter-replay');
await Promise.all(replayableRecords.map((record) => deadLetterQueue.settle(record.id, 'succeeded')));
realtimeMetrics.replayed += replayableRecords.length;
replayableRecords.slice(-900).forEach((record) => {
publishRealtimeMetrics('replayed', {
taskId: record.payload.id,
clientId: record.payload.clientId,
workerId: record.payload.workerId
});
});
publishRealtimeMetrics('replayed');
replayableRecords.slice(0, 80).forEach((record) => {
recordTimeline({
step: 'controller-replay',
taskId: record.payload.id,
clientId: record.payload.clientId,
workerId: record.payload.workerId
});
});
const report = {
replayed: replayableRecords.map((record) => record.id),
retried: [],
abandoned: [],
skipped: skippedRecords.map((record) => record.id)
};
const records = await deadLetterQueue.list();
return {
ok: true,
status: 200,
pendingBefore: pendingBefore.length,
report,
bulkReports,
records: records.map((record) => ({
id: record.id,
taskId: record.payload.id,
status: record.status,
attempts: record.attempts
}))
};
}
const reactClientIds = ['react-client-a', 'react-client-b', 'react-client-c'];
const streamDurationMs = 30000;
const maxConcurrentRequestsPerClient = 12;
const requestPaceMs = 25;
let globalSequence = 0;
function createNextTask(clientId) {
const sequence = globalSequence;
globalSequence += 1;
return {
id: `task-${sequence + 1}`,
title: `Concurrent Task ${sequence + 1}`,
categoryId: 'work',
clientId,
workerId: workerShards[sequence % workerShards.length].id,
sequence,
processingMs: 12,
clientDelayMs: requestPaceMs
};
}
const reactClients = reactClientIds.map((clientId) => {
const provider = createBulkTaskImportProvider({
actorId: `${clientId}-controller`,
clientId
});
return {
id: clientId,
provider,
panel: BulkImportPanel({
provider,
createNextTask,
streamConfig: {
durationMs: streamDurationMs,
maxConcurrentRequests: maxConcurrentRequestsPerClient
}
})
};
});
const streamStartedAt = Date.now();
const bulkResponseGroups = await Promise.all(
reactClients.map((client) => client.panel.startStream())
);
const actualRunDurationMs = Date.now() - streamStartedAt;
const bulkResponses = bulkResponseGroups.flat();
const pendingAfterBulk = await deadLetterQueue.pending();
const replay = await replayDeadLettersController();
const pendingAfterReplay = await deadLetterQueue.pending();
const taskRows = await database.stores.tasks.query({ index: 'byUpdatedAt' });
const categoryRows = await database.stores.categories.query({ index: 'byName' });
const lastTask = await keyValue.get('category:work:lastTask');
await recordIndexedDbQuota('final', taskRows.length);
const storage = await database.cana.storageState();
const workerShardSummary = workerShards.map((shard) => ({
id: shard.id,
database: shard.database,
status: shard.status,
handledRequests: shard.handledRequests,
events: shard.events
}));
const databaseSnapshot = {
adapter: 'Cana database adapter',
backend: database.cana.backend,
workerMode: 'createWorkerHost + createWorkerClient',
storage,
indexedDbDatabase: database.cana.name,
stores: {
categories: categoryRows.length,
tasks: taskRows.length
},
categoryIds: categoryRows.map((category) => category.id),
taskIds: taskRows.map((task) => task.id)
};
stopCanaEvents();
await Promise.all(workerShards.map(closeWorkerShard));
await database.disconnect();
const createdDuringBulk = bulkResponses.filter((response) => response.ok).length;
const submittedToController = bulkResponses.filter((response) => !response.interrupted).length;
const interruptedBeforeController = bulkResponses.filter((response) => response.interrupted).length;
const rejectedToDeadLetterQueue = bulkResponses.filter((response) => response.deadLetterId).length;
const deadLetterQueueFullyProcessed = pendingAfterReplay.length === 0
&& replay.records.every((record) => record.status === 'succeeded');
const jobsAccountedFor = taskRows.length + interruptedBeforeController;
publishRealtimeMetrics('complete');
return {
databaseAdapter: 'Cana',
databaseBackend: databaseSnapshot.backend,
requestMode: 'concurrent-30s-stream',
streamDurationMs,
actualRunDurationMs,
maxConcurrentRequestsPerClient,
requestPaceMs,
attemptedBulkCount: bulkResponses.length,
createdDuringBulk,
submittedToController,
interruptedBeforeController,
rejectedToDeadLetterQueue,
shutdownReport: {
stopNewRequestsAfterMs: streamDurationMs,
pendingDeadLettersAfterReplay: pendingAfterReplay.length,
deadLetterQueueFullyProcessed,
jobsAccountedFor,
noLostJobs: deadLetterQueueFullyProcessed && jobsAccountedFor === bulkResponses.length
},
pendingBeforeReplay: pendingAfterBulk.map((record) => ({
id: record.id,
taskId: record.payload.id,
resourceId: record.resourceId,
status: record.status
})),
replayInbox,
replayReport: replay.report,
finalTaskCount: taskRows.length,
lastTaskInCategory: lastTask.result,
reactClients: reactClients.map((client) => ({
id: client.id,
taskCount: client.provider.value.getState().accepted
+ client.provider.value.getState().rejected
+ client.provider.value.getState().interrupted,
accepted: client.provider.value.getState().accepted,
rejected: client.provider.value.getState().rejected,
interrupted: client.provider.value.getState().interrupted
})),
workerShards: workerShardSummary,
requestTimeline: timeline,
totalTimelineEvents,
canaEvents: canaEvents.slice(-500),
totalCanaEvents: canaEvents.length,
storageUsageSamples,
databaseSnapshot,
controllerLevelReplay: timeline
.filter((entry) => entry.step.startsWith('controller'))
.slice(0, 40)
.map((entry) => entry.step),
storedTasks: taskRows.slice(0, 20).map((task) => ({
id: task.id,
title: task.title,
source: task.source,
clientId: task.clientId,
workerId: task.workerId
})),
omittedStoredTasks: Math.max(0, taskRows.length - 20),
deadLetterRecords: replay.records
};
```const taskSchema = {
version: 1,
stores: [
{
name: 'categories',
keyPath: 'id',
indexes: [{ name: 'byName', keyPath: 'name', unique: true }]
},
{
name: 'tasks',
keyPath: 'id',
indexes: [
{ name: 'byCategory', keyPath: 'categoryId' },
{ name: 'byUpdatedAt', keyPath: 'updatedAt' },
{ name: 'bySource', keyPath: 'source' },
{ name: 'byClient', keyPath: 'clientId' },
{ name: 'byWorker', keyPath: 'workerId' }
]
}
]
};
const database = api.createCanaDatabaseClient({
name: api.createCanaDatabaseName('bulk-mutex-dlq-workers'),
schema: taskSchema,
operationLedger: true
});
const keyValue = api.createKeyValueStorage();
const mutex = api.createMutex(keyValue);
const mediator = api.createMessageMediator();
const deadLetterQueue = api.createDeadLetterQueue({ maxAttempts: 3 });
const replayInbox = [];
const timeline = [];
let totalTimelineEvents = 0;
const canaEvents = [];
const storageUsageSamples = [];
const workerShards = [];
const React = api.React;
const BulkTaskImportContext = React.createContext(null);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const realtimeMetrics = {
attempted: 0,
processed: 0,
rejected: 0,
replayed: 0
};
const criticalTimelineSteps = new Set([
'client-ingestion-stopped',
'controller-replay-batch',
'controller-replay',
'react-component-render'
]);
function publishRealtimeMetrics(phase, request = {}) {
if (typeof reportPlaygroundProgress === 'function') {
reportPlaygroundProgress({
...realtimeMetrics,
taskId: request.taskId,
clientId: request.clientId,
workerId: request.workerId,
phase,
timestamp: Date.now()
});
}
}
function recordTimeline(entry) {
totalTimelineEvents += 1;
if (timeline.length < 320) {
timeline.push(entry);
} else if (criticalTimelineSteps.has(entry.step)) {
timeline.shift();
timeline.push(entry);
}
}
async function recordIndexedDbQuota(label, tasks = 0) {
const estimate = navigator.storage && navigator.storage.estimate
? await navigator.storage.estimate()
: {};
const usage = Number(estimate.usage ?? 0);
const quota = Number(estimate.quota ?? 0);
const percent = quota > 0 ? (usage / quota) * 100 : 0;
storageUsageSamples.push({ label, usage, quota, percent, tasks });
return storageUsageSamples[storageUsageSamples.length - 1];
}
const stopCanaEvents = database.subscribe((event) => {
canaEvents.push({
cursor: event.cursor,
type: event.type,
store: event.store,
key: event.key,
taskId: event.store === 'tasks' && event.record ? event.record.id : undefined,
categoryId: event.record && event.record.categoryId ? event.record.categoryId : event.key,
clientId: event.record && event.record.clientId ? event.record.clientId : undefined,
workerId: event.record && event.record.workerId ? event.record.workerId : undefined,
source: event.record && event.record.source ? event.record.source : 'category-seed'
});
});
function createCanaWorkerShard(id) {
const channel = new MessageChannel();
channel.port1.start?.();
channel.port2.start?.();
const shard = {
id,
status: 'starting',
handledRequests: 0,
events: 0,
database: database.cana.name,
channel,
router: null,
host: null,
client: null
};
shard.router = api.createCanaRouter({
port: channel.port1,
timeoutMs: 5000,
onBroadcast(event) {
shard.events += 1;
canaEvents.push({
cursor: event.cursor,
type: event.type,
store: event.store,
key: event.key,
taskId: event.store === 'tasks' && event.record ? event.record.id : undefined,
categoryId: event.record && event.record.categoryId ? event.record.categoryId : event.key,
clientId: event.record && event.record.clientId ? event.record.clientId : undefined,
workerId: id,
source: event.record && event.record.source ? event.record.source : 'worker-broadcast'
});
}
});
shard.host = api.createCanaWorkerHost({
port: channel.port2,
name: database.cana.name,
schema: taskSchema,
originId: `docs-${id}`,
retainedEvents: 50,
operationLedger: true
});
shard.client = api.createCanaWorkerClient(shard.router);
return shard;
}
function closeWorkerShard(shard) {
return Promise.resolve()
.then(() => shard.client.close())
.catch(() => undefined)
.then(() => {
shard.router.dispose();
shard.channel.port1.close();
shard.channel.port2.close();
return shard.host.dispose();
});
}
function createBulkTaskImportProvider({ actorId, clientId }) {
const state = {
actorId,
clientId,
submittedBatches: 0,
lastBatchSize: 0
};
const contextValue = {
actorId,
clientId,
getState: () => ({ ...state }),
submitBulkImport: async (tasks, options = {}) => {
state.submittedBatches += 1;
state.lastBatchSize = tasks.length;
recordTimeline({
step: 'react-context-submit',
component: 'BulkTaskImportProvider',
actorId,
clientId,
taskCount: tasks.length
});
const responses = await bulkImportController({
body: { tasks },
actorId,
client: clientId,
stopNewRequestsAfterMs: options.stopNewRequestsAfterMs ?? Number.POSITIVE_INFINITY
});
state.accepted = (state.accepted ?? 0) + responses.filter((response) => response.ok).length;
state.rejected = (state.rejected ?? 0) + responses.filter((response) => response.deadLetterId).length;
state.interrupted = (state.interrupted ?? 0) + responses.filter((response) => response.interrupted).length;
recordTimeline({
step: 'react-context-complete',
component: 'BulkTaskImportProvider',
clientId,
accepted: state.accepted,
rejected: state.rejected,
interrupted: state.interrupted
});
return responses;
}
};
return {
Context: BulkTaskImportContext,
value: contextValue
};
}
function BulkImportPanel({ provider, createNextTask, streamConfig }) {
const previewTree = React.createElement(
provider.Context.Provider,
{ value: provider.value },
'BulkImportButton'
);
return {
component: 'BulkImportPanel',
previewElementType: previewTree.type === provider.Context.Provider
? 'BulkTaskImportContext.Provider'
: 'unknown',
startStream: async () => {
recordTimeline({
step: 'react-component-click',
component: 'BulkImportPanel',
clientId: provider.value.clientId,
mode: 'concurrent-30s-stream',
durationMs: streamConfig.durationMs,
maxConcurrentRequests: streamConfig.maxConcurrentRequests
});
const responses = [];
let stopped = false;
let inFlight = 0;
const startedAt = Date.now();
await new Promise((resolve) => {
const launchNext = () => {
if (Date.now() - startedAt >= streamConfig.durationMs) {
if (!stopped) {
stopped = true;
recordTimeline({
step: 'client-ingestion-stopped',
component: 'BulkImportPanel',
clientId: provider.value.clientId,
elapsedMs: Date.now() - startedAt,
reason: '30 second stream window completed'
});
}
if (inFlight === 0) resolve();
return;
}
while (inFlight < streamConfig.maxConcurrentRequests && Date.now() - startedAt < streamConfig.durationMs) {
const task = createNextTask(provider.value.clientId);
inFlight += 1;
provider.value.submitBulkImport([task])
.then((batchResponses) => {
responses.push(...batchResponses);
})
.finally(() => {
inFlight -= 1;
launchNext();
});
}
};
launchNext();
});
recordTimeline({
step: 'react-component-render',
component: 'BulkImportPanel',
clientId: provider.value.clientId,
state: provider.value.getState()
});
return responses;
}
};
}
await database.connect();
await keyValue.connect();
workerShards.push(
createCanaWorkerShard('worker-a'),
createCanaWorkerShard('worker-b'),
createCanaWorkerShard('worker-c')
);
await Promise.all(workerShards.map(async (shard) => {
await shard.client.open();
shard.status = 'ready';
}));
await recordIndexedDbQuota('opened', 0);
await database.stores.categories.add({
id: 'work',
name: 'Work',
color: '#2563eb',
createdAt: Date.now(),
updatedAt: Date.now()
});
await recordIndexedDbQuota('category seeded', 0);
await mediator.subscribe('dead-letter.enqueued', async (event) => {
replayInbox.push(event.payload.recordId);
recordTimeline({
step: 'dead-letter-listener-received',
taskId: event.payload.taskId,
recordId: event.payload.recordId
});
});
await mediator.subscribe('tasks.created', async (event) => {
recordTimeline({
step: 'task-created-event',
taskId: event.payload.id,
source: event.payload.source
});
});
let committedTaskCount = 0;
async function recordWriteQuota(taskId) {
committedTaskCount += 1;
if (committedTaskCount <= 3 || committedTaskCount % 500 === 0) {
await recordIndexedDbQuota(`${taskId}: ${committedTaskCount} tasks`, committedTaskCount);
}
}
function createTaskRecord(input, source) {
return {
id: input.id,
title: input.title,
categoryId: input.categoryId,
completed: false,
clientId: input.clientId,
workerId: input.workerId,
source,
createdAt: new Date().toISOString(),
updatedAt: Date.now()
};
}
async function writeTasksWithCanaWorkers(tasks, source) {
const groups = new Map();
for (const task of tasks) {
const selectedWorker = workerShards.find((shard) => shard.id === task.workerId)
?? workerShards[groups.size % workerShards.length];
const record = {
...task,
workerId: selectedWorker.id,
source
};
const current = groups.get(selectedWorker) ?? [];
current.push(record);
groups.set(selectedWorker, current);
}
const reports = [];
for (const [worker, records] of groups.entries()) {
worker.status = records.length > 1 ? 'bulk-writing' : 'writing';
const write = records.length === 1
? await worker.client.add('tasks', records[0])
: await worker.client.bulkAdd('tasks', records);
worker.handledRequests += records.length;
worker.status = 'ready';
for (const task of records) {
await recordWriteQuota(task.id);
}
const lastTask = records[records.length - 1];
await keyValue.set(`category:${lastTask.categoryId}:lastTask`, lastTask.id);
reports.push({
workerId: worker.id,
count: records.length,
emittedEvents: write.events ? write.events.length : records.length
});
}
return reports;
}
async function createTaskUseCase(input) {
const lock = await mutex.lock('category', input.categoryId);
if (!lock.result.locked) {
const record = await deadLetterQueue.enqueue({
entityName: 'Task',
resourceId: input.categoryId,
operation: 'tasks.create.v1',
payload: input,
actorId: input.requestedBy
});
await mediator.publish({
name: 'dead-letter.enqueued',
payload: {
recordId: record.id,
taskId: input.id,
categoryId: input.categoryId
},
metadata: {
source: 'tasks.create.use-case',
reason: 'category resource is already locked'
}
});
realtimeMetrics.rejected += 1;
publishRealtimeMetrics('rejected', {
taskId: input.id,
clientId: input.clientId,
workerId: input.workerId
});
return {
ok: false,
status: 409,
error: 'category is locked; request queued for replay',
deadLetterId: record.id
};
}
try {
recordTimeline({
step: 'lock-acquired',
taskId: input.id,
categoryId: input.categoryId
});
publishRealtimeMetrics('accepted', {
taskId: input.id,
clientId: input.clientId,
workerId: input.workerId
});
await sleep(input.processingMs);
const task = createTaskRecord(input, input.source);
const [write] = await writeTasksWithCanaWorkers([task], input.source);
recordTimeline({
step: 'cana-task-written',
taskId: task.id,
categoryId: task.categoryId,
clientId: task.clientId,
workerId: write.workerId,
source: task.source,
emittedEvents: write.emittedEvents
});
await mediator.publish({ name: 'tasks.created', payload: task });
realtimeMetrics.processed += 1;
publishRealtimeMetrics('processed', {
taskId: task.id,
clientId: task.clientId,
workerId: write.workerId
});
return { ok: true, status: 201, result: task };
} finally {
await mutex.unlock('category', input.categoryId);
recordTimeline({
step: 'lock-released',
taskId: input.id,
categoryId: input.categoryId
});
}
}
async function createTaskController(request) {
if (!request.replay) {
realtimeMetrics.attempted += 1;
publishRealtimeMetrics('submitted', {
taskId: request.body.id,
clientId: request.body.clientId,
workerId: request.body.workerId
});
}
recordTimeline({
step: request.replay ? 'controller-replay' : 'controller-create',
taskId: request.body.id,
clientId: request.body.clientId,
workerId: request.body.workerId
});
return createTaskUseCase({
...request.body,
requestedBy: request.actorId,
source: request.replay ? 'dead-letter-replay' : 'bulk-import'
});
}
async function bulkImportController(request) {
const startedAt = Date.now();
let stopRecorded = false;
recordTimeline({
step: 'bulk-import-controller',
component: 'BulkImportController',
actorId: request.actorId,
client: request.client,
clientId: request.client,
taskCount: request.body.tasks.length,
stopNewRequestsAfterMs: request.stopNewRequestsAfterMs
});
return Promise.all(request.body.tasks.map(async (task) => {
if (task.clientDelayMs > 0) {
await sleep(task.clientDelayMs);
}
const elapsedMs = Date.now() - startedAt;
if (elapsedMs > request.stopNewRequestsAfterMs) {
if (!stopRecorded) {
stopRecorded = true;
recordTimeline({
step: 'client-ingestion-stopped',
component: 'BulkImportController',
clientId: request.client,
elapsedMs,
reason: 'stop accepting new client requests'
});
}
recordTimeline({
step: 'client-request-interrupted',
component: 'BulkImportController',
taskId: task.id,
clientId: request.client,
workerId: task.workerId,
elapsedMs
});
publishRealtimeMetrics('interrupted', {
taskId: task.id,
clientId: request.client,
workerId: task.workerId
});
return {
ok: false,
status: 202,
interrupted: true,
error: 'client stopped sending new requests before controller admission',
taskId: task.id
};
}
return createTaskController({
body: task,
actorId: request.actorId
});
}));
}
async function replayDeadLettersController() {
const pendingBefore = await deadLetterQueue.pending();
const replayableRecords = pendingBefore.filter((record) => record.operation === 'tasks.create.v1');
const skippedRecords = pendingBefore.filter((record) => record.operation !== 'tasks.create.v1');
recordTimeline({
step: 'controller-replay-batch',
component: 'ReplayDeadLettersController',
taskCount: replayableRecords.length,
mode: 'worker-bulk-add'
});
const replayTasks = replayableRecords.map((record) => createTaskRecord(
{
...record.payload,
processingMs: 0
},
'dead-letter-replay'
));
const bulkReports = await writeTasksWithCanaWorkers(replayTasks, 'dead-letter-replay');
await Promise.all(replayableRecords.map((record) => deadLetterQueue.settle(record.id, 'succeeded')));
realtimeMetrics.replayed += replayableRecords.length;
replayableRecords.slice(-900).forEach((record) => {
publishRealtimeMetrics('replayed', {
taskId: record.payload.id,
clientId: record.payload.clientId,
workerId: record.payload.workerId
});
});
publishRealtimeMetrics('replayed');
replayableRecords.slice(0, 80).forEach((record) => {
recordTimeline({
step: 'controller-replay',
taskId: record.payload.id,
clientId: record.payload.clientId,
workerId: record.payload.workerId
});
});
const report = {
replayed: replayableRecords.map((record) => record.id),
retried: [],
abandoned: [],
skipped: skippedRecords.map((record) => record.id)
};
const records = await deadLetterQueue.list();
return {
ok: true,
status: 200,
pendingBefore: pendingBefore.length,
report,
bulkReports,
records: records.map((record) => ({
id: record.id,
taskId: record.payload.id,
status: record.status,
attempts: record.attempts
}))
};
}
const reactClientIds = ['react-client-a', 'react-client-b', 'react-client-c'];
const streamDurationMs = 30000;
const maxConcurrentRequestsPerClient = 12;
const requestPaceMs = 25;
let globalSequence = 0;
function createNextTask(clientId) {
const sequence = globalSequence;
globalSequence += 1;
return {
id: `task-${sequence + 1}`,
title: `Concurrent Task ${sequence + 1}`,
categoryId: 'work',
clientId,
workerId: workerShards[sequence % workerShards.length].id,
sequence,
processingMs: 12,
clientDelayMs: requestPaceMs
};
}
const reactClients = reactClientIds.map((clientId) => {
const provider = createBulkTaskImportProvider({
actorId: `${clientId}-controller`,
clientId
});
return {
id: clientId,
provider,
panel: BulkImportPanel({
provider,
createNextTask,
streamConfig: {
durationMs: streamDurationMs,
maxConcurrentRequests: maxConcurrentRequestsPerClient
}
})
};
});
const streamStartedAt = Date.now();
const bulkResponseGroups = await Promise.all(
reactClients.map((client) => client.panel.startStream())
);
const actualRunDurationMs = Date.now() - streamStartedAt;
const bulkResponses = bulkResponseGroups.flat();
const pendingAfterBulk = await deadLetterQueue.pending();
const replay = await replayDeadLettersController();
const pendingAfterReplay = await deadLetterQueue.pending();
const taskRows = await database.stores.tasks.query({ index: 'byUpdatedAt' });
const categoryRows = await database.stores.categories.query({ index: 'byName' });
const lastTask = await keyValue.get('category:work:lastTask');
await recordIndexedDbQuota('final', taskRows.length);
const storage = await database.cana.storageState();
const workerShardSummary = workerShards.map((shard) => ({
id: shard.id,
database: shard.database,
status: shard.status,
handledRequests: shard.handledRequests,
events: shard.events
}));
const databaseSnapshot = {
adapter: 'Cana database adapter',
backend: database.cana.backend,
workerMode: 'createWorkerHost + createWorkerClient',
storage,
indexedDbDatabase: database.cana.name,
stores: {
categories: categoryRows.length,
tasks: taskRows.length
},
categoryIds: categoryRows.map((category) => category.id),
taskIds: taskRows.map((task) => task.id)
};
stopCanaEvents();
await Promise.all(workerShards.map(closeWorkerShard));
await database.disconnect();
const createdDuringBulk = bulkResponses.filter((response) => response.ok).length;
const submittedToController = bulkResponses.filter((response) => !response.interrupted).length;
const interruptedBeforeController = bulkResponses.filter((response) => response.interrupted).length;
const rejectedToDeadLetterQueue = bulkResponses.filter((response) => response.deadLetterId).length;
const deadLetterQueueFullyProcessed = pendingAfterReplay.length === 0
&& replay.records.every((record) => record.status === 'succeeded');
const jobsAccountedFor = taskRows.length + interruptedBeforeController;
publishRealtimeMetrics('complete');
return {
databaseAdapter: 'Cana',
databaseBackend: databaseSnapshot.backend,
requestMode: 'concurrent-30s-stream',
streamDurationMs,
actualRunDurationMs,
maxConcurrentRequestsPerClient,
requestPaceMs,
attemptedBulkCount: bulkResponses.length,
createdDuringBulk,
submittedToController,
interruptedBeforeController,
rejectedToDeadLetterQueue,
shutdownReport: {
stopNewRequestsAfterMs: streamDurationMs,
pendingDeadLettersAfterReplay: pendingAfterReplay.length,
deadLetterQueueFullyProcessed,
jobsAccountedFor,
noLostJobs: deadLetterQueueFullyProcessed && jobsAccountedFor === bulkResponses.length
},
pendingBeforeReplay: pendingAfterBulk.map((record) => ({
id: record.id,
taskId: record.payload.id,
resourceId: record.resourceId,
status: record.status
})),
replayInbox,
replayReport: replay.report,
finalTaskCount: taskRows.length,
lastTaskInCategory: lastTask.result,
reactClients: reactClients.map((client) => ({
id: client.id,
taskCount: client.provider.value.getState().accepted
+ client.provider.value.getState().rejected
+ client.provider.value.getState().interrupted,
accepted: client.provider.value.getState().accepted,
rejected: client.provider.value.getState().rejected,
interrupted: client.provider.value.getState().interrupted
})),
workerShards: workerShardSummary,
requestTimeline: timeline,
totalTimelineEvents,
canaEvents: canaEvents.slice(-500),
totalCanaEvents: canaEvents.length,
storageUsageSamples,
databaseSnapshot,
controllerLevelReplay: timeline
.filter((entry) => entry.step.startsWith('controller'))
.slice(0, 40)
.map((entry) => entry.step),
storedTasks: taskRows.slice(0, 20).map((task) => ({
id: task.id,
title: task.title,
source: task.source,
clientId: task.clientId,
workerId: task.workerId
})),
omittedStoredTasks: Math.max(0, taskRows.length - 20),
deadLetterRecords: replay.records
};Um canvas único acompanha requisições concorrentes por 30 segundos: cada request recente vira um token próprio atravessando cliente React, Context API, controller, mutex, DLQ, Message Mediator, replay, workers do Cana, commits no IndexedDB, eventos de subscribe e retorno ao cliente. Rejeições pelo lock aparecem em vermelho.
0 requests foram recuperadas depois do lock; 0 chegaram ao banco.
Uma fatia completa de produto 100% browser usando Category e Task.
Execute o use-case de criar task contra o adaptador real in-memory e prove as regras 201/400/404.
### MVP REST Dia 1 — use-case no adaptador in-memory
```ts
const database = api.createInMemoryDatabase({ stores: ['categories', 'tasks'] });
await database.connect();
await database.stores.categories.create('work', { id: 'work', name: 'Work' });
async function createTaskUseCase(input) {
if (!input.title || !String(input.title).trim()) {
return { status: 400, body: { error: 'title is required' } };
}
const category = await database.stores.categories.getOneById(input.categoryId);
if (!category.result) {
return { status: 404, body: { error: 'category not found' } };
}
const task = {
id: crypto.randomUUID(),
title: input.title,
categoryId: input.categoryId,
completed: false
};
await database.stores.tasks.create(task.id, task);
return { status: 201, body: task };
}
const created = await createTaskUseCase({ title: 'Ship the MVP', categoryId: 'work' });
const invalid = await createTaskUseCase({ title: ' ', categoryId: 'work' });
const orphan = await createTaskUseCase({ title: 'No owner', categoryId: 'missing' });
const listed = await database.stores.tasks.getAll({}, { page: 1, size: 10 });
return {
created: created.status,
invalid: invalid.status,
unknownCategory: orphan.status,
storedTotal: listed.total,
firstTask: listed.result[0].title
};
```const database = api.createInMemoryDatabase({ stores: ['categories', 'tasks'] });
await database.connect();
await database.stores.categories.create('work', { id: 'work', name: 'Work' });
async function createTaskUseCase(input) {
if (!input.title || !String(input.title).trim()) {
return { status: 400, body: { error: 'title is required' } };
}
const category = await database.stores.categories.getOneById(input.categoryId);
if (!category.result) {
return { status: 404, body: { error: 'category not found' } };
}
const task = {
id: crypto.randomUUID(),
title: input.title,
categoryId: input.categoryId,
completed: false
};
await database.stores.tasks.create(task.id, task);
return { status: 201, body: task };
}
const created = await createTaskUseCase({ title: 'Ship the MVP', categoryId: 'work' });
const invalid = await createTaskUseCase({ title: ' ', categoryId: 'work' });
const orphan = await createTaskUseCase({ title: 'No owner', categoryId: 'missing' });
const listed = await database.stores.tasks.getAll({}, { page: 1, size: 10 });
return {
created: created.status,
invalid: invalid.status,
unknownCategory: orphan.status,
storedTotal: listed.total,
firstTask: listed.result[0].title
};Uma fatia completa de produto 100% browser usando Category e Task.
Roteie operationIds OpenAPI por um client REST para um handler apoiado no adaptador in-memory.
### MVP REST Dia 2 — client tipado sobre o mesmo adaptador
```ts
const database = api.createInMemoryDatabase({ stores: ['categories', 'tasks'] });
await database.connect();
await database.stores.categories.create('work', { id: 'work', name: 'Work' });
const client = api.createRestClient(async (request) => {
if (request.operationId === 'createTask') {
const input = request.body ?? {};
if (!input.title || !String(input.title).trim()) {
return { ok: false, status: 400, error: 'title is required' };
}
const category = await database.stores.categories.getOneById(input.categoryId);
if (!category.result) {
return { ok: false, status: 404, error: 'category not found' };
}
const task = { id: crypto.randomUUID(), ...input, completed: false };
await database.stores.tasks.create(task.id, task);
return { ok: true, status: 201, result: task };
}
if (request.operationId === 'listTasks') {
const tasks = await database.stores.tasks.getAll({}, { page: 1, size: 20 });
return { ok: true, status: 200, result: tasks.result };
}
return { ok: false, status: 404, error: 'unknown operationId' };
});
const created = await client.request({
operationId: 'createTask',
method: 'POST',
path: '/tasks',
body: { title: 'Publish first REST MVP', categoryId: 'work' }
});
const rejected = await client.request({
operationId: 'createTask',
method: 'POST',
path: '/tasks',
body: { title: '', categoryId: 'work' }
});
const listed = await client.request({ operationId: 'listTasks', method: 'GET', path: '/tasks' });
return {
created: created.status,
rejected: rejected.status,
listed: listed.status,
total: listed.result.length,
firstTask: listed.result[0].title
};
```const database = api.createInMemoryDatabase({ stores: ['categories', 'tasks'] });
await database.connect();
await database.stores.categories.create('work', { id: 'work', name: 'Work' });
const client = api.createRestClient(async (request) => {
if (request.operationId === 'createTask') {
const input = request.body ?? {};
if (!input.title || !String(input.title).trim()) {
return { ok: false, status: 400, error: 'title is required' };
}
const category = await database.stores.categories.getOneById(input.categoryId);
if (!category.result) {
return { ok: false, status: 404, error: 'category not found' };
}
const task = { id: crypto.randomUUID(), ...input, completed: false };
await database.stores.tasks.create(task.id, task);
return { ok: true, status: 201, result: task };
}
if (request.operationId === 'listTasks') {
const tasks = await database.stores.tasks.getAll({}, { page: 1, size: 20 });
return { ok: true, status: 200, result: tasks.result };
}
return { ok: false, status: 404, error: 'unknown operationId' };
});
const created = await client.request({
operationId: 'createTask',
method: 'POST',
path: '/tasks',
body: { title: 'Publish first REST MVP', categoryId: 'work' }
});
const rejected = await client.request({
operationId: 'createTask',
method: 'POST',
path: '/tasks',
body: { title: '', categoryId: 'work' }
});
const listed = await client.request({ operationId: 'listTasks', method: 'GET', path: '/tasks' });
return {
created: created.status,
rejected: rejected.status,
listed: listed.status,
total: listed.result.length,
firstTask: listed.result[0].title
};Uma fatia completa de produto 100% browser usando Category e Task.
Ligue um client WebSocket a um handler do mediator que persiste pelo adaptador in-memory e transmite o evento de criação.
### MVP realtime Dia 1 — comando live com ack e broadcast
```ts
const database = api.createInMemoryDatabase({ stores: ['categories', 'tasks'] });
const mediator = api.createMessageMediator();
await database.connect();
await database.stores.categories.create('work', { id: 'work', name: 'Work' });
const liveCards = [];
await mediator.subscribe('tasks.created', async (event) => {
liveCards.push(event.payload.title);
});
mediator.registerHandler('tasks.create.v1', async (message) => {
const category = await database.stores.categories.getOneById(message.payload.categoryId);
if (!category.result) {
return { ok: false, error: 'category not found' };
}
const task = {
id: crypto.randomUUID(),
title: message.payload.title,
categoryId: message.payload.categoryId,
completed: false
};
await database.stores.tasks.create(task.id, task);
await mediator.publish({ name: 'tasks.created', payload: task });
return { ok: true, result: task };
});
const socket = api.createWebSocketClient((request) => mediator.request({
contract: 'tasks.create.v1',
payload: request.input,
metadata: { transport: 'websocket' }
}));
await socket.connect();
const ack = await socket.request({
operationId: 'tasks.create',
input: { title: 'Show realtime status', categoryId: 'work' }
});
const rejected = await socket.request({
operationId: 'tasks.create',
input: { title: 'No owner', categoryId: 'missing' }
});
await socket.disconnect();
return {
ack: ack.ok,
createdTask: ack.result.title,
rejectedError: rejected.error,
broadcastedToUi: liveCards
};
```const database = api.createInMemoryDatabase({ stores: ['categories', 'tasks'] });
const mediator = api.createMessageMediator();
await database.connect();
await database.stores.categories.create('work', { id: 'work', name: 'Work' });
const liveCards = [];
await mediator.subscribe('tasks.created', async (event) => {
liveCards.push(event.payload.title);
});
mediator.registerHandler('tasks.create.v1', async (message) => {
const category = await database.stores.categories.getOneById(message.payload.categoryId);
if (!category.result) {
return { ok: false, error: 'category not found' };
}
const task = {
id: crypto.randomUUID(),
title: message.payload.title,
categoryId: message.payload.categoryId,
completed: false
};
await database.stores.tasks.create(task.id, task);
await mediator.publish({ name: 'tasks.created', payload: task });
return { ok: true, result: task };
});
const socket = api.createWebSocketClient((request) => mediator.request({
contract: 'tasks.create.v1',
payload: request.input,
metadata: { transport: 'websocket' }
}));
await socket.connect();
const ack = await socket.request({
operationId: 'tasks.create',
input: { title: 'Show realtime status', categoryId: 'work' }
});
const rejected = await socket.request({
operationId: 'tasks.create',
input: { title: 'No owner', categoryId: 'missing' }
});
await socket.disconnect();
return {
ack: ack.ok,
createdTask: ack.result.title,
rejectedError: rejected.error,
broadcastedToUi: liveCards
};Uma fatia completa de produto 100% browser usando Category e Task.
Derrube o handler do socket e prove que o fallback REST retorna o mesmo resultado de negócio pelo mesmo adaptador in-memory.
### MVP realtime Dia 2 — drill de paridade do fallback REST
```ts
const database = api.createInMemoryDatabase({ stores: ['categories', 'tasks'] });
await database.connect();
await database.stores.categories.create('work', { id: 'work', name: 'Work' });
async function createTaskUseCase(input) {
const task = {
id: crypto.randomUUID(),
title: input.title,
categoryId: input.categoryId,
completed: false
};
await database.stores.tasks.create(task.id, task);
return { ok: true, result: task };
}
const downSocket = api.createWebSocketClient(async () => {
throw new Error('socket unavailable');
});
const restFallback = api.createRestClient((request) => createTaskUseCase(request.body));
async function createTaskWithFallback(input) {
try {
const live = await downSocket.request({ operationId: 'tasks.create', input });
return { transport: 'websocket', result: live };
} catch {
const fallback = await restFallback.request({
operationId: 'createTask',
method: 'POST',
path: '/tasks',
body: input
});
return { transport: 'rest', result: fallback };
}
}
const response = await createTaskWithFallback({ title: 'Fallback parity task', categoryId: 'work' });
const stored = await database.stores.tasks.getAll({}, { page: 1, size: 10 });
return {
transportUsed: response.transport,
ok: response.result.ok,
storedTotal: stored.total,
storedTitle: response.result.result.title
};
```const database = api.createInMemoryDatabase({ stores: ['categories', 'tasks'] });
await database.connect();
await database.stores.categories.create('work', { id: 'work', name: 'Work' });
async function createTaskUseCase(input) {
const task = {
id: crypto.randomUUID(),
title: input.title,
categoryId: input.categoryId,
completed: false
};
await database.stores.tasks.create(task.id, task);
return { ok: true, result: task };
}
const downSocket = api.createWebSocketClient(async () => {
throw new Error('socket unavailable');
});
const restFallback = api.createRestClient((request) => createTaskUseCase(request.body));
async function createTaskWithFallback(input) {
try {
const live = await downSocket.request({ operationId: 'tasks.create', input });
return { transport: 'websocket', result: live };
} catch {
const fallback = await restFallback.request({
operationId: 'createTask',
method: 'POST',
path: '/tasks',
body: input
});
return { transport: 'rest', result: fallback };
}
}
const response = await createTaskWithFallback({ title: 'Fallback parity task', categoryId: 'work' });
const stored = await database.stores.tasks.getAll({}, { page: 1, size: 10 });
return {
transportUsed: response.transport,
ok: response.result.ok,
storedTotal: stored.total,
storedTitle: response.result.result.title
};Uma fatia completa de produto 100% browser usando Category e Task.
Prove a guarda de tenant: org-1 escreve a própria task, org-2 é negado e o store só guarda o registro legítimo.
### MVP SaaS Dia 1 — policy de tenant no adaptador in-memory
```ts
const database = api.createInMemoryDatabase({ stores: ['categories', 'tasks'] });
await database.connect();
await database.stores.categories.create('work', { id: 'work', name: 'Work' });
async function createTenantTask(context, input) {
if (context.organizationId !== input.organizationId) {
return { ok: false, status: 403, error: 'tenant access denied' };
}
const task = {
id: crypto.randomUUID(),
organizationId: input.organizationId,
title: input.title,
categoryId: input.categoryId
};
await database.stores.tasks.create(task.id, task);
return { ok: true, status: 201, result: task };
}
const own = await createTenantTask(
{ organizationId: 'org-1', userId: 'user-1' },
{ organizationId: 'org-1', title: 'Tenant scoped task', categoryId: 'work' }
);
const denied = await createTenantTask(
{ organizationId: 'org-2', userId: 'user-2' },
{ organizationId: 'org-1', title: 'Cross-tenant write', categoryId: 'work' }
);
const org1Tasks = await database.stores.tasks.getByRelation('organizationId', 'org-1');
const org2Tasks = await database.stores.tasks.getByRelation('organizationId', 'org-2');
return {
ownWrite: own.status,
crossTenantWrite: denied.status,
org1Sees: org1Tasks.result.map((task) => task.title),
org2Sees: org2Tasks.result.length
};
```const database = api.createInMemoryDatabase({ stores: ['categories', 'tasks'] });
await database.connect();
await database.stores.categories.create('work', { id: 'work', name: 'Work' });
async function createTenantTask(context, input) {
if (context.organizationId !== input.organizationId) {
return { ok: false, status: 403, error: 'tenant access denied' };
}
const task = {
id: crypto.randomUUID(),
organizationId: input.organizationId,
title: input.title,
categoryId: input.categoryId
};
await database.stores.tasks.create(task.id, task);
return { ok: true, status: 201, result: task };
}
const own = await createTenantTask(
{ organizationId: 'org-1', userId: 'user-1' },
{ organizationId: 'org-1', title: 'Tenant scoped task', categoryId: 'work' }
);
const denied = await createTenantTask(
{ organizationId: 'org-2', userId: 'user-2' },
{ organizationId: 'org-1', title: 'Cross-tenant write', categoryId: 'work' }
);
const org1Tasks = await database.stores.tasks.getByRelation('organizationId', 'org-1');
const org2Tasks = await database.stores.tasks.getByRelation('organizationId', 'org-2');
return {
ownWrite: own.status,
crossTenantWrite: denied.status,
org1Sees: org1Tasks.result.map((task) => task.title),
org2Sees: org2Tasks.result.length
};Uma fatia completa de produto 100% browser usando Category e Task.
Assine um worker de notificação no mediator e persista cada entrega no próprio store in-memory — sem endpoint HTTP falso.
### MVP microsserviços Dia 2 — worker persistindo notificações reais
```ts
const database = api.createInMemoryDatabase({ stores: ['tasks', 'notifications'] });
const mediator = api.createMessageMediator();
await database.connect();
await mediator.subscribe('tasks.created.v1', async (event) => {
await database.stores.notifications.create(crypto.randomUUID(), {
template: 'task-created',
taskId: event.payload.id,
title: event.payload.title
});
});
mediator.registerHandler('tasks.create.v1', async (message) => {
const task = {
id: crypto.randomUUID(),
title: message.payload.title,
categoryId: message.payload.categoryId,
completed: false
};
await database.stores.tasks.create(task.id, task);
await mediator.publish({ name: 'tasks.created.v1', payload: task });
return { ok: true, result: task };
});
const created = await mediator.request({
contract: 'tasks.create.v1',
payload: { title: 'Notify assignee', categoryId: 'work' }
});
const sent = await database.stores.notifications.getAll({}, { page: 1, size: 10 });
return {
task: created.result.title,
notificationsDelivered: sent.total,
firstNotification: sent.result[0]
};
```const database = api.createInMemoryDatabase({ stores: ['tasks', 'notifications'] });
const mediator = api.createMessageMediator();
await database.connect();
await mediator.subscribe('tasks.created.v1', async (event) => {
await database.stores.notifications.create(crypto.randomUUID(), {
template: 'task-created',
taskId: event.payload.id,
title: event.payload.title
});
});
mediator.registerHandler('tasks.create.v1', async (message) => {
const task = {
id: crypto.randomUUID(),
title: message.payload.title,
categoryId: message.payload.categoryId,
completed: false
};
await database.stores.tasks.create(task.id, task);
await mediator.publish({ name: 'tasks.created.v1', payload: task });
return { ok: true, result: task };
});
const created = await mediator.request({
contract: 'tasks.create.v1',
payload: { title: 'Notify assignee', categoryId: 'work' }
});
const sent = await database.stores.notifications.getAll({}, { page: 1, size: 10 });
return {
task: created.result.title,
notificationsDelivered: sent.total,
firstNotification: sent.result[0]
};Uma fatia completa de produto 100% browser usando Category e Task.
Envie uma mensagem venenosa para a dead-letter queue e reprocesse, provando que o modo de falha é explícito e medido.
### MVP microsserviços Release — falha explícita com replay via DLQ
```ts
const deadLetterQueue = api.createDeadLetterQueue({ maxAttempts: 2 });
const mediator = api.createMessageMediator();
mediator.registerHandler('tasks.create.v1', async (message) => {
if (!message.payload.title) {
const record = await deadLetterQueue.enqueue({
entityName: 'Task',
resourceId: message.payload.categoryId ?? 'unknown',
operation: 'tasks.create.v1',
payload: message.payload
});
return { ok: false, error: 'queued for replay', deadLetterId: record.id };
}
return { ok: true, result: { id: crypto.randomUUID(), ...message.payload } };
});
const failed = await mediator.request({
contract: 'tasks.create.v1',
payload: { categoryId: 'work' }
});
const report = await deadLetterQueue.replay({
'tasks.create.v1': async (record) => {
if (!record.payload.title) throw new Error('title is required');
return record.id;
}
});
const after = await deadLetterQueue.find(failed.deadLetterId);
return {
firstAttempt: failed.error,
replayReport: report,
statusAfterReplay: after.status,
lastError: after.lastError
};
```const deadLetterQueue = api.createDeadLetterQueue({ maxAttempts: 2 });
const mediator = api.createMessageMediator();
mediator.registerHandler('tasks.create.v1', async (message) => {
if (!message.payload.title) {
const record = await deadLetterQueue.enqueue({
entityName: 'Task',
resourceId: message.payload.categoryId ?? 'unknown',
operation: 'tasks.create.v1',
payload: message.payload
});
return { ok: false, error: 'queued for replay', deadLetterId: record.id };
}
return { ok: true, result: { id: crypto.randomUUID(), ...message.payload } };
});
const failed = await mediator.request({
contract: 'tasks.create.v1',
payload: { categoryId: 'work' }
});
const report = await deadLetterQueue.replay({
'tasks.create.v1': async (record) => {
if (!record.payload.title) throw new Error('title is required');
return record.id;
}
});
const after = await deadLetterQueue.find(failed.deadLetterId);
return {
firstAttempt: failed.error,
replayReport: report,
statusAfterReplay: after.status,
lastError: after.lastError
};Estado relacional local, workers, eventos e exemplos de UI com IndexedDB.
Abra um client, crie registros Category e Task, depois leia de volta.
### Primeiros passos
```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();
await client.table('categories').add({
id: 'work',
name: 'Work',
color: '#2563eb',
createdAt: Date.now(),
updatedAt: Date.now()
});
await client.table('tasks').add({
id: 'task-1',
title: 'Write the Cana tutorial',
categoryId: 'work',
completed: false,
priority: 'high',
createdAt: Date.now(),
updatedAt: Date.now()
});
return {
backend: client.backend,
category: await client.table('categories').get('work'),
task: await client.table('tasks').get('task-1')
};
```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();
await client.table('categories').add({
id: 'work',
name: 'Work',
color: '#2563eb',
createdAt: Date.now(),
updatedAt: Date.now()
});
await client.table('tasks').add({
id: 'task-1',
title: 'Write the Cana tutorial',
categoryId: 'work',
completed: false,
priority: 'high',
createdAt: Date.now(),
updatedAt: Date.now()
});
return {
backend: client.backend,
category: await client.table('categories').get('work'),
task: await client.table('tasks').get('task-1')
};Estado relacional local, workers, eventos e exemplos de UI com IndexedDB.
Comece com Category, depois suba a versao e adicione a tabela Task.
### Upgrade de schema
```ts
const v1 = cana.createClient({
name: dbName,
schema: {
version: 1,
stores: [{ name: 'categories', keyPath: 'id' }]
}
});
await v1.open();
await v1.table('categories').add({ id: 'work', name: 'Work' });
await v1.close();
const v2 = 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 v2.open();
await v2.table('tasks').add({
id: 'task-1',
title: 'Created after upgrade',
categoryId: 'work',
completed: false,
priority: 'medium',
createdAt: Date.now(),
updatedAt: Date.now()
});
return {
categories: await v2.table('categories').query(),
tasks: await v2.table('tasks').query()
};
```const v1 = cana.createClient({
name: dbName,
schema: {
version: 1,
stores: [{ name: 'categories', keyPath: 'id' }]
}
});
await v1.open();
await v1.table('categories').add({ id: 'work', name: 'Work' });
await v1.close();
const v2 = 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 v2.open();
await v2.table('tasks').add({
id: 'task-1',
title: 'Created after upgrade',
categoryId: 'work',
completed: false,
priority: 'medium',
createdAt: Date.now(),
updatedAt: Date.now()
});
return {
categories: await v2.table('categories').query(),
tasks: await v2.table('tasks').query()
};Estado relacional local, workers, eventos e exemplos de UI com IndexedDB.
Use ids estaveis nos registros Category e Task.
### Chaves
```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();
await client.table('categories').add({
id: 'docs',
name: 'Docs',
color: '#0f766e',
createdAt: 1,
updatedAt: 1
});
await client.table('tasks').add({
id: 'docs-1',
title: 'Document stable keys',
categoryId: 'docs',
completed: false,
priority: 'medium',
createdAt: 2,
updatedAt: 2
});
return {
categoryKey: 'docs',
taskKey: 'docs-1',
task: await client.table('tasks').get('docs-1')
};
```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();
await client.table('categories').add({
id: 'docs',
name: 'Docs',
color: '#0f766e',
createdAt: 1,
updatedAt: 1
});
await client.table('tasks').add({
id: 'docs-1',
title: 'Document stable keys',
categoryId: 'docs',
completed: false,
priority: 'medium',
createdAt: 2,
updatedAt: 2
});
return {
categoryKey: 'docs',
taskKey: 'docs-1',
task: await client.table('tasks').get('docs-1')
};Estado relacional local, workers, eventos e exemplos de UI com IndexedDB.
Crie, leia, atualize e remova uma Task.
### CRUD
```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();
await client.table('categories').add({ id: 'work', name: 'Work', color: '#2563eb' });
const tasks = client.table('tasks');
await tasks.add({
id: 'task-1',
title: 'Draft the tutorial',
categoryId: 'work',
completed: false,
priority: 'high',
createdAt: 1,
updatedAt: 1
});
await tasks.update('task-1', { completed: true, updatedAt: 2 });
const afterUpdate = await tasks.get('task-1');
await tasks.delete('task-1');
return { afterUpdate, afterDelete: await tasks.get('task-1') };
```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();
await client.table('categories').add({ id: 'work', name: 'Work', color: '#2563eb' });
const tasks = client.table('tasks');
await tasks.add({
id: 'task-1',
title: 'Draft the tutorial',
categoryId: 'work',
completed: false,
priority: 'high',
createdAt: 1,
updatedAt: 1
});
await tasks.update('task-1', { completed: true, updatedAt: 2 });
const afterUpdate = await tasks.get('task-1');
await tasks.delete('task-1');
return { afterUpdate, afterDelete: await tasks.get('task-1') };Estado relacional local, workers, eventos e exemplos de UI com IndexedDB.
Popule Category e Task com operacoes em lote.
### Operacoes em lote
```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 now = Date.now();
await client.table('categories').bulkAdd([
{ id: 'work', name: 'Work', color: '#2563eb', createdAt: now, updatedAt: now },
{ id: 'home', name: 'Home', color: '#16a34a', createdAt: now, updatedAt: now }
]);
await client.table('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 + 1
}
]);
const put = await client.table('tasks').bulkPut([
{
id: 'task-2',
title: 'Review category filters',
categoryId: 'home',
completed: false,
priority: 'high',
createdAt: Date.now(),
updatedAt: Date.now()
},
{
id: 'task-3',
title: 'Publish the example app',
categoryId: 'work',
completed: false,
priority: 'medium',
createdAt: Date.now(),
updatedAt: Date.now()
}
]);
return {
put,
categories: await client.table('categories').query({ index: 'byName' }),
tasks: await client.table('tasks').query({ index: 'byUpdatedAt' })
};
```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 now = Date.now();
await client.table('categories').bulkAdd([
{ id: 'work', name: 'Work', color: '#2563eb', createdAt: now, updatedAt: now },
{ id: 'home', name: 'Home', color: '#16a34a', createdAt: now, updatedAt: now }
]);
await client.table('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 + 1
}
]);
const put = await client.table('tasks').bulkPut([
{
id: 'task-2',
title: 'Review category filters',
categoryId: 'home',
completed: false,
priority: 'high',
createdAt: Date.now(),
updatedAt: Date.now()
},
{
id: 'task-3',
title: 'Publish the example app',
categoryId: 'work',
completed: false,
priority: 'medium',
createdAt: Date.now(),
updatedAt: Date.now()
}
]);
return {
put,
categories: await client.table('categories').query({ index: 'byName' }),
tasks: await client.table('tasks').query({ index: 'byUpdatedAt' })
};Estado relacional local, workers, eventos e exemplos de UI com IndexedDB.
Rode uma query indexada de Task por Category e inspecione o plano.
### Query + explain
```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 now = Date.now();
await client.table('categories').bulkAdd([
{ id: 'work', name: 'Work', color: '#2563eb', createdAt: now, updatedAt: now },
{ id: 'home', name: 'Home', color: '#16a34a', createdAt: now, updatedAt: now }
]);
await client.table('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 + 1
}
]);
const { records, plan } = await client.table('tasks').explain({
index: 'byCategory',
equals: 'work'
});
return { records, plan };
```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 now = Date.now();
await client.table('categories').bulkAdd([
{ id: 'work', name: 'Work', color: '#2563eb', createdAt: now, updatedAt: now },
{ id: 'home', name: 'Home', color: '#16a34a', createdAt: now, updatedAt: now }
]);
await client.table('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 + 1
}
]);
const { records, plan } = await client.table('tasks').explain({
index: 'byCategory',
equals: 'work'
});
return { records, plan };Estado relacional local, workers, eventos e exemplos de UI com IndexedDB.
Crie uma Category e sua primeira Task em um unico commit.
### Transacoes
```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()
};Estado relacional local, workers, eventos e exemplos de UI com IndexedDB.
Assine e colete eventos confirmados de Task.
### Eventos de mudanca
```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 };Estado relacional local, workers, eventos e exemplos de UI com IndexedDB.
Rode hooks beforeWrite e afterCommit ao redor de escritas de Task.
### Hooks
```ts
const trail = [];
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' }
]
}
]
},
hooks: {
beforeWrite: (ctx) => { trail.push('before:' + ctx.store + ':' + ctx.type); },
afterCommit: (events) => { trail.push('commit:' + events.length); }
}
});
await client.open();
await client.table('categories').add({ id: 'docs', name: 'Docs', color: '#0f766e' });
await client.table('tasks').add({
id: 'docs-1',
title: 'Passes through hooks',
categoryId: 'docs',
completed: false,
priority: 'medium',
createdAt: 1,
updatedAt: 1
});
return { trail, task: await client.table('tasks').get('docs-1') };
```const trail = [];
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' }
]
}
]
},
hooks: {
beforeWrite: (ctx) => { trail.push('before:' + ctx.store + ':' + ctx.type); },
afterCommit: (events) => { trail.push('commit:' + events.length); }
}
});
await client.open();
await client.table('categories').add({ id: 'docs', name: 'Docs', color: '#0f766e' });
await client.table('tasks').add({
id: 'docs-1',
title: 'Passes through hooks',
categoryId: 'docs',
completed: false,
priority: 'medium',
createdAt: 1,
updatedAt: 1
});
return { trail, task: await client.table('tasks').get('docs-1') };Estado relacional local, workers, eventos e exemplos de UI com IndexedDB.
Detecte ids duplicados de Category com isCanaErrorCode.
### Erros
```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();
await client.table('categories').add({ id: 'docs', name: 'Docs', color: '#0f766e' });
try {
await client.table('categories').add({ id: 'docs', name: 'Duplicada', color: '#dc2626' });
return { unexpected: 'no error' };
} catch (error) {
return {
isCanaError: cana.isCanaError(error),
constraint: cana.isCanaErrorCode(error, 'ConstraintViolation'),
code: error && error.code
};
}
```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();
await client.table('categories').add({ id: 'docs', name: 'Docs', color: '#0f766e' });
try {
await client.table('categories').add({ id: 'docs', name: 'Duplicada', color: '#dc2626' });
return { unexpected: 'no error' };
} catch (error) {
return {
isCanaError: cana.isCanaError(error),
constraint: cana.isCanaErrorCode(error, 'ConstraintViolation'),
code: error && error.code
};
}Estado relacional local, workers, eventos e exemplos de UI com IndexedDB.
Leia storageState e durabilityAssessment depois de gravar Task.
### Avaliacao de storage
```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();
await client.table('categories').add({ id: 'docs', name: 'Docs', color: '#0f766e' });
await client.table('tasks').add({
id: 'docs-1',
title: 'Durable data',
categoryId: 'docs',
completed: false,
priority: 'high',
createdAt: 1,
updatedAt: 1
});
const storage = await client.storageState();
const durability = await client.durabilityAssessment();
return { backend: client.backend, storage, durability };
```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();
await client.table('categories').add({ id: 'docs', name: 'Docs', color: '#0f766e' });
await client.table('tasks').add({
id: 'docs-1',
title: 'Durable data',
categoryId: 'docs',
completed: false,
priority: 'high',
createdAt: 1,
updatedAt: 1
});
const storage = await client.storageState();
const durability = await client.durabilityAssessment();
return { backend: client.backend, storage, durability };Estado relacional local, workers, eventos e exemplos de UI com IndexedDB.
Resolva uma escrita de Task commitada com operation ledger ligado.
### Operation ledger
```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' }
]
}
]
},
operationLedger: true
});
await client.open();
const tx = await client.transaction('readwrite', ['categories', 'tasks'], async (scope) => {
const now = Date.now();
await scope.table('categories').put({ id: 'docs', name: 'Docs', color: '#0f766e' });
await scope.table('tasks').put({
id: 'docs-1',
title: 'Reconcile uncertain write',
categoryId: 'docs',
completed: false,
priority: 'high',
createdAt: now,
updatedAt: now
});
return 'wrote';
});
const resolved = await client.resolveWrite(tx.correlationId, tx.attemptedAt);
return { outcome: tx.outcome, resolved, task: await client.table('tasks').get('docs-1') };
```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' }
]
}
]
},
operationLedger: true
});
await client.open();
const tx = await client.transaction('readwrite', ['categories', 'tasks'], async (scope) => {
const now = Date.now();
await scope.table('categories').put({ id: 'docs', name: 'Docs', color: '#0f766e' });
await scope.table('tasks').put({
id: 'docs-1',
title: 'Reconcile uncertain write',
categoryId: 'docs',
completed: false,
priority: 'high',
createdAt: now,
updatedAt: now
});
return 'wrote';
});
const resolved = await client.resolveWrite(tx.correlationId, tx.attemptedAt);
return { outcome: tx.outcome, resolved, task: await client.table('tasks').get('docs-1') };Estado relacional local, workers, eventos e exemplos de UI com IndexedDB.
Exporte as stores Category e Task como dados puros.
### Export
```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 now = Date.now();
await client.table('categories').bulkAdd([
{ id: 'work', name: 'Work', color: '#2563eb', createdAt: now, updatedAt: now },
{ id: 'home', name: 'Home', color: '#16a34a', createdAt: now, updatedAt: now }
]);
await client.table('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 + 1
}
]);
const dump = await client.exportAll();
return dump;
```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 now = Date.now();
await client.table('categories').bulkAdd([
{ id: 'work', name: 'Work', color: '#2563eb', createdAt: now, updatedAt: now },
{ id: 'home', name: 'Home', color: '#16a34a', createdAt: now, updatedAt: now }
]);
await client.table('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 + 1
}
]);
const dump = await client.exportAll();
return dump;Estado relacional local, workers, eventos e exemplos de UI com IndexedDB.
Mostre client.backend apos abrir o banco de tasks.
### Selecao de backend
```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' }
]
}
]
},
fallback: 'localStorage'
});
await client.open();
await client.table('categories').add({ id: 'docs', name: 'Docs', color: '#0f766e' });
return { backend: client.backend, categories: await client.table('categories').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' }
]
}
]
},
fallback: 'localStorage'
});
await client.open();
await client.table('categories').add({ id: 'docs', name: 'Docs', color: '#0f766e' });
return { backend: client.backend, categories: await client.table('categories').query() };Estado relacional local, workers, eventos e exemplos de UI com IndexedDB.
Use createCanaDatabaseClient com stores Category e Task.
### Adapter de factory
```ts
const adapter = cana.createCanaDatabaseClient({
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 adapter.connect();
await adapter.stores.categories.add({ id: 'docs', name: 'Docs', color: '#0f766e' });
await adapter.stores.tasks.add({
id: 'docs-1',
title: 'Created through the adapter',
categoryId: 'docs',
completed: false,
priority: 'medium',
createdAt: 1,
updatedAt: 1
});
const task = await adapter.stores.tasks.get('docs-1');
await adapter.disconnect();
return {
backend: adapter.cana.backend,
stores: Object.keys(adapter.stores),
task
};
```const adapter = cana.createCanaDatabaseClient({
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 adapter.connect();
await adapter.stores.categories.add({ id: 'docs', name: 'Docs', color: '#0f766e' });
await adapter.stores.tasks.add({
id: 'docs-1',
title: 'Created through the adapter',
categoryId: 'docs',
completed: false,
priority: 'medium',
createdAt: 1,
updatedAt: 1
});
const task = await adapter.stores.tasks.get('docs-1');
await adapter.disconnect();
return {
backend: adapter.cana.backend,
stores: Object.keys(adapter.stores),
task
};Estado relacional local, workers, eventos e exemplos de UI com IndexedDB.
Use createWorkerHost, createRouter e createWorkerClient com MessageChannel.
### Fluxo com worker client
```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();
}Estado relacional local, workers, eventos e exemplos de UI com IndexedDB.
Abra um client Cana IndexedDB real, semeie Category e crie a primeira Task — o fluxo inteiro roda no browser.
### MVP SPA Dia 1 — registros offline no Cana
```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 now = Date.now();
await client.table('categories').add({
id: 'work',
name: 'Work',
color: '#2563eb',
createdAt: now,
updatedAt: now
});
await client.table('tasks').add({
id: 'task-1',
title: 'Offline task',
categoryId: 'work',
completed: false,
priority: 'high',
createdAt: now,
updatedAt: now
});
return {
backend: client.backend,
category: await client.table('categories').get('work'),
task: await client.table('tasks').get('task-1')
};
```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 now = Date.now();
await client.table('categories').add({
id: 'work',
name: 'Work',
color: '#2563eb',
createdAt: now,
updatedAt: now
});
await client.table('tasks').add({
id: 'task-1',
title: 'Offline task',
categoryId: 'work',
completed: false,
priority: 'high',
createdAt: now,
updatedAt: now
});
return {
backend: client.backend,
category: await client.table('categories').get('work'),
task: await client.table('tasks').get('task-1')
};Estado relacional local, workers, eventos e exemplos de UI com IndexedDB.
Assine eventos de mudança confirmados, escreva e atualize uma Task, depois leia de volta pelo índice byCategory.
### MVP SPA Dia 2 — estado da UI via eventos do Cana e queries indexadas
```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({ type: event.type, store: event.store, key: event.key });
});
const now = Date.now();
await client.table('categories').add({
id: 'work',
name: 'Work',
color: '#2563eb',
createdAt: now,
updatedAt: now
});
await client.table('tasks').add({
id: 'task-1',
title: 'Listen to local changes',
categoryId: 'work',
completed: false,
priority: 'medium',
createdAt: now,
updatedAt: now
});
await client.table('tasks').update('task-1', { completed: true, updatedAt: now + 1 });
stop();
const workTasks = await client.table('tasks').query({
index: 'byCategory',
equals: 'work'
});
return {
events: seen,
workTasks: workTasks.map((task) => ({ title: task.title, completed: task.completed }))
};
```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({ type: event.type, store: event.store, key: event.key });
});
const now = Date.now();
await client.table('categories').add({
id: 'work',
name: 'Work',
color: '#2563eb',
createdAt: now,
updatedAt: now
});
await client.table('tasks').add({
id: 'task-1',
title: 'Listen to local changes',
categoryId: 'work',
completed: false,
priority: 'medium',
createdAt: now,
updatedAt: now
});
await client.table('tasks').update('task-1', { completed: true, updatedAt: now + 1 });
stop();
const workTasks = await client.table('tasks').query({
index: 'byCategory',
equals: 'work'
});
return {
events: seen,
workTasks: workTasks.map((task) => ({ title: task.title, completed: task.completed }))
};Estado relacional local, workers, eventos e exemplos de UI com IndexedDB.
Feche o client e reabra o mesmo banco: os registros offline sobrevivem, provando estado local durável.
### MVP SPA Release — durabilidade ao reabrir
```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 now = Date.now();
await client.table('categories').add({
id: 'work',
name: 'Work',
color: '#2563eb',
createdAt: now,
updatedAt: now
});
await client.table('tasks').add({
id: 'task-1',
title: 'Survives reload',
categoryId: 'work',
completed: false,
priority: 'high',
createdAt: now,
updatedAt: now
});
await client.close();
const reopened = 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 reopened.open();
const tasksAfterReopen = await reopened.table('tasks').query();
const categoriesAfterReopen = await reopened.table('categories').query();
return {
backend: reopened.backend,
categoriesAfterReopen: categoriesAfterReopen.length,
tasksAfterReopen: tasksAfterReopen.map((task) => task.title)
};
```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 now = Date.now();
await client.table('categories').add({
id: 'work',
name: 'Work',
color: '#2563eb',
createdAt: now,
updatedAt: now
});
await client.table('tasks').add({
id: 'task-1',
title: 'Survives reload',
categoryId: 'work',
completed: false,
priority: 'high',
createdAt: now,
updatedAt: now
});
await client.close();
const reopened = 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 reopened.open();
const tasksAfterReopen = await reopened.table('tasks').query();
const categoriesAfterReopen = await reopened.table('categories').query();
return {
backend: reopened.backend,
categoriesAfterReopen: categoriesAfterReopen.length,
tasksAfterReopen: tasksAfterReopen.map((task) => task.title)
};Composição request/response e eventos entre módulos de domínio independentes.
Troque mensagens entre os domínios Category e Task e componha um read model via request/response do mediator.
### Mediator em memória
```ts
const domainMessages = [];
const events = [];
const mediator = api.createInMemory();
const categories = new Map([
['work', { id: 'work', name: 'Work', color: '#2563eb' }],
['home', { id: 'home', name: 'Home', color: '#16a34a' }]
]);
const tasks = [];
await mediator.subscribe('tasks.created', async (event) => {
events.push({
title: event.payload.title,
categoryId: event.payload.categoryId
});
});
mediator.registerHandler('categories.get.v1', async (message) => {
domainMessages.push({
from: message.metadata.sourceDomain,
to: 'Categories',
contract: 'categories.get.v1',
categoryId: message.payload.id
});
return {
ok: true,
result: categories.get(message.payload.id) ?? null
};
});
mediator.registerHandler('tasks.create.v1', async (message) => {
const task = {
id: message.payload.id,
title: message.payload.title,
categoryId: message.payload.categoryId,
completed: false
};
tasks.push(task);
await mediator.publish({ name: 'tasks.created', payload: task });
return { ok: true, result: task };
});
mediator.registerHandler('tasks.board.v1', async () => {
const cards = await Promise.all(tasks.map(async (task) => {
const category = await mediator.request({
contract: 'categories.get.v1',
payload: { id: task.categoryId },
metadata: {
sourceDomain: 'Tasks',
reason: 'compose task board read model'
}
});
return {
id: task.id,
title: task.title,
categoryName: category.result?.name ?? 'Uncategorized',
categoryColor: category.result?.color ?? '#64748b'
};
}));
return {
ok: true,
result: {
readModel: 'TaskBoard',
composedFrom: ['Tasks', 'Categories'],
cards
}
};
});
const response = await mediator.request({
contract: 'tasks.create.v1',
payload: {
id: 'task-1',
title: 'Wire mediator events',
categoryId: 'work'
},
metadata: { source: 'browser-playground' }
});
const board = await mediator.request({
contract: 'tasks.board.v1',
payload: {},
metadata: { source: 'task-board-page' }
});
return {
createdTask: response.result,
board: board.result,
domainMessages,
events
};
```const domainMessages = [];
const events = [];
const mediator = api.createInMemory();
const categories = new Map([
['work', { id: 'work', name: 'Work', color: '#2563eb' }],
['home', { id: 'home', name: 'Home', color: '#16a34a' }]
]);
const tasks = [];
await mediator.subscribe('tasks.created', async (event) => {
events.push({
title: event.payload.title,
categoryId: event.payload.categoryId
});
});
mediator.registerHandler('categories.get.v1', async (message) => {
domainMessages.push({
from: message.metadata.sourceDomain,
to: 'Categories',
contract: 'categories.get.v1',
categoryId: message.payload.id
});
return {
ok: true,
result: categories.get(message.payload.id) ?? null
};
});
mediator.registerHandler('tasks.create.v1', async (message) => {
const task = {
id: message.payload.id,
title: message.payload.title,
categoryId: message.payload.categoryId,
completed: false
};
tasks.push(task);
await mediator.publish({ name: 'tasks.created', payload: task });
return { ok: true, result: task };
});
mediator.registerHandler('tasks.board.v1', async () => {
const cards = await Promise.all(tasks.map(async (task) => {
const category = await mediator.request({
contract: 'categories.get.v1',
payload: { id: task.categoryId },
metadata: {
sourceDomain: 'Tasks',
reason: 'compose task board read model'
}
});
return {
id: task.id,
title: task.title,
categoryName: category.result?.name ?? 'Uncategorized',
categoryColor: category.result?.color ?? '#64748b'
};
}));
return {
ok: true,
result: {
readModel: 'TaskBoard',
composedFrom: ['Tasks', 'Categories'],
cards
}
};
});
const response = await mediator.request({
contract: 'tasks.create.v1',
payload: {
id: 'task-1',
title: 'Wire mediator events',
categoryId: 'work'
},
metadata: { source: 'browser-playground' }
});
const board = await mediator.request({
contract: 'tasks.board.v1',
payload: {},
metadata: { source: 'task-board-page' }
});
return {
createdTask: response.result,
board: board.result,
domainMessages,
events
};Composição request/response e eventos entre módulos de domínio independentes.
Prove request/response e publish/listen no mediator in-memory real, incluindo o erro explícito para contrato desconhecido.
### MVP microsserviços Dia 1 — contrato sobre o mediator in-memory
```ts
const mediator = api.createInMemory();
const receivedEvents = [];
await mediator.subscribe('tasks.created.v1', async (event) => {
receivedEvents.push(event.payload.title);
});
mediator.registerHandler('tasks.create.v1', async (message) => {
const task = {
id: crypto.randomUUID(),
title: message.payload.title,
categoryId: message.payload.categoryId,
completed: false
};
await mediator.publish({ name: 'tasks.created.v1', payload: task });
return { ok: true, result: task };
});
const created = await mediator.request({
contract: 'tasks.create.v1',
payload: { title: 'Notify assignee', categoryId: 'work' }
});
const unknown = await mediator.request({
contract: 'tasks.unknown.v1',
payload: {}
});
return {
createdTask: created.result.title,
eventDelivered: receivedEvents,
unknownContractError: unknown.error
};
```const mediator = api.createInMemory();
const receivedEvents = [];
await mediator.subscribe('tasks.created.v1', async (event) => {
receivedEvents.push(event.payload.title);
});
mediator.registerHandler('tasks.create.v1', async (message) => {
const task = {
id: crypto.randomUUID(),
title: message.payload.title,
categoryId: message.payload.categoryId,
completed: false
};
await mediator.publish({ name: 'tasks.created.v1', payload: task });
return { ok: true, result: task };
});
const created = await mediator.request({
contract: 'tasks.create.v1',
payload: { title: 'Notify assignee', categoryId: 'work' }
});
const unknown = await mediator.request({
contract: 'tasks.unknown.v1',
payload: {}
});
return {
createdTask: created.result.title,
eventDelivered: receivedEvents,
unknownContractError: unknown.error
};Pequenos registros de estado com o mesmo formato contratual usado por adaptadores substituíveis.
Guarde preferências da lista de Task com o mesmo formato de resposta usado pelos adaptadores do pacote.
### Chave/valor em memória
```ts
const client = api.createInMemory();
await client.connect();
await client.set('ui:selected-category', {
id: 'work',
name: 'Work',
visibleTaskIds: ['task-1', 'task-3']
});
await client.set('ui:last-sort', 'priority-desc');
const selectedCategory = await client.get('ui:selected-category');
const lastSort = await client.get('ui:last-sort');
await client.del('ui:last-sort');
const deletedSort = await client.get('ui:last-sort');
await client.disconnect();
return {
selectedCategory: selectedCategory.result,
lastSort: lastSort.result,
deletedSort: deletedSort.result
};
```const client = api.createInMemory();
await client.connect();
await client.set('ui:selected-category', {
id: 'work',
name: 'Work',
visibleTaskIds: ['task-1', 'task-3']
});
await client.set('ui:last-sort', 'priority-desc');
const selectedCategory = await client.get('ui:selected-category');
const lastSort = await client.get('ui:last-sort');
await client.del('ui:last-sort');
const deletedSort = await client.get('ui:last-sort');
await client.disconnect();
return {
selectedCategory: selectedCategory.result,
lastSort: lastSort.result,
deletedSort: deletedSort.result
};Locks por Category em volta dos fluxos de Task.
Proteja uma atualização de Category enquanto dois escritores de Task competem pelo mesmo recurso.
### Mutex com KV em memória
```ts
const keyValue = api.createKeyValueStorage();
const mutex = api.create(keyValue);
const firstWriter = await mutex.lock('category', 'work');
const secondWriter = await mutex.lock('category', 'work');
const lockedBeforeRelease = await mutex.isLocked('category', 'work');
await mutex.unlock('category', 'work');
const lockedAfterRelease = await mutex.isLocked('category', 'work');
return {
firstWriter: firstWriter.result,
secondWriter: secondWriter.result,
lockedBeforeRelease: lockedBeforeRelease.result,
lockedAfterRelease: lockedAfterRelease.result
};
```const keyValue = api.createKeyValueStorage();
const mutex = api.create(keyValue);
const firstWriter = await mutex.lock('category', 'work');
const secondWriter = await mutex.lock('category', 'work');
const lockedBeforeRelease = await mutex.isLocked('category', 'work');
await mutex.unlock('category', 'work');
const lockedAfterRelease = await mutex.isLocked('category', 'work');
return {
firstWriter: firstWriter.result,
secondWriter: secondWriter.result,
lockedBeforeRelease: lockedBeforeRelease.result,
lockedAfterRelease: lockedAfterRelease.result
};Um consumidor client-side chamando o mesmo comportamento de Task por uma superfície de API estável.
Chame operações OpenAPI de Task com um cliente mock seguro para browser.
### Cliente REST com fetch mock
```ts
const client = api.createMockClient();
const created = await client.request({
operationId: 'createTask',
method: 'POST',
path: '/tasks',
body: {
id: 'task-1',
title: 'Generate REST SDK example',
categoryId: 'work',
completed: false
}
});
const listed = await client.request({
operationId: 'listTasks',
method: 'GET',
path: '/tasks?categoryId=work'
});
return {
created,
listed
};
```const client = api.createMockClient();
const created = await client.request({
operationId: 'createTask',
method: 'POST',
path: '/tasks',
body: {
id: 'task-1',
title: 'Generate REST SDK example',
categoryId: 'work',
completed: false
}
});
const listed = await client.request({
operationId: 'listTasks',
method: 'GET',
path: '/tasks?categoryId=work'
});
return {
created,
listed
};Um consumidor client-side chamando o mesmo comportamento de Task por uma superfície de API estável.
Use o contrato do cliente realtime para criar e listar registros Task no browser.
### Cliente WS com socket fake
```ts
const client = api.createFakeClient();
const status = await client.connect();
const created = await client.request({
operationId: 'tasks.create',
input: {
id: 'task-1',
title: 'Render realtime updates',
categoryId: 'home',
completed: false
}
});
const listed = await client.request({
operationId: 'tasks.list',
input: { categoryId: 'home' }
});
const closed = await client.disconnect();
return {
status,
created,
listed,
closed
};
```const client = api.createFakeClient();
const status = await client.connect();
const created = await client.request({
operationId: 'tasks.create',
input: {
id: 'task-1',
title: 'Render realtime updates',
categoryId: 'home',
completed: false
}
});
const listed = await client.request({
operationId: 'tasks.list',
input: { categoryId: 'home' }
});
const closed = await client.disconnect();
return {
status,
created,
listed,
closed
};Formato do domínio, entidades e relações antes do código runtime.
Normalize o modelo de exemplo e colete issues de validação (API real do designer-core).
### Validar um design
```ts
const raw = api.buildSampleModelPayload();
const state = api.normalizeStatePayload(raw);
const issues = api.collectModelIssues(state);
const errors = issues.filter((issue) => issue.severity === 'error');
return {
ok: errors.length === 0,
issueCount: issues.length,
errorCount: errors.length,
sample: issues.slice(0, 3)
};
```const raw = api.buildSampleModelPayload();
const state = api.normalizeStatePayload(raw);
const issues = api.collectModelIssues(state);
const errors = issues.filter((issue) => issue.severity === 'error');
return {
ok: errors.length === 0,
issueCount: issues.length,
errorCount: errors.length,
sample: issues.slice(0, 3)
};Operação com PM2
O Jumentix usa PM2 quando uma VM ou container precisa de supervisão de processo: perfis por ambiente, logs, status, métricas, reloads e recuperação no startup. Os arquivos ecosystem deixam explícitos os processos REST, WebSocket, gRPC e Service Management.
PM2 não é a única opção de deploy, mas é uma ponte operacional forte para times que rodam serviços Node/Bun em máquinas persistentes. Ele daemoniza apps, reinicia processos com falha, expõe logs e métricas, suporta cluster mode e preserva a lista de processos após restarts.
bun run pm2:start:dev:restapi
bun run pm2:start:staging:websocket-rest
bun run pm2:start:prod:grpc-restExplore o código, execute a fábrica localmente e transforme seu próximo serviço Node.js em uma capacidade repetível de plataforma.