Documento para iniciar a próxima conversa com as decisões cravadas. Contexto de entrada: v1 só code-first tipado · worker único (escala vertical) · MVP enxuto · solo founder (tempo parcial).
O contrato (NeoReports.Abstractions) é desenhado para não fechar porta nenhuma — caminho dinâmico, multi-worker e UI são todos possíveis sem rework. A implementação da v1 entrega só o mínimo demonstrável. Abstração estável e pequena; implementação enxuta.
Corolário para solo founder: cada interface pública em Abstractions é um passivo (trava SemVer, quebra plugins externos se mudar). Toda interface que o MVP não usa sai da v1.
Decisão. v1 é exclusivamente code-first tipado. A pipeline é genérica sobre T; o registro é o POCO. Não existe ReportRecord posicional nem dicionário por linha na v1.
- Leitura e processamento (
IBatchSource<T>,map,filter): tudo opera emT. Zero boxing durante o processamento. - Projeção para colunas acontece só na borda do writer: o Core compila
Func<T, object?>por coluna (a partir doReportSchemadeclarado no builder) e projeta cada linha paraobject?[]na ordem do schema, imediatamente antes de escrever. Boxing só aqui, e é inevitável (CSV/XLSX são saídas fracamente tipadas). - Writers ficam não-genéricos: consomem
(object?[] linha, ReportSchema). Plugin de formato não precisa saber deT.
Por quê. Dicionário por linha mata a memória constante. Processar tipado e projetar só na saída dá perf máxima no caminho quente e mantém os writers simples. O caminho dinâmico (ReportRecord posicional + filtro JsonLogic) volta pós-MVP sem quebrar o writer (a borda já fala object?[] + schema).
Decisão.
- Um processo worker, escala vertical. Sem fila distribuída, sem coordenação multi-máquina na v1.
- Default: Hangfire single-server (storage SQL/SQLite) — ganha persistência de estado de job entre restarts e dashboard de graça, com um servidor só.
InMemorypara dev/testes. - Job é unidade atômica que roda inteiro nesse worker. Se o processo cai no meio, o job reinicia do zero (re-execução idempotente). Saída em temp local, upload no fim, marca
completed. ICheckpointStoreexiste como contrato mas é no-op na v1.
Por quê. Worker vertical é a forma mais simples e atende o MVP. Hangfire single-server custa quase nada e já abre caminho pra multi-server depois (é só subir mais instâncias) sem trocar o contrato. Resume mid-job e multi-worker ficam pós-MVP — o contrato (IJobStore, ICheckpointStore) já está pronto pra ambos.
Decisão. O cursor de paginação é um token opaco serializável (string?, codificado pela própria source). Nada de object? Cursor.
Por quê. Mesmo com worker único, o cursor é o mecanismo de paginação keyset (abre/fecha conexão por página). string? opaco é o tipo certo e custa zero — e já deixa checkpoint/multi-worker viáveis no futuro sem rework.
Muda no código. BatchResult<T>.NextCursor, BatchContext.Cursor e Checkpoint.LastCursor são string?. A source é dona do encode/decode do cursor tipado interno.
Decisão. IBatchSource<T> é o contrato primário e o modelo interno da pipeline. IStreamingSource<T> (IAsyncEnumerable<T>) existe como opção de autoria, mas é fatiado em batches por um StreamingToBatchAdapter (tamanho configurável).
Por quê. Retry, threshold e escrita operam em batch. Um modelo só pra raciocinar.
Decisão. Cortar variants, herança de config e coalescência do MVP. Reports independentes evoluem pra pipeline+variants depois sem quebrar contrato.
Decisão. Usar Polly v8 (ResiliencePipeline) direto no loop de leitura de batch. Remover IRetryPolicy e IExceptionClassifier de Abstractions. Manter só IFailureStrategy (decisão após esgotar tentativas) + monitor de threshold.
Muda no código. Config de retry compila para um ResiliencePipeline. IFailureStrategy na v1: só AbortReport() e SkipBatchAndLog().
Decisão. Renomear. Colide com System.Threading.ExecutionContext.
Decisão. Sem endpoint de config dinâmica na v1. Reports são registrados em código (.AddReport("nome", b => b.From<T>()...)). O endpoint dispara um report registrado por nome com parâmetros:
POST /api/reports/{nome}/run # async → jobId
POST /api/reports/{nome}/run?mode=sync # streaming direto no response
mode=sync é single-output (um formato, response body, sem compressão de múltiplos arquivos). Compiler valida e rejeita multi-output em sync com 400.
Decisão. SemVer estrito; tratar como ABI. Superfície da v1:
Schema/ ColumnType · ReportColumn · ReportSchema
Data/ ReportBatch<T>
Execution/ ReportExecutionContext · JobPriority
Sources/ IReportSource · IBatchSource<T> · IStreamingSource<T>
BatchContext · BatchResult<T> (Cursor = string?)
Formats/ IReportWriter · WriterContext (writer não-genérico; recebe object?[] + schema)
Destinations/ IReportDestination · ReportFile · DestinationContext · UploadResult
Resilience/ IFailureStrategy · BatchFailureContext · FailureDecision · FailureAction
Jobs/ IReportJobScheduler · IJobStore · ReportJob · ReportJobRequest
ReportJobStatus · JobStats · ICheckpointStore (contrato, no-op v1)
Extensibility/ ISourceFactory · IWriterFactory · IDestinationFactory
Exceptions/ NeoReportsException · BatchFailedException · SourceFailedException
· ThresholdExceededException · ConfigurationException
Removido da v1 vs. Cap. 16: IRetryPolicy, IExceptionClassifier, IAuthProvider* (host auth basta), IReportConfigParser + DTOs de config/variant, ReportRecord posicional, JobEvent/JobEventType completo, IPaginationStrategy público (interno por ora).
Decisão. Filtro e transform são delegates C# tipados (Func<T,bool>, Func<T,T>) declarados no builder. JsonLogic e DynamicLinq saem da v1 (eram do caminho dinâmico, cortado em D1).
Por quê. Code-first não precisa de avaliador de expressão — o filtro é código C# compilado, rápido e seguro. Expressões dinâmicas voltam junto com o caminho dinâmico, pós-MVP.
| Camada | Entra na v1 | Fica pra depois |
|---|---|---|
| Paradigma | Code-first tipado (.AddReport + .From<T>) |
Endpoint de config dinâmica, builder visual, UI |
| Sources | SQL (IBatchSource<T>, keyset) |
HTTP, File, Mongo, Custom |
| Formats | CSV, XLSX | PDF, JSON, XML, Parquet |
| Destinations | Local, S3 | SharePoint, Azure, GDrive, FTP, Email, Webhook |
| Jobs | Hangfire single-server, InMemory | Multi-worker, Quartz, MassTransit, Azure Functions |
| Resiliência | Polly + Abort/SkipAndLog + threshold | Pause/Review, FallbackToCache, dead-letter |
| Auth | Herda do host | Filter chain, signed URLs, per-area/action |
| Estrutura | Reports independentes | Pipeline + variants + coalescência |
| Checkpoint | Contrato no-op; restart-do-zero | Resume mid-job; multi-worker |
| Filtro | Delegates C# tipados | JsonLogic / DynamicLinq (caminho dinâmico) |
Não entra na v1: UI Blazor, caminho dinâmico, variants/coalescência, multi-worker, auth chain, SharePoint, templates dotnet new, PDF, config YAML/TOML, dashboard de métricas.
Estado: o design das telas já está pronto no projeto Claude Design (Claude Design System — Anthropic Sans, CSS variables, paleta oficial, ícones Tabler outline, flat). A UI continua pós-MVP — este handoff é preparação para a fase de UI, não para a v1.
Stack-alvo da UI: Blazor Server + MudBlazor (+ ApexCharts).
Pedir ao projeto de design que exporte, nesta ordem de prioridade:
tokens.css— todos os tokens do Design System (cores, tipografia, espaçamento, raios, sombras) como CSS custom properties nomeadas, arquivo único. Vira tema MudBlazor.components.html— catálogo de cada componente reutilizável em todas as variantes e estados (default/hover/active/disabled/loading/empty/error). Mínimo: MetricCard, StatusBadge (queued/running/completed/failed/paused/retrying/cancelled), ProgressBar, PhaseStepper, WizardStepper, FilterBar, ReportCard, SourceCard, DestinationCard, FormatCard, DataGrid (header+rows), Timeline/EventRow, EmptyState, Banner/Alert, NavBar, SubNav, Chip/Tag, Switch. Classes nomeadas e estáveis, sem estilo inline.- Um
.htmlpor tela (as 17) — markup semântico que referencia as classes do catálogo (não recopia estilo); só layout/composição/grid. handoff.md— tabelatela → rota → componentes usados → endpoint que alimenta → estados a tratar, mais breakpoints responsivos e lista de ícones Tabler.
Regras de formato (minimizam trabalho no Claude Code): CSS externo só, zero inline style; classes que mapeiam 1:1 pra nome de componente; HTML semântico (button/table/nav/headings, sem div-soup); nenhuma suposição de comportamento JS (interatividade é do Blazor). Evitar: screenshots/PNG, Figma, HTML gigante com estilo inline.
Abstractionsmínimo (D9) +Core(builder fluente genérico<T>+ pipeline batch + projeção compilada + Polly).Sources.Sql(keyset) +Formats.Csv+Destinations.Local. Primeiro report tipado end-to-end rodando.Formats.Xlsx(ClosedXML) +Destinations.S3(upload tudo-ou-nada).Jobs.Hangfire(single-server) +Jobs.InMemory+IJobStore.AspNetCore: endpoints de disparo async/sync de reports registrados. MVP demonstrável.- Validar com usuários reais antes de UI / caminho dinâmico / variants / multi-worker.
| # | Tema | Decisão |
|---|---|---|
| D1 | Registro | Pipeline genérica <T> tipada; projeção pra object?[] só na borda do writer; sem dicionário; dinâmico pós-MVP |
| D2 | Worker | Único / vertical; Hangfire single-server; job atômico; restart-do-zero; multi-worker e resume pós-MVP |
| D3 | Cursor | Token opaco serializável (string?) |
| D4 | Stream vs Batch | Batch canônico; streaming adaptado |
| D5 | Variants/coalescência | Fora da v1 |
| D6 | Resiliência | Polly direto; só IFailureStrategy + threshold como abstração própria |
| D7 | Naming | ExecutionContext → ReportExecutionContext |
| D8 | Disparo/sync | Reports registrados por nome; sem config dinâmica; sync = single-output |
| D9 | Abstractions | Mínimo typed-only, congelado, SemVer estrito |
| D10 | Filtro | Delegates C# tipados; JsonLogic/DynamicLinq pós-MVP |
| D11 | Retry/Skip | Retry (Polly) envolve a leitura do batch; falha de leitura não é "skippável" (sem cursor pra avançar) → vira Abort; falha de projeção/escrita é skippável (cursor já conhecido) |
| D12 | Map no builder | Map não é um passo que troca o tipo do builder; o mapeamento é expresso por From(source, map), mantendo ReportBuilder<TRow> mono-genérico e compatível com AddReport<TRow>(Action<...>) |
| — | Design | Já feito no Claude Design; exportar conforme handoff; UI pós-MVP |
Decisão.
- A unidade de resiliência é a leitura de um batch. A
ResiliencePipeline(Polly v8) envolvereader.ReadAsync(leitura + filtro + projeção).MaxAttemptsinclui a primeira tentativa (MaxRetryAttempts = MaxAttempts - 1). Cancelamento (OperationCanceledException) nunca é retentado. - Falha de leitura depois de esgotado o retry não é "skippável": sem um batch lido não há
NextCursorpara avançar a paginação keyset, então pular silenciosamente truncaria dados. Nesse caso, mesmo em modo skip, o report aborta (statusFailed). - Falha de projeção/escrita de um batch já lido é skippável: o
NextCursorjá é conhecido, entãoSkipBatchAndLogdescarta aquele batch, loga warning estruturado e marca o report como parcial (CompletedPartial), seguindo para o próximo cursor. - O
IFailureStrategyrecebe contadores (consecutivas/total/razão) viaBatchFailureContext;SkipBatchAndLog().AbortIf(t => t.ConsecutiveFailures(n))escala para Abort quando o threshold é atingido. - Premissa de atomicidade do writer: writers devem escrever um batch de forma atômica (bufferizar e dar flush) para que o skip não deixe linha parcial. Saída vai para arquivo temporário por execução; publicação (upload) acontece só no fim (alinha com D2: restart-do-zero, publicação atômica).
Por quê. Retry resolve transitórios de leitura (CA-11); skip + threshold dão resiliência a falhas definitivas sem corromper ordenação keyset (CA-12/13/14). Separar leitura (retentável, idempotente) de escrita (não re-escrita) evita escrita dupla no stream de saída.
Decisão. ReportBuilder<TRow> é genérico apenas sobre o tipo de linha final TRow. O mapeamento de um tipo de origem diferente é expresso por overloads From<TSource>(IBatchSource<TSource>, Func<TSource,TRow>) / From<TSource>(IStreamingSource<TSource>, Func<TSource,TRow>), que adaptam a source via MappingBatchSource/MappingStreamingSource.
Por quê. Um passo Map<TOut> que troca o tipo do builder quebraria o padrão de registro AddReport<TRow>("nome", Action<ReportBuilder<TRow>>) (a lambda continuaria num builder de outro tipo enquanto o registro buildaria o original). O overload de From entrega a mesma capacidade ("Map para um tipo de saída" da spec) sem essa armadilha e sem segundo parâmetro genérico no builder. Colunas são declaradas com .Column(v => v.X, "Header") (infere ColumnType do tipo do membro) ou Columns(Col(...)).