@softize/opus 25.2.0 → 25.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,44 @@ Depois de qualquer bump, rode os gates (`typecheck` · `test` · `opus check` ·
7
7
  `opus copy --check` · `base copy check` · `manifest:check`) — eles apontam o que a
8
8
  mudança cobra do seu código.
9
9
 
10
+ ## 25.3.1 — 2026-10-05
11
+
12
+ Com um `Dialog` ou `Drawer` aberto e a pessoa numa `FloatingLayer` sem `onEscapeKeyDown`, o Esc
13
+ fecha o modal. Antes, o Opus impedia o modal de fechar e chamava um handler que não existia: quem
14
+ chegava ao botão do assistente pelo Tab e apertava Esc não via efeito nenhum. Com o handler, nada
15
+ muda: a camada recebe a tecla e o modal continua aberto. Para que o Esc no botão feche o modal,
16
+ passe `onEscapeKeyDown` só enquanto a camada tiver o que fechar, como com o painel aberto, e
17
+ `undefined` no restante do tempo. A decisão está numa nota da seção 5 da ADR 0052.
18
+
19
+ `assistantContextLeaves`, de `@softize/opus/assistant-schema`, passa a listar em `open` dois pontos
20
+ por onde qualquer campo chega ao modelo e que antes ficavam de fora: o `$ref` que não resolve dentro
21
+ do schema, que sumia do resultado, e o objeto que aceita chaves não declaradas
22
+ (`additionalProperties: true`, do `.passthrough()` do zod), que aparece como `<caminho>.*`. A forma
23
+ do resultado e a conferência do `opus gen` não mudam. Um teste de campos pessoais que reprova pontos
24
+ abertos pode passar a acusar esses casos; reprove-os ou justifique cada um.
25
+
26
+ A ADR 0052 e a página da `FloatingLayer` registram que o roteiro manual em Safari com VoiceOver e no
27
+ Firefox não foi executado: na adoção pela Grand Brasil, o dono do produto o dispensou. A evidência
28
+ é a dos testes de navegador no Chromium real, e o roteiro volta a ser exigido se surgir problema de
29
+ leitor de tela, de Safari ou de Firefox.
30
+
31
+ ## 25.3.0 — 2026-10-05
32
+
33
+ `ListAction.grouping` declara dimensões fechadas pelas quais a `ActionList` pode agrupar a tabela.
34
+ Cada dimensão referencia um filtro `select` com até 12 opções estáticas ou de dicionário, e a
35
+ action precisa usar `paginate: 'offset'` e devolver `total`. A pessoa escolhe o agrupamento em
36
+ **Exibição**; cada grupo consulta o servidor separadamente, pode ser recolhido e carrega páginas
37
+ adicionais sem descartar as anteriores. O agrupamento não altera views alternativas nem o modo de
38
+ composição por `children`.
39
+
40
+ O manifest e os stubs passam a projetar `grouping`. `ActionListState` e os helpers de URL preservam
41
+ a dimensão em `group`; dimensões desconhecidas são ignoradas. A implementação das consultas por
42
+ grupo permanece interna à `ActionList`, sem introduzir um hook público adicional.
43
+
44
+ `useListAction(action, input, { enabled })` pode suspender a consulta declarativa sem desmontar o
45
+ hook. O padrão é `true`; a `ActionList` usa essa opção internamente ao alternar entre a consulta
46
+ plana e as consultas agrupadas.
47
+
10
48
  ## 25.2.0 — 2026-10-03
11
49
 
12
50
  Uma action `view` pode declarar o assistente do recurso que ela lê (ADR 0053). Com isso, a conversa
@@ -13,8 +13,9 @@ export interface AssistantContextLeaves {
13
13
  /** Os campos folha alcançados, com ponto e sem índice de lista; `*` marca a chave de um mapa. */
14
14
  leaves: string[]
15
15
  /**
16
- * Pontos em que o schema não declara forma ou volta a si mesmo por `$ref`: qualquer campo abaixo
17
- * deles pode ir ao modelo, e a conferência não alcança.
16
+ * Pontos por onde qualquer campo pode ir ao modelo sem que a conferência alcance: o schema que não
17
+ * declara forma, o `$ref` que volta a si mesmo ou que não resolve, e, como `<ponto>.*`, o objeto
18
+ * que aceita chaves não declaradas (`additionalProperties: true`, do `.passthrough()` do zod).
18
19
  */
19
20
  open: string[]
20
21
  }
@@ -45,18 +45,26 @@ function isNullOnly(schema) {
45
45
  const NO_REFS = new Set();
46
46
  /** Marca o ponto em que um `$ref` volta a um schema que já está no caminho atual. */
47
47
  const CYCLE = Object.freeze({ cycle: true });
48
+ /** Marca o ponto em que um `$ref` não resolve dentro do schema: não há forma a conferir ali. */
49
+ const UNRESOLVED = Object.freeze({ unresolved: true });
50
+
51
+ /** Um dos marcadores de `expand`, e não um schema de verdade. */
52
+ function isMarker(schema) {
53
+ return schema === CYCLE || schema === UNRESOLVED;
54
+ }
48
55
 
49
56
  /**
50
57
  * Os ramos concretos e não nulos de um schema, com `$ref` e combinadores expandidos. Cada ramo traz
51
58
  * os `$ref` percorridos no caminho atual; um `$ref` que se repete ali vira `CYCLE`, em vez de
52
- * expandir de novo.
59
+ * expandir de novo, e um `$ref` que não resolve vira `UNRESOLVED`, em vez de sumir.
53
60
  */
54
61
  function expand(schema, root, active = NO_REFS, depth = 0) {
55
62
  if (schema === null || typeof schema !== "object" || depth > MAX_DEPTH) return [];
56
63
  if (typeof schema.$ref === "string") {
57
64
  if (active.has(schema.$ref)) return [{ schema: CYCLE, active }];
58
65
  const target = resolveRef(root, schema.$ref);
59
- return target === undefined ? [] : expand(target, root, new Set([...active, schema.$ref]), depth + 1);
66
+ if (target === undefined) return [{ schema: UNRESOLVED, active }];
67
+ return expand(target, root, new Set([...active, schema.$ref]), depth + 1);
60
68
  }
61
69
  const nested = COMBINATORS.flatMap((key) =>
62
70
  Array.isArray(schema[key]) ? schema[key].flatMap((branch) => expand(branch, root, active, depth + 1)) : [],
@@ -68,9 +76,9 @@ function expand(schema, root, active = NO_REFS, depth = 0) {
68
76
  return [{ schema, active }, ...nested];
69
77
  }
70
78
 
71
- /** Os ramos sem os ciclos, para quem só olha o tipo do próprio nó. */
79
+ /** Os ramos sem os marcadores, para quem só olha o tipo do próprio nó. */
72
80
  function branches(schema, root) {
73
- return expand(schema, root).flatMap(({ schema: branch }) => (branch === CYCLE ? [] : [branch]));
81
+ return expand(schema, root).flatMap(({ schema: branch }) => (isMarker(branch) ? [] : [branch]));
74
82
  }
75
83
 
76
84
  function isOpen(schema) {
@@ -89,7 +97,9 @@ function isArraySchema(schema) {
89
97
  * Os schemas alcançados por `segments` em todos os ramos não nulos. Um schema sem forma alcança
90
98
  * qualquer caminho abaixo dele e entra como está. Com `throughLists`, uma lista é atravessada. Um
91
99
  * ciclo de `$ref` sem avançar no caminho (por lista ou união) não leva a lugar nenhum; ao descer
92
- * por um campo, o caminho avança, e o mesmo `$ref` pode voltar, como numa árvore.
100
+ * por um campo, o caminho avança, e o mesmo `$ref` pode voltar, como numa árvore. Um `$ref` que não
101
+ * resolve antes do fim do caminho entra como `UNRESOLVED`: quem confere o caminho o descarta, e
102
+ * quem lista as folhas o marca como aberto.
93
103
  */
94
104
  function collectTargets(schema, segments, root, throughLists, out, active = NO_REFS, depth = 0) {
95
105
  if (depth > MAX_DEPTH) return;
@@ -99,7 +109,7 @@ function collectTargets(schema, segments, root, throughLists, out, active = NO_R
99
109
  }
100
110
  for (const { schema: branch, active: refs } of expand(schema, root, active)) {
101
111
  if (branch === CYCLE) continue;
102
- if (isOpen(branch)) {
112
+ if (branch === UNRESOLVED || isOpen(branch)) {
103
113
  out.push(branch);
104
114
  continue;
105
115
  }
@@ -127,7 +137,7 @@ function collectTargets(schema, segments, root, throughLists, out, active = NO_R
127
137
  export function schemaHasPath(schema, segments, root = schema) {
128
138
  const targets = [];
129
139
  collectTargets(schema, segments, root, true, targets);
130
- return targets.length > 0;
140
+ return targets.some((target) => target !== UNRESOLVED);
131
141
  }
132
142
 
133
143
  const TITLE_TYPES = new Set(["string", "number", "integer"]);
@@ -139,19 +149,24 @@ const TITLE_TYPES = new Set(["string", "number", "integer"]);
139
149
  export function schemaTitleIsText(schema, segments, root = schema) {
140
150
  const targets = [];
141
151
  collectTargets(schema, segments, root, false, targets);
142
- return targets.some((target) =>
143
- isOpen(target) ||
144
- branches(target, root).some((branch) => {
145
- if (isOpen(branch)) return true;
146
- const types = Array.isArray(branch.type) ? branch.type : [branch.type];
147
- return types.some((type) => TITLE_TYPES.has(type));
148
- }),
152
+ return targets.some(
153
+ (target) =>
154
+ target !== UNRESOLVED &&
155
+ (isOpen(target) ||
156
+ branches(target, root).some((branch) => {
157
+ if (isOpen(branch)) return true;
158
+ const types = Array.isArray(branch.type) ? branch.type : [branch.type];
159
+ return types.some((type) => TITLE_TYPES.has(type));
160
+ })),
149
161
  );
150
162
  }
151
163
 
152
164
  /**
153
- * As folhas abaixo de `schema`. Um `$ref` que volta a um schema já no caminho atual (uma estrutura
154
- * recursiva) entra em `open` naquele ponto: abaixo dele, qualquer profundidade pode chegar ao modelo.
165
+ * As folhas abaixo de `schema`. Entram em `open`, no ponto em que aparecem, o schema sem forma, o
166
+ * `$ref` que volta a um schema já no caminho atual (uma estrutura recursiva) e o `$ref` que não
167
+ * resolve: abaixo deles, qualquer campo pode chegar ao modelo. Um objeto que aceita chaves não
168
+ * declaradas (`additionalProperties: true`, como o `.passthrough()` do zod) entra como `<ponto>.*`,
169
+ * a mesma marca da chave de um mapa, porque a projeção copia o objeto inteiro.
155
170
  */
156
171
  function collectLeaves(schema, prefix, root, leaves, open, active = NO_REFS, depth = 0) {
157
172
  if (depth > MAX_DEPTH) {
@@ -159,7 +174,7 @@ function collectLeaves(schema, prefix, root, leaves, open, active = NO_REFS, dep
159
174
  return;
160
175
  }
161
176
  for (const { schema: branch, active: refs } of expand(schema, root, active)) {
162
- if (branch === CYCLE || isOpen(branch)) {
177
+ if (isMarker(branch) || isOpen(branch)) {
163
178
  open.add(prefix);
164
179
  continue;
165
180
  }
@@ -176,10 +191,13 @@ function collectLeaves(schema, prefix, root, leaves, open, active = NO_REFS, dep
176
191
  }
177
192
  }
178
193
  const additional = branch.additionalProperties;
179
- if (additional !== null && typeof additional === "object") {
194
+ const hasAdditional = additional === true || (additional !== null && typeof additional === "object");
195
+ if (additional === true) {
196
+ open.add(`${prefix}.*`);
197
+ } else if (hasAdditional) {
180
198
  collectLeaves(additional, `${prefix}.*`, root, leaves, open, refs, depth + 1);
181
199
  }
182
- if (!hasProperties && !(additional !== null && typeof additional === "object")) leaves.add(prefix);
200
+ if (!hasProperties && !hasAdditional) leaves.add(prefix);
183
201
  }
184
202
  }
185
203
 
@@ -190,8 +208,10 @@ function collectLeaves(schema, prefix, root, leaves, open, active = NO_REFS, dep
190
208
  * ao modelo por um caminho que termina num contêiner.
191
209
  *
192
210
  * - `leaves`: caminhos das folhas, com ponto e sem índice de lista; `*` marca a chave de um mapa;
193
- * - `open`: pontos em que o schema não declara forma (`z.unknown()`, `z.any()`) ou volta a si mesmo
194
- * por `$ref`: qualquer campo abaixo deles pode ir ao modelo, e a conferência não alcança.
211
+ * - `open`: pontos por onde qualquer campo pode ir ao modelo sem que a conferência alcance: o schema
212
+ * que não declara forma (`z.unknown()`, `z.any()`), o `$ref` que volta a si mesmo ou que não
213
+ * resolve, e, como `<ponto>.*`, o objeto que aceita chaves não declaradas
214
+ * (`additionalProperties: true`, do `.passthrough()` do zod).
195
215
  */
196
216
  export function assistantContextLeaves(output, context, root = output) {
197
217
  return context.map((path) => {
@@ -80,6 +80,7 @@ function shapeAction(a) {
80
80
  out.paginate = a.paginate;
81
81
  out.text = a.text;
82
82
  out.periods = a.periods;
83
+ if (a.grouping !== undefined) out.grouping = a.grouping;
83
84
  }
84
85
  if (a.kind === "view") {
85
86
  out.projection = a.projection ?? [];
@@ -43,7 +43,7 @@
43
43
  * input: JSONSchema, // zod-to-json-schema
44
44
  * output: JSONSchema, // zod-to-json-schema
45
45
  * fields?: Record<string, FieldSpecSerialized>,
46
- * filters?, sort?, paginate?, text?, periods?, // search
46
+ * filters?, sort?, paginate?, text?, periods?, grouping?, // list
47
47
  * projection?, expand?, // view
48
48
  * background?: boolean,
49
49
  * emits?: string[],
@@ -433,6 +433,7 @@ function serializeAction(action, ctx) {
433
433
  base.paginate = action.paginate ?? null;
434
434
  base.text = action.text ?? null;
435
435
  base.periods = Array.isArray(action.periods) ? action.periods : [];
436
+ if (action.grouping !== undefined) base.grouping = action.grouping;
436
437
  }
437
438
 
438
439
  if (action.kind === "view") {
@@ -149,6 +149,9 @@ function renderActionStub(action) {
149
149
  const filters = stripNullsFromFields(action.filters)
150
150
  lines.push(` filters: ${formatObjectLiteral(filters, ' ')},`)
151
151
  }
152
+ if (action.grouping && Array.isArray(action.grouping.fields)) {
153
+ lines.push(` grouping: ${JSON.stringify(action.grouping)},`)
154
+ }
152
155
  }
153
156
  if (Array.isArray(action.invalidates) && action.invalidates.length > 0) {
154
157
  lines.push(` invalidates: ${JSON.stringify(action.invalidates)},`)
@@ -225,6 +225,17 @@ camada uma vez. No modal, o Esc fecha o modal, como hoje. A alternativa de fecha
225
225
  está visualmente por cima foi descartada: quem preenche o modal e aperta Esc quer sair do modal, não
226
226
  do painel que nem está usando.
227
227
 
228
+ *Nota (2026-10-05):* a regra vale para a camada que tem o que fechar. Uma camada sem
229
+ `onEscapeKeyDown` não engole o Esc: com a pessoa nela, a tecla segue para o modal de trabalho, que
230
+ fecha como fecharia com a pessoa nele. Antes, o Opus marcava o evento e chamava um handler ausente,
231
+ e nem o modal nem a camada reagiam; quem chegava ao FAB pelo Tab e apertava Esc encontrava uma
232
+ tecla sem efeito. Com o handler, nada muda: a camada recebe a tecla e o modal continua aberto. O
233
+ consumidor que só tem o que fechar com o painel aberto passa o handler nesse estado e `undefined`
234
+ com o FAB, como a Grand Brasil já faz. A revisão também apontou o handler presente que não tem o que
235
+ fechar naquele momento; o Opus não consegue distinguir esse caso, e a forma de dizer “não há nada a
236
+ fechar aqui” é não passar o handler. Os testes do pacote cobrem o FAB focado e o clique numa área
237
+ sem foco da camada, com Dialog e Drawer, e o navegador real cobre o FAB alcançado pelo Tab.
238
+
228
239
  Antes dessa regra vale a do Radix: um `DismissableLayer` aberto depois do modal consome o Esc, onde
229
240
  quer que a pessoa esteja. Isso dá três exceções conhecidas e aceitas:
230
241
 
@@ -658,6 +669,12 @@ cada repositório, o que mantém uma só pilha de escopos e um só contexto de `
658
669
  navegadores. O roteiro manual acima fica para a adoção na Grand Brasil, executado no Mac do João
659
670
  (Safari com VoiceOver) e no Firefox antes de a adoção chegar à `main` da Grand Brasil, com o
660
671
  resultado registrado na entrega da adoção.
672
+
673
+ *Nota (2026-10-05):* o roteiro manual não foi executado. Na adoção pela Grand Brasil, o dono do
674
+ produto decidiu seguir sem ele. A evidência da camada é a dos testes de navegador do pacote, no
675
+ Chromium real, somada aos testes da adoção. Safari, Firefox e VoiceOver continuam sem
676
+ verificação. Se aparecer em uso um problema de leitor de tela, de Safari ou de Firefox com a
677
+ camada sobre um modal, o roteiro acima volta a ser exigido.
661
678
  - **Arquitetura:**
662
679
  - todo uso de portal em `src/ui` lê o contêiner da camada;
663
680
  - só `modal-layers.ts`, `dialog.tsx`, `drawer.tsx` e `floating-layer.tsx` aplicam as exceções;
@@ -666,3 +683,5 @@ cada repositório, o que mantém uma só pilha de escopos e um só contexto de `
666
683
  - o teste de numeração de ADRs continua passando.
667
684
  - **Na Grand Brasil:** um teste do shell confirma que o FAB e o painel ficam dentro de
668
685
  `[data-slot="floating-layer"]`, e o roteiro de navegador acima roda na pilha de desenvolvimento.
686
+ *Nota (2026-10-05):* o roteiro foi dispensado na adoção e não rodou; ver a nota da mesma data no
687
+ item **Navegador real**.
@@ -355,7 +355,14 @@ aberto assim:
355
355
  `assistantContextLeaves(output, context)`: a partir do JSON Schema da saída, como o manifest o
356
356
  traz, devolve por caminho os campos folha alcançados e os pontos sem forma declarada ou em que um
357
357
  `$ref` volta a si mesmo, por onde qualquer campo pode passar. O subpath publica tipos
358
- (`assistant-schema.d.mts`) para um teste em TypeScript estrito.
358
+ (`assistant-schema.d.mts`) para um teste em TypeScript estrito. *Nota (2026-10-05):* dois pontos
359
+ por onde qualquer campo passa ficavam fora de `open`. Um `$ref` que não resolve dentro do schema
360
+ sumia sem aparecer em `leaves` nem em `open`; agora entra em `open` no ponto em que aparece. Um
361
+ objeto que aceita chaves não declaradas (`additionalProperties: true`, que o `.passthrough()` do
362
+ zod produz) era tratado como fechado, embora a projeção copie o objeto inteiro; agora entra em
363
+ `open` como `<caminho>.*`, a mesma marca da chave de um mapa. A conferência do `opus gen` não
364
+ muda: um caminho abaixo de um `$ref` que não resolve continua recusado, e ele não vale como
365
+ `title`.
359
366
 
360
367
  ## Consequências
361
368
 
@@ -0,0 +1,82 @@
1
+ # ADR 0054 — Agrupamento da ActionList é um recorte declarado e consultado no servidor
2
+
3
+ - **Status:** aceita.
4
+ - **Data:** 2026-10-03.
5
+ - **Relacionadas:** ADR 0013 (Presentation orientada a action) e ADR 0024 (preenchimento das
6
+ regiões de lista).
7
+
8
+ ## Contexto
9
+
10
+ Listagens operacionais precisam, em alguns casos, comparar conjuntos dentro do mesmo recorte. A
11
+ fila de eventos da Grand Brasil é o primeiro caso: a pessoa quer reunir os eventos por status sem
12
+ perder busca, filtros, ordenação, contagens ou paginação.
13
+
14
+ Agrupar apenas os itens carregados criaria uma representação falsa. Uma página de cinquenta itens
15
+ não permite concluir quantos registros existem em cada grupo, e trocar de página faria grupos
16
+ sumirem ou mudarem de tamanho. Acrescentar um envelope agregado ao protocolo resolveria isso com
17
+ uma consulta, mas obrigaria todo handler e adapter a aprender uma segunda forma de resposta antes de
18
+ existir evidência de que o custo compensa.
19
+
20
+ O campo também não pode ser inferido de toda coluna ou filtro. Texto livre e lookup podem ter
21
+ cardinalidade alta, data e número exigem faixas, e nem toda dimensão fechada faz sentido como
22
+ organização da lista. A decisão precisa continuar no contrato de produto.
23
+
24
+ ## Decisão
25
+
26
+ ### 1. A action declara as dimensões permitidas
27
+
28
+ Uma `ListAction` pode declarar:
29
+
30
+ ```ts
31
+ grouping: {
32
+ fields: ['status', 'stage'],
33
+ }
34
+ ```
35
+
36
+ Cada nome referencia um filtro `select` com opções `static` ou `dictionary`. O controle do filtro
37
+ pode aceitar uma ou várias opções; cada consulta de grupo envia um único valor na forma esperada
38
+ pelo filtro. A declaração não define agrupamento inicial: a lista continua plana até a pessoa
39
+ escolher “Agrupar por” em **Exibição**. O estado usa `groupBy` e a URL usa `group=<campo>`.
40
+
41
+ O Opus rejeita dimensões ausentes, filtros de outro tipo, lookups, conjuntos estáticos sem opções
42
+ fechadas e actions sem `paginate: 'offset'`. A interface oferece apenas dicionários resolvidos com
43
+ até doze opções por dimensão; acima disso, a declaração não é adequada para agrupamento operacional.
44
+
45
+ ### 2. Cada grupo é uma consulta da mesma ListAction
46
+
47
+ O agrupamento preserva o input base, a busca, o período, os demais filtros e a ordenação. Para cada
48
+ opção da dimensão, o Opus executa a mesma action com o valor do grupo aplicado ao filtro. Um filtro
49
+ já aplicado à dimensão restringe quais grupos aparecem; não é removido nem reinterpretado.
50
+
51
+ Cada resposta precisa devolver `total`. A ausência desse dado vira o estado de erro da lista, sem
52
+ lançar uma exceção durante o render. Esse total nomeia o grupo e governa sua paginação. O primeiro
53
+ recorte usa o limite da lista; **Carregar mais** busca a página seguinte daquele grupo e acrescenta
54
+ os itens, sem reiniciar os demais. A invalidação ou a atualização manual refaz todas as consultas.
55
+
56
+ Essa primeira implementação aceita o custo de até doze consultas em paralelo. Um envelope de
57
+ facetas ou uma action agregada só será introduzido quando medição real justificar outra fronteira.
58
+
59
+ ### 3. O agrupamento pertence à tabela
60
+
61
+ A tabela mantém um único cabeçalho e abre um `tbody` por grupo. O cabeçalho do grupo é recolhível,
62
+ expõe `aria-expanded`, apresenta o valor pelo dicionário quando houver e mostra a contagem correta,
63
+ inclusive zero. O agrupamento não se aplica a `children` nem a views alternativas; ao sair da tabela,
64
+ os dados voltam à consulta plana.
65
+
66
+ Busca, filtros e período são aplicados antes do agrupamento; ordenação vale dentro de cada grupo. A
67
+ seleção em lote cobre os itens carregados dos grupos abertos. Recolher um grupo remove da seleção os
68
+ itens dele, para nenhuma ação operar sobre linhas invisíveis.
69
+
70
+ Se uma consulta falhar, a lista apresenta o erro padrão e permite refazer o conjunto. Se todos os
71
+ totais forem zero, há um único estado vazio. Não existe paginador global enquanto o agrupamento está
72
+ ativo; o rodapé mostra apenas a soma dos totais.
73
+
74
+ ## Consequências
75
+
76
+ - O contrato distingue capacidade de agrupamento de uma seleção corrente.
77
+ - Contagens e páginas continuam autoritativas no servidor; não há agrupamento parcial silencioso.
78
+ - O número de requisições cresce com as opções da dimensão, de forma explícita e limitada.
79
+ - O primeiro consumidor deve testar que a soma dos totais agrupados coincide com o total da lista
80
+ plana para o mesmo recorte.
81
+ - Uma futura otimização de protocolo pode preservar `grouping` e `groupBy`, trocando apenas como os
82
+ dados chegam ao cliente.
package/docs/protocol.md CHANGED
@@ -129,6 +129,7 @@ type ListAction<In, Out> = ActionBase<In, Paginated<Out>> & {
129
129
  paginate?: 'cursor' | 'offset' | false
130
130
  text?: { fields: string[] }
131
131
  periods?: { value: string, label: I18nRef }[]
132
+ grouping?: { fields: string[] }
132
133
  }
133
134
 
134
135
  type ViewAction<In, Out> = ActionBase<In, Out> & {
@@ -205,7 +206,10 @@ type OptionsSpec =
205
206
 
206
207
  **`search`**
207
208
  - `filters?: Record<string, FilterSpec>` — filtros disponíveis (chave é nome).
208
- - `sort?`, `paginate?`, `text?`, `periods?` — ver tipos abaixo.
209
+ - `sort?`, `paginate?`, `text?`, `periods?`, `grouping?` — ver tipos abaixo.
210
+ - `grouping.fields` referencia filtros `select` com opções fechadas (`static` ou
211
+ `dictionary`). A interface consulta cada valor como um recorte da mesma action; não agrupa só a
212
+ página carregada.
209
213
 
210
214
  ```ts
211
215
  type FilterSpec = {
@@ -259,7 +263,10 @@ o dado necessário. A action `view` que lê o recurso declara as três coisas em
259
263
  um intermediário `null` aparece como `null`. Num campo anulável ou numa união, o `opus gen` aceita
260
264
  o caminho que existe em algum ramo não nulo. Um caminho que termina num objeto ou numa lista leva
261
265
  tudo abaixo dele (`assistantContextLeaves`, de `@softize/opus/assistant-schema`, lista o que cada
262
- caminho alcança). `title` aponta para texto ou número, fora de lista.
266
+ caminho alcança: em `leaves`, as folhas; em `open`, os pontos por onde qualquer campo passa sem
267
+ conferência, como um schema sem forma, um `$ref` recursivo ou que não resolve e, como
268
+ `<caminho>.*`, um objeto com `additionalProperties: true`). `title` aponta para texto ou número,
269
+ fora de lista.
263
270
  - **Leitura.** `runtime.readResource(resource, id, base, { context? })` executa a action dona com
264
271
  `{ id }` pelo `execute`: autorização, loaders, auditoria, marca de dado pessoal e span são os da
265
272
  action. Devolve `{ ok: true, resource: { kind, id, title, label, skill }, context?, meta }` ou o
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softize/opus",
3
- "version": "25.2.0",
3
+ "version": "25.3.1",
4
4
  "description": "End-to-end action protocol for TypeScript. Single package with subpath exports (core + adapters).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -9,8 +9,9 @@
9
9
  * resto server-only) — ver `contracts.ts`. `defineAction` continua válido e
10
10
  * suportado para action server-only, em que não há contrato a compartilhar.
11
11
  *
12
- * Validação estrutural acontece no `Runtime.register()`, não aqui. Esta função
13
- * é puramente sobre **declaração**.
12
+ * A factory valida contradições que existem inteiras na declaração, como um
13
+ * `grouping` que referencia um filtro incompatível. O runtime repete essas
14
+ * guardas para actions montadas sem a factory.
14
15
  *
15
16
  * Ver §2 do protocolo.
16
17
  */
@@ -22,6 +23,7 @@ import type {
22
23
  SimpleAction,
23
24
  ViewAction,
24
25
  } from './types.ts'
26
+ import { assertValidListGrouping } from './list-grouping.ts'
25
27
 
26
28
  /* eslint-disable @typescript-eslint/no-explicit-any */
27
29
 
@@ -61,6 +63,7 @@ export function defineAction<In, Out, ParsedIn = In>(
61
63
  ): ViewAction<In, Out, ParsedIn>
62
64
 
63
65
  export function defineAction(spec: any): any {
66
+ if (spec.kind === 'list') assertValidListGrouping(spec)
64
67
  return spec
65
68
  }
66
69
 
@@ -28,6 +28,7 @@ import type {
28
28
  SimpleAction,
29
29
  ViewAction,
30
30
  } from './types.ts'
31
+ import { assertValidListGrouping } from './list-grouping.ts'
31
32
 
32
33
  /* eslint-disable @typescript-eslint/no-explicit-any */
33
34
 
@@ -117,6 +118,7 @@ export function defineContract<In, Out, ParsedIn = In>(
117
118
  contract: ViewContract<In, Out, ParsedIn>,
118
119
  ): ViewContract<In, Out, ParsedIn>
119
120
  export function defineContract(contract: any): any {
121
+ if (contract.kind === 'list') assertValidListGrouping(contract)
120
122
  return contract
121
123
  }
122
124
 
@@ -0,0 +1,45 @@
1
+ import type { ListAction } from './types.ts'
2
+
3
+ const MAX_GROUP_OPTIONS = 12
4
+
5
+ /** Valida a parte estrutural de `ListAction.grouping` no momento da declaração.
6
+ * Dicionários são resolvidos pelo provider e têm a cardinalidade conferida pela UI. */
7
+ export function assertValidListGrouping(
8
+ action: Pick<ListAction<unknown, unknown>, 'name' | 'filters' | 'grouping' | 'paginate'>,
9
+ ): void {
10
+ const fields = action.grouping?.fields
11
+ if (fields === undefined) return
12
+ if (fields.length === 0) {
13
+ throw new Error(`A action "${action.name}" declara grouping.fields vazio.`)
14
+ }
15
+ if (new Set(fields).size !== fields.length) {
16
+ throw new Error(`A action "${action.name}" repete uma dimensão em grouping.fields.`)
17
+ }
18
+ if (action.filters?.group !== undefined) {
19
+ throw new Error(`A action "${action.name}" usa o filtro reservado "group" enquanto declara grouping.`)
20
+ }
21
+ if (action.paginate !== 'offset') {
22
+ throw new Error(`A action "${action.name}" declara grouping, mas o agrupamento exige paginate: "offset".`)
23
+ }
24
+ for (const field of fields) {
25
+ const filter = action.filters?.[field]
26
+ if (filter === undefined) {
27
+ throw new Error(`A action "${action.name}" agrupa por "${field}", mas esse filtro não existe.`)
28
+ }
29
+ if (filter.type !== 'select' || (filter.options?.kind !== 'static' && filter.options?.kind !== 'dictionary')) {
30
+ throw new Error(
31
+ `A action "${action.name}" agrupa por "${field}", mas a dimensão precisa ser ` +
32
+ 'um filtro select com opções static ou dictionary.',
33
+ )
34
+ }
35
+ if (
36
+ filter.options.kind === 'static' &&
37
+ (filter.options.items.length === 0 || filter.options.items.length > MAX_GROUP_OPTIONS)
38
+ ) {
39
+ throw new Error(
40
+ `A action "${action.name}" agrupa por "${field}", mas a dimensão precisa ter ` +
41
+ `entre 1 e ${MAX_GROUP_OPTIONS} opções.`,
42
+ )
43
+ }
44
+ }
45
+ }
@@ -13,6 +13,7 @@
13
13
 
14
14
  import { isReaction } from './reactions.ts'
15
15
  import { isBackgroundAction } from './actions.ts'
16
+ import { assertValidListGrouping } from './list-grouping.ts'
16
17
  import { isSchedule } from './schedules.ts'
17
18
  import {
18
19
  collectDomainWarnings,
@@ -547,6 +548,7 @@ export class Runtime {
547
548
  })
548
549
  }
549
550
  this.claimAssistant(action)
551
+ if (action.kind === 'list') assertValidListGrouping(action)
550
552
  this.actions.set(action.name, action)
551
553
  if (this.config.dev) this.warnIgnoredExecutionFields(action)
552
554
  }
package/src/core/types.ts CHANGED
@@ -924,6 +924,9 @@ export interface SearchFilters {
924
924
  paginate?: 'cursor' | 'offset' | false
925
925
  text?: { fields: string[]; placeholder?: I18nRef }
926
926
  periods?: PeriodSpec[]
927
+ /** Dimensões fechadas que a ActionList pode usar para agrupar a tabela. Cada nome
928
+ * referencia um filtro select com opções static ou dictionary. */
929
+ grouping?: { fields: string[] }
927
930
  /**
928
931
  * Cláusula DSL aplicada **sempre** pelo repository do consumer (ex:
929
932
  * `'NOT archived'`). Runtime NÃO executa SQL — quem consome a search
@@ -956,6 +959,8 @@ export interface ListAction<In, Out, ParsedIn = In>
956
959
  paginate?: 'cursor' | 'offset' | false
957
960
  text?: { fields: string[]; placeholder?: I18nRef }
958
961
  periods?: PeriodSpec[]
962
+ /** Dimensões fechadas que a ActionList pode usar para agrupar a tabela. */
963
+ grouping?: { fields: string[] }
959
964
  /**
960
965
  * Ver `SearchFilters.where`. Baseline DSL aplicado pelo repository
961
966
  * via `compileWhere` (do `@softize/opus/dsl`). Runtime não executa SQL.
@@ -0,0 +1,88 @@
1
+ import { useQueries } from '@tanstack/react-query'
2
+ import { useRef } from 'react'
3
+ import type { ActionDef, ActionError, Paginated } from '../../../core/index.ts'
4
+ import { useClient } from '../../drivers/context.tsx'
5
+
6
+ export interface ListActionGroup<TItem> {
7
+ value: string
8
+ items: TItem[]
9
+ total: number | undefined
10
+ error: ActionError | undefined
11
+ isLoading: boolean
12
+ isFetching: boolean
13
+ isError: boolean
14
+ refetch: () => Promise<void>
15
+ }
16
+
17
+ /** Implementação interna do ActionList para executar um recorte paginado por grupo. */
18
+ export function useGroupedListAction<TItem = unknown, TInput extends Record<string, unknown> = Record<string, unknown>>(
19
+ action: { name: string; kind: 'list' },
20
+ input: TInput,
21
+ field: string | null,
22
+ values: string[],
23
+ multiple: boolean,
24
+ pages: Record<string, number>,
25
+ pageSize: number,
26
+ ): ListActionGroup<TItem>[] {
27
+ const client = useClient('ActionList grouping')
28
+ const previous = useRef<{
29
+ field: string | null
30
+ groups: Map<string, { items: TItem[]; total: number }>
31
+ }>({ field, groups: new Map() })
32
+ if (previous.current.field !== field) {
33
+ previous.current = { field, groups: new Map() }
34
+ }
35
+ const groupField = field ?? '__disabled_group__'
36
+ const requests =
37
+ field === null
38
+ ? []
39
+ : values.flatMap((value) =>
40
+ Array.from({ length: pages[value] ?? 1 }, (_, index) => ({
41
+ value,
42
+ page: index + 1,
43
+ })),
44
+ )
45
+ const queries = useQueries({
46
+ queries: requests.map(({ value, page }) => {
47
+ const groupInput = {
48
+ ...input,
49
+ [groupField]: multiple ? [value] : value,
50
+ page,
51
+ limit: pageSize,
52
+ }
53
+ return {
54
+ queryKey: [action.name, groupInput],
55
+ queryFn: async (): Promise<Paginated<TItem>> => {
56
+ const result = await client.run<Paginated<TItem>>(action as unknown as ActionDef, groupInput)
57
+ if (!result.ok) throw result.error
58
+ return result.data
59
+ },
60
+ }
61
+ }),
62
+ })
63
+
64
+ return values.map((value) => {
65
+ const groupQueries = queries.filter((_, index) => requests[index]?.value === value)
66
+ const items = groupQueries.flatMap((query) => query.data?.items ?? [])
67
+ const total = groupQueries.find((query) => query.data?.total !== undefined)?.data?.total
68
+ const isError = groupQueries.some((query) => query.isError)
69
+ const hasCompleteData = groupQueries.length > 0 && groupQueries.every((query) => query.data !== undefined)
70
+ if (hasCompleteData && !isError && total !== undefined) {
71
+ previous.current.groups.set(value, { items, total })
72
+ }
73
+ const fallback = previous.current.groups.get(value)
74
+ const hasCurrentData = groupQueries.some((query) => query.data !== undefined)
75
+ return {
76
+ value,
77
+ items: hasCurrentData ? items : (fallback?.items ?? []),
78
+ total: hasCurrentData ? total : fallback?.total,
79
+ error: (groupQueries.find((query) => query.error !== null)?.error as unknown as ActionError | null) ?? undefined,
80
+ isLoading: (groupQueries[0]?.isPending ?? true) && fallback === undefined,
81
+ isFetching: groupQueries.some((query) => query.isFetching),
82
+ isError,
83
+ refetch: async () => {
84
+ await Promise.all(groupQueries.map((query) => query.refetch()))
85
+ },
86
+ }
87
+ })
88
+ }