Persistência e views
Config global, views por usuário e definições criadas em runtime — tudo passa por um único contrato, o ReportStorageAdapter. Troque a implementação sem tocar no editor, no designer ou nos exports.
O contrato
ReportStorageAdapter — a única interface que qualquer implementação de storage precisa cumprir. Editor de layout, designer e provisionamento dependem só dela, nunca de um backend específico.
type ReportStorageAdapter = {
/** Config global salva, ou `null` quando nada foi salvo ainda (chamador aplica
* seu próprio default nesse caso). */
loadGlobalConfig(): Promise<ReportGlobalConfig | null>;
saveGlobalConfig(config: ReportGlobalConfig): Promise<void>;
/** Lista as views; `slug` filtra por relatório, omitido retorna todas. */
listViews(slug?: string): Promise<ReportView[]>;
getView(id: string): Promise<ReportView | null>;
/** Upsert por `id`. */
saveView(view: ReportView): Promise<void>;
/** Lança se a view for `isSystem` (view do sistema é somente leitura). */
deleteView(id: string): Promise<void>;
/** Marca `viewId` como default do `slug` e zera `isDefault` das demais
* views do mesmo slug. */
setDefaultView(slug: string, viewId: string): Promise<void>;
listDefinitions(): Promise<SerializableReportDefinition[]>;
getDefinition(slug: string): Promise<SerializableReportDefinition | null>;
/** Upsert por `slug`. */
saveDefinition(definition: SerializableReportDefinition): Promise<void>;
/** Remove a definição E todas as views associadas ao seu `slug`. */
deleteDefinition(slug: string): Promise<void>;
};Config global
companyName, logo, papel, margens, moeda — o mesmo objeto ReportGlobalConfig aplicado a todo relatório, salvo uma vez só.
Views
Cada view é um conjunto de overrides (colunas, cabeçalho, estilo, resumo) sobre uma definição — a de sistema nunca é apagada, só as criadas pelo usuário.
Definições
Só entram aqui relatórios criados em runtime pelo CmReportDesigner — os definidos em código (defineReport) nunca passam por saveDefinition.
Pronto para usar
Duas implementações inclusas no pacote — nenhuma exige um banco de dados.
createLocalStorageReportAdapter
Guarda tudo no localStorage do navegador. Use em apps 100% client-side, protótipos e demos — cada usuário mantém suas próprias views, sem servidor nenhum envolvido.
import { createLocalStorageReportAdapter } from "cosmemilton-report";
// options.storage só é acessado DENTRO de cada método (nunca no factory),
// então é seguro instanciar isto durante SSR — só não pode ser *usado* lá.
export const adapter = createLocalStorageReportAdapter({
prefix: "meuapp:relatorios:",
});createMemoryReportAdapter
Guarda tudo em Map na memória do processo. Use em testes automatizados e protótipos de servidor — nunca em produção multi-instância, porque cada processo tem sua própria memória e nada é persistido entre reinícios.
import { createMemoryReportAdapter } from "cosmemilton-report";
// Tudo em Map na memória do processo — reinicia zerado a cada
// deploy/restart e não é compartilhado entre instâncias.
export const adapter = createMemoryReportAdapter({
views: [], // seed opcional
});Auto-provisionamento
ensureReportViews garante que toda definição do registry tenha ao menos uma view de sistema para abrir — chame no boot do servidor.
import { createReportRegistry, ensureReportViews } from "cosmemilton-report";
import { relatorioVendas, relatorioPorVendedor } from "@/lib/demo-data";
import { adapter } from "@/lib/report-adapter";
const registry = createReportRegistry([relatorioVendas, relatorioPorVendedor]);
// Seguro chamar em todo boot do servidor (ou lazy, na primeira requisição):
// só cria o que falta, nunca duplica.
const { created, existing } = await ensureReportViews({ registry, adapter });Por que é seguro chamar toda vez
Nunca sobrescreve views existentes — só cria a view de sistema que ainda falta para um slug.
Se o slug já tem alguma view default (do usuário ou de uma execução anterior), a view de sistema nasce com isDefault: false — não rouba o default de ninguém.
Retorna { created, existing } com os ids de cada grupo — útil para log de inicialização.
Adapter Prisma (esqueleto)
Duas tabelas — definições e views — cada uma com colunas de busca (slug, id, flags) e o restante do objeto serializado como JSON em payload.
// Duas tabelas bastam: a definição (quando criada no designer) e as
// views. columns/header/style/summary não têm formato fixo por coluna
// no banco — vão inteiros como JSON, no mesmo shape do pacote.
model ReportDefinition {
slug String @id
name String
description String?
dataSource String?
// columns, summary, header, style, group — SerializableReportDefinition
// menos slug/name/description/dataSource, que já são colunas próprias.
payload Json
updatedAt DateTime @updatedAt
@@map("report_definitions")
}
model ReportView {
id String @id
slug String
name String
isSystem Boolean @default(false)
isDefault Boolean @default(false)
// columns, header, style, summary — ReportView menos os campos acima.
payload Json
updatedAt DateTime @updatedAt
@@index([slug])
@@map("report_views")
}import { PrismaClient } from "@prisma/client";
import type {
ReportGlobalConfig,
ReportStorageAdapter,
ReportView,
SerializableReportDefinition,
} from "cosmemilton-report";
const prisma = new PrismaClient();
// Remonta uma linha da tabela report_views no shape de ReportView.
function rowToView(row: {
id: string;
slug: string;
name: string;
isSystem: boolean;
isDefault: boolean;
payload: unknown;
updatedAt: Date;
}): ReportView {
return {
id: row.id,
slug: row.slug,
name: row.name,
isSystem: row.isSystem,
isDefault: row.isDefault,
updatedAt: row.updatedAt.toISOString(),
...(row.payload as object), // columns/header/style/summary
};
}
// Idem para report_definitions → SerializableReportDefinition.
function rowToDefinition(row: {
slug: string;
name: string;
description: string | null;
dataSource: string | null;
payload: unknown;
}): SerializableReportDefinition {
return {
slug: row.slug,
name: row.name,
description: row.description ?? undefined,
dataSource: row.dataSource ?? undefined,
columns: [],
...(row.payload as object), // columns real, summary, header, style, group
};
}
export function createPrismaReportAdapter(): ReportStorageAdapter {
return {
// Sem tabela própria neste esqueleto: grave/leia de uma linha
// "singleton" na tabela de settings do seu app.
async loadGlobalConfig() {
return null;
},
async saveGlobalConfig(_config: ReportGlobalConfig) {
// await prisma.appSettings.upsert(...)
},
async listViews(slug) {
const rows = await prisma.reportView.findMany({ where: slug ? { slug } : undefined });
return rows.map(rowToView);
},
async getView(id) {
const row = await prisma.reportView.findUnique({ where: { id } });
return row ? rowToView(row) : null;
},
async saveView(view: ReportView) {
const { id, slug, name, isSystem, isDefault, updatedAt: _updatedAt, ...rest } = view;
await prisma.reportView.upsert({
where: { id },
create: { id, slug, name, isSystem, isDefault, payload: rest },
update: { name, isSystem, isDefault, payload: rest },
});
},
async deleteView(id) {
const view = await prisma.reportView.findUnique({ where: { id } });
if (view?.isSystem) throw new Error("View de sistema é somente leitura.");
await prisma.reportView.delete({ where: { id } });
},
async setDefaultView(slug, viewId) {
// Transação: zera o default das demais views do slug e marca a nova.
await prisma.$transaction([
prisma.reportView.updateMany({ where: { slug }, data: { isDefault: false } }),
prisma.reportView.update({ where: { id: viewId }, data: { isDefault: true } }),
]);
},
async listDefinitions() {
const rows = await prisma.reportDefinition.findMany();
return rows.map(rowToDefinition);
},
async getDefinition(slug) {
const row = await prisma.reportDefinition.findUnique({ where: { slug } });
return row ? rowToDefinition(row) : null;
},
async saveDefinition(definition: SerializableReportDefinition) {
const { slug, name, description, dataSource, ...rest } = definition;
await prisma.reportDefinition.upsert({
where: { slug },
create: { slug, name, description, dataSource, payload: rest },
update: { name, description, dataSource, payload: rest },
});
},
async deleteDefinition(slug) {
// Sem cascade no schema acima de propósito: apaga views e definição
// juntas, numa transação, para não depender de configuração do banco.
await prisma.$transaction([
prisma.reportView.deleteMany({ where: { slug } }),
prisma.reportDefinition.delete({ where: { slug } }),
]);
},
};
}Ciclo das views
Do relatório definido em código até a view que um usuário deixou como padrão.
- SistemaensureReportViews cria a view system:<slug> a partir da definição em código: isSystem: true, somente leitura, sem overrides — reflete o defineReport tal como está.
- DuplicarNo editor de layout, o usuário duplica a view de sistema (ou outra view). Nasce uma view nova com isSystem: false, livre para editar e apagar.
- EditarMostrar/ocultar/reordenar colunas, trocar cabeçalho, estilo ou resumo — cada mudança vira um override salvo via saveView, mesclado sobre a definição na hora de resolver o relatório.
- Definir como padrãosetDefaultView marca a view como isDefault: true e zera o default das demais views do mesmo slug — é ela que abre da próxima vez que alguém abrir esse relatório.