@softize/opus 18.2.0 → 18.4.0

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,32 @@ 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
+ ## 18.4.0 — 2026-09-21
11
+
12
+ `executions` acrescenta infraestrutura experimental de acompanhamento: snapshots versionados,
13
+ store PostgreSQL com aviso transacional, assinante LISTEN com reconexão e SSE autenticado de
14
+ invalidação. Não há instrumentação automática do runtime, nova fila nem controles de negócio.
15
+ O consumidor precisa adaptar seus executores, persistir a revisão, autorizar leituras/avisos e
16
+ reconciliar a projeção. Nenhuma migration ou conexão é criada ao importar o módulo.
17
+
18
+ ## 18.3.0 — 2026-09-17
19
+
20
+ `ActionList` passa a oferecer composição previsível para barras densas: filtros inline de valor
21
+ único abraçam o conteúdo até o limite da barra, a busca ocupa o espaço flexível restante, dropdowns
22
+ podem ser mais largos que o controle e filtros pesquisáveis podem omitir a opção vazia. Valores de
23
+ repouso, ícones por filtro, agrupamento de tipo de data com período e ícones nas seções avançadas
24
+ ficam declarados pelas props do pattern. A barra deixa de transformar filtros inline em chips por
25
+ falta de espaço; a página preserva sua largura mínima e delega o overflow à superfície correta.
26
+
27
+ Listagens podem usar `layout="fill"` para ocupar a altura disponível e manter a rolagem dentro da
28
+ tabela, com cabeçalho fixo e a mesma moldura nos estados vazio, carregando e erro. `Page` e
29
+ `Presentation` propagam esse contrato sem criar uma segunda rolagem no frame.
30
+
31
+ `MetricCard` aceita `secondaryMetric` para uma leitura complementar diretamente relacionada,
32
+ exibida como lâmina posterior com explicação própria. `FieldLegend` aceita ícone e a variante
33
+ `section`; o `Select` equaliza a ação de limpar com os demais campos e permite que sua lista cresça
34
+ além do gatilho sem alterar o layout.
35
+
10
36
  ## 18.2.0 — 2026-09-16
11
37
 
12
38
  `Presentation` passa a aceitar um body serializável baseado em componente. O contrato continua
package/README.md CHANGED
@@ -33,6 +33,7 @@ Um pacote (`@softize/opus`), uma versão. Cada categoria abaixo é um **subpath
33
33
  | `@softize/opus/audit` | `/console`, `/pg` | Destinos de auditoria |
34
34
  | `@softize/opus/log` | `/pino` | Adapter de log (console é o default no core) |
35
35
  | `@softize/opus/queue` | `/bullmq` | Execução de jobs em segundo plano |
36
+ | `@softize/opus/executions` | `/memory`, `/pg`, `/pg-changes`, `/stream` | Projeção e SSE de acompanhamento (experimental, opt-in) |
36
37
  | `@softize/opus/events` | `/mitt` | EventBus em processo |
37
38
  | `@softize/opus/scheduler` | `/node-cron` | Adapter de agendamento (ação iniciada por tempo) |
38
39
  | `@softize/opus/storage` | `/fs`, `/s3` | Armazenamento de arquivos (experimental) |
@@ -0,0 +1,39 @@
1
+ # ADR 0022 — Acompanhamento de execução é uma projeção, não um motor
2
+
3
+ - **Status:** aceita para candidato local.
4
+ - **Data:** 2026-09-21.
5
+
6
+ ## Contexto
7
+
8
+ O runtime já oferece fila, scheduler e progresso. Consumidores também executam trabalho fora
9
+ dele. Uma interface comum de acompanhamento não deve obrigar a migração desses motores nem
10
+ centralizar regras de negócio, retry ou autorização no SDK.
11
+
12
+ ## Decisão
13
+
14
+ Adicionar uma fronteira `executions`: snapshots operacionais versionados, store de projeção e
15
+ avisos de invalidação. O executor fornece ID e revisão estáveis; o store só aceita revisão
16
+ maior e preserva tenant e operação. A integração é explícita: não inferir cobertura de cron,
17
+ filas ou processos arbitrários só por instalar o pacote.
18
+
19
+ O snapshot não transporta input/output do job, erro livre, contatos ou credenciais. Ausência de
20
+ heartbeat é distinta de heartbeat vencido. Nenhum desses estados dispara execução ou retry.
21
+ Drivers concretos ficam fora do core. PostgreSQL persiste a projeção e publica NOTIFY na mesma
22
+ transação; LISTEN e SSE são avisos transitórios, recuperados por leitura após reconexão.
23
+
24
+ O transporte exige autorização do consumidor, limita o buffer e não envia snapshot pelo SSE.
25
+ Não há nova fila, API de controle universal, tabela de notificações nem substituição de audit
26
+ ou tracing. O consumidor trata falha de projeção fora da política de retry do negócio.
27
+
28
+ ## Alternativas
29
+
30
+ Estender `JobHandle` para substituir todos os diários confundiria observação com autoridade.
31
+ Um event bus em memória não atende workers em processos distintos. Replicar polling e stores
32
+ por aplicação repetiria infraestrutura. A projeção opt-in preserva os motores existentes e
33
+ permite adoção incremental, ao custo de instrumentação e reconciliação explícitas.
34
+
35
+ ## Verificação
36
+
37
+ Testar revisão obsoleta, isolamento, snapshot malformado, aviso após commit, reconexão,
38
+ revogação de acesso, limites de buffer e cancelamento. A API não deve aceitar payload arbitrário
39
+ nem possuir método para executar/repetir trabalho. Publicar somente após os gates do SDK.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softize/opus",
3
- "version": "18.2.0",
3
+ "version": "18.4.0",
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",
@@ -103,6 +103,26 @@
103
103
  "types": "./src/observability/drivers/opentelemetry.ts",
104
104
  "default": "./src/observability/drivers/opentelemetry.ts"
105
105
  },
106
+ "./executions": {
107
+ "types": "./src/executions/index.ts",
108
+ "default": "./src/executions/index.ts"
109
+ },
110
+ "./executions/memory": {
111
+ "types": "./src/executions/drivers/memory.ts",
112
+ "default": "./src/executions/drivers/memory.ts"
113
+ },
114
+ "./executions/pg": {
115
+ "types": "./src/executions/drivers/pg.ts",
116
+ "default": "./src/executions/drivers/pg.ts"
117
+ },
118
+ "./executions/pg-changes": {
119
+ "types": "./src/executions/drivers/pg-changes.ts",
120
+ "default": "./src/executions/drivers/pg-changes.ts"
121
+ },
122
+ "./executions/stream": {
123
+ "types": "./src/executions/stream.ts",
124
+ "default": "./src/executions/stream.ts"
125
+ },
106
126
  "./queue": {
107
127
  "types": "./src/queue/index.ts",
108
128
  "default": "./src/queue/index.ts"
@@ -37,8 +37,9 @@ existentes, mantendo navegação observável e componentes reutilizáveis sem la
37
37
  permanece sempre presente.
38
38
  6. Deixar ausência, paginação e largura de filtro com o pattern: célula e `DetailField` já
39
39
  representam valor ausente (`EmptyValue`, `empty` para o significado do domínio); listas
40
- paginam pela primitiva `Pagination`; selects inline de filtro têm largura fixa. Não reescrever
41
- esses defaults na tela.
40
+ paginam pela primitiva `Pagination`; selects inline de valor único abraçam o valor até o limite
41
+ definido pela barra, e o menu pode ser mais largo que o controle. Não reescrever esses defaults
42
+ na tela.
42
43
  7. Tratar página, filtro, seleção e modal importante como estado navegável por URL quando
43
44
  o produto precisa de deep link, back/forward ou refresh. Em `Presentation`, declarar `route`
44
45
  no artefato e deixar o adaptador apenas conectar a spec ao router da aplicação.
@@ -122,9 +122,10 @@ ContentDescription, ContentMeta, ContentActions) + ContentBody`. `ContentHeader`
122
122
  - Paginação é a primitiva `Pagination`, inclusive dentro de `ActionList`. Número de página tem
123
123
  largura mínima e cresce com os dígitos; só as setas são quadradas. Não montar paginador com
124
124
  `Button` nem prender números a `size-*`.
125
- - Select inline de filtro tem largura fixa (`w-40`; lookup e múltiplo `w-52`): a label trunca, o
126
- valor trunca no trigger e a opção inteira fica na lista. Não dimensionar filtro pelo conteúdo
127
- nem pela label; o modal usa `w-full`.
125
+ - Select inline de valor único abraça o valor selecionado até `max-w-80`. Acima desse limite, o
126
+ valor trunca no gatilho e a opção inteira permanece na lista, que pode ser mais larga.
127
+ Multiseleção mantém `w-52`; o modal usa `w-full`. Não recalcular essa largura na tela nem
128
+ dimensioná-la pela label do filtro.
128
129
  - Catálogo e API efetivos vêm dos exports da versão instalada, não de memória ou exemplo antigo.
129
130
  - Em componentes semânticos, `context` responde por que há destaque e `variant` responde como ele
130
131
  aparece. O vocabulário canônico é `neutral | primary | info | success | warning | danger`; as
@@ -93,7 +93,6 @@ class ActionErrorImpl extends Error implements ActionError {
93
93
  readonly i18nParams?: Record<string, unknown>
94
94
  readonly field?: string
95
95
  readonly issues?: ValidationIssue[]
96
- override readonly cause?: unknown
97
96
  readonly meta?: Record<string, unknown>
98
97
 
99
98
  constructor(input: ErrorInput) {
package/src/core/types.ts CHANGED
@@ -598,6 +598,10 @@ export interface FilterSpec {
598
598
 
599
599
  type: 'text' | 'select' | 'lookup' | 'date' | 'number'
600
600
  multiple?: boolean
601
+ /** Habilita busca local nas opções de um `select`; `lookup` já é pesquisável por natureza. */
602
+ searchable?: boolean
603
+ /** Quando `false`, a interface omite a opção vazia. Limpar restaura o default do filtro. */
604
+ allowEmpty?: boolean
601
605
  mode?: 'server' | 'client'
602
606
  operators?: FilterOp[]
603
607
  path?: string
@@ -0,0 +1,39 @@
1
+ import { executionNotice, executionQuery, executionSnapshot } from '../index.ts'
2
+ import type { ExecutionChanges, ExecutionNotice, ExecutionSnapshot, ExecutionStore } from '../index.ts'
3
+
4
+ /** Driver de testes. Não é durável nem observa outros processos. */
5
+ export function memoryExecutions(): ExecutionStore & ExecutionChanges {
6
+ const snapshots = new Map<string, ExecutionSnapshot>()
7
+ const listeners = new Set<(notice: ExecutionNotice | null) => void>()
8
+ return {
9
+ async observe(input) {
10
+ const next = executionSnapshot(input)
11
+ const current = snapshots.get(next.id)
12
+ if (current && (current.revision >= next.revision || current.tenantId !== next.tenantId ||
13
+ current.operation !== next.operation || current.createdAt !== next.createdAt || current.trigger !== next.trigger)) return false
14
+ snapshots.set(next.id, next)
15
+ for (const listener of listeners) {
16
+ // Um assinante não participa da confirmação da projeção.
17
+ try { listener(executionNotice(next)) } catch { /* transporte já desconectado */ }
18
+ }
19
+ return true
20
+ },
21
+ async get(id, tenantId) {
22
+ executionQuery({ tenantId, operations: [] })
23
+ const snapshot = snapshots.get(id)
24
+ return snapshot && snapshot.tenantId === tenantId ? structuredClone(snapshot) : null
25
+ },
26
+ async list(input) {
27
+ const query = executionQuery(input)
28
+ return [...snapshots.values()]
29
+ .filter((snapshot) => snapshot.tenantId === query.tenantId && query.operations.includes(snapshot.operation))
30
+ .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt) || a.id.localeCompare(b.id))
31
+ .slice(0, query.limit).map((snapshot) => structuredClone(snapshot))
32
+ },
33
+ subscribe(listener) {
34
+ const subscription = (notice: ExecutionNotice | null) => listener(notice)
35
+ listeners.add(subscription)
36
+ return () => { listeners.delete(subscription) }
37
+ },
38
+ }
39
+ }
@@ -0,0 +1,104 @@
1
+ import { parseExecutionNotice } from '../index.ts'
2
+ import type { ExecutionChanges, ExecutionNotice } from '../index.ts'
3
+
4
+ export interface ExecutionPgListener {
5
+ query(sql: string): Promise<unknown>
6
+ on(event: 'notification', listener: (message: { channel: string; payload?: string }) => void): unknown
7
+ on(event: 'error' | 'end', listener: () => void): unknown
8
+ removeListener(event: 'notification', listener: (message: { channel: string; payload?: string }) => void): unknown
9
+ removeListener(event: 'error' | 'end', listener: () => void): unknown
10
+ end(): Promise<unknown>
11
+ }
12
+
13
+ /** Uma conexão LISTEN compartilhada entre assinantes; nunca usar conexão de transação/pooler. */
14
+ export function pgExecutionChanges(options: {
15
+ /** Conexão nova, já conectada e com connectionTimeoutMillis definido pelo consumidor. */
16
+ connect: () => Promise<ExecutionPgListener>
17
+ onError: (error: unknown) => void
18
+ channel?: string
19
+ reconnectMs?: number
20
+ }): ExecutionChanges {
21
+ const channel = options.channel ?? 'opus_executions'
22
+ if (!/^[a-z_][a-z0-9_]{0,62}$/.test(channel)) throw new Error('Invalid execution channel')
23
+ const reconnectMs = options.reconnectMs ?? 1_000
24
+ if (!Number.isFinite(reconnectMs) || reconnectMs < 100) throw new Error('Invalid execution reconnect delay')
25
+ const listeners = new Set<{ notify: (notice: ExecutionNotice | null) => void; unavailable?: () => void }>()
26
+ let generation = 0
27
+ let cleanup: (() => void) | undefined
28
+ let retry: ReturnType<typeof setTimeout> | undefined
29
+
30
+ const report = (error: unknown) => { try { options.onError(error) } catch { /* diagnóstico não controla LISTEN */ } }
31
+ const broadcast = (notice: ExecutionNotice | null) => {
32
+ for (const listener of listeners) {
33
+ try { listener.notify(notice) } catch (error) { report(error) }
34
+ }
35
+ }
36
+ const close = (client: ExecutionPgListener) => {
37
+ const closingError = () => report(new Error('Execution change feed failed while closing'))
38
+ client.on('error', closingError)
39
+ void client.end().catch(report).finally(() => client.removeListener('error', closingError))
40
+ }
41
+
42
+ async function connect(): Promise<void> {
43
+ const ownGeneration = ++generation
44
+ try {
45
+ const client = await options.connect()
46
+ if (ownGeneration !== generation || listeners.size === 0) { close(client); return }
47
+ const notification = (message: { channel: string; payload?: string }) => {
48
+ if (message.channel !== channel) return
49
+ try { broadcast(parseExecutionNotice(JSON.parse(message.payload ?? ''))) } catch (error) { report(error) }
50
+ }
51
+ const disconnected = () => {
52
+ if (ownGeneration !== generation) return
53
+ report(new Error('Execution change feed disconnected'))
54
+ reconnect()
55
+ }
56
+ client.on('notification', notification)
57
+ client.on('error', disconnected)
58
+ client.on('end', disconnected)
59
+ cleanup = () => {
60
+ client.removeListener('notification', notification)
61
+ client.removeListener('error', disconnected)
62
+ client.removeListener('end', disconnected)
63
+ close(client)
64
+ }
65
+ await client.query(`LISTEN "${channel}"`)
66
+ if (ownGeneration === generation) broadcast(null)
67
+ } catch (error) {
68
+ if (ownGeneration !== generation) return
69
+ report(error)
70
+ reconnect()
71
+ }
72
+ }
73
+
74
+ function disconnect(): void {
75
+ ++generation
76
+ if (retry !== undefined) { clearTimeout(retry); retry = undefined }
77
+ const dispose = cleanup
78
+ cleanup = undefined
79
+ dispose?.()
80
+ }
81
+
82
+ function reconnect(): void {
83
+ disconnect()
84
+ const disconnectedGeneration = generation
85
+ for (const listener of [...listeners]) {
86
+ try { listener.unavailable?.() } catch (error) { report(error) }
87
+ }
88
+ if (generation === disconnectedGeneration && listeners.size > 0) {
89
+ retry = setTimeout(() => { retry = undefined; void connect() }, reconnectMs)
90
+ }
91
+ }
92
+
93
+ return {
94
+ subscribe(listener, onUnavailable) {
95
+ const subscription = { notify: listener, unavailable: onUnavailable }
96
+ listeners.add(subscription)
97
+ if (listeners.size === 1) void connect()
98
+ return () => {
99
+ listeners.delete(subscription)
100
+ if (listeners.size === 0) disconnect()
101
+ }
102
+ },
103
+ }
104
+ }
@@ -0,0 +1,74 @@
1
+ import { executionQuery, executionSnapshot } from '../index.ts'
2
+ import type { ExecutionSnapshot, ExecutionStore } from '../index.ts'
3
+
4
+ export interface ExecutionPgPool {
5
+ query(text: string, values?: unknown[]): Promise<{ rows: Record<string, unknown>[] }>
6
+ }
7
+
8
+ export interface PgExecutionsOptions {
9
+ pool: ExecutionPgPool
10
+ table?: string
11
+ channel?: string
12
+ }
13
+
14
+ function sqlIdentifier(value: string): string {
15
+ if (!/^[a-z_][a-z0-9_]{0,62}$/.test(value)) throw new Error('Invalid execution SQL identifier')
16
+ return `"${value}"`
17
+ }
18
+
19
+ /** SQL para a migration do consumidor. O driver não aplica DDL nem grants automaticamente. */
20
+ export function executionTableSql(table = 'execution_snapshots'): string {
21
+ const name = sqlIdentifier(table)
22
+ return `CREATE TABLE ${name} (
23
+ id text PRIMARY KEY,
24
+ tenant_id text,
25
+ operation text NOT NULL,
26
+ revision bigint NOT NULL CHECK (revision > 0),
27
+ updated_at timestamptz NOT NULL,
28
+ snapshot jsonb NOT NULL
29
+ );
30
+ CREATE INDEX ON ${name} (tenant_id, operation, updated_at DESC, id);`
31
+ }
32
+
33
+ /** Escrita do snapshot e NOTIFY pertencem à mesma transação PostgreSQL. */
34
+ export function pgExecutions(options: PgExecutionsOptions): ExecutionStore {
35
+ const table = sqlIdentifier(options.table ?? 'execution_snapshots')
36
+ const channel = options.channel ?? 'opus_executions'
37
+ sqlIdentifier(channel)
38
+ return {
39
+ async observe(input) {
40
+ const next = executionSnapshot(input)
41
+ const result = await options.pool.query(`
42
+ WITH changed AS (
43
+ INSERT INTO ${table} AS current (id, tenant_id, operation, revision, updated_at, snapshot)
44
+ VALUES ($1, $2, $3, $4, $5, $6::jsonb)
45
+ ON CONFLICT (id) DO UPDATE SET revision = EXCLUDED.revision,
46
+ updated_at = EXCLUDED.updated_at, snapshot = EXCLUDED.snapshot
47
+ WHERE current.revision < EXCLUDED.revision
48
+ AND current.tenant_id IS NOT DISTINCT FROM EXCLUDED.tenant_id
49
+ AND current.operation = EXCLUDED.operation
50
+ AND current.snapshot->>'createdAt' = EXCLUDED.snapshot->>'createdAt'
51
+ AND current.snapshot->>'trigger' = EXCLUDED.snapshot->>'trigger'
52
+ RETURNING id, tenant_id, operation, revision
53
+ )
54
+ SELECT pg_notify($7, json_build_object('id', id, 'tenantId', tenant_id,
55
+ 'operation', operation, 'revision', revision)::text) FROM changed`,
56
+ [next.id, next.tenantId, next.operation, next.revision, next.updatedAt, JSON.stringify(next), channel])
57
+ return result.rows.length > 0
58
+ },
59
+ async get(id, tenantId) {
60
+ executionQuery({ tenantId, operations: [] })
61
+ const result = await options.pool.query(`SELECT snapshot FROM ${table} WHERE id = $1 AND tenant_id IS NOT DISTINCT FROM $2`, [id, tenantId])
62
+ const row = result.rows[0]
63
+ return row ? executionSnapshot(row.snapshot as ExecutionSnapshot) : null
64
+ },
65
+ async list(input) {
66
+ const query = executionQuery(input)
67
+ if (query.operations.length === 0) return []
68
+ const result = await options.pool.query(`SELECT snapshot FROM ${table}
69
+ WHERE tenant_id IS NOT DISTINCT FROM $1 AND operation = ANY($2::text[])
70
+ ORDER BY updated_at DESC, id ASC LIMIT $3`, [query.tenantId, query.operations, query.limit])
71
+ return result.rows.map((row) => executionSnapshot(row.snapshot as ExecutionSnapshot))
72
+ },
73
+ }
74
+ }
@@ -0,0 +1,129 @@
1
+ /** Projeção operacional. Estados e revisões são informados pelo executor, nunca inferidos aqui. */
2
+ export type ExecutionStatus = 'queued' | 'running' | 'paused' | 'succeeded' | 'failed' | 'cancelled'
3
+ export type ExecutionTrigger = 'manual' | 'schedule' | 'queue' | 'system'
4
+
5
+ export interface ExecutionSnapshot {
6
+ id: string
7
+ tenantId: string | null
8
+ operation: string
9
+ revision: number
10
+ status: ExecutionStatus
11
+ trigger: ExecutionTrigger
12
+ /** Tentativa da mesma execução; o executor continua responsável por sua política de retry. */
13
+ attempt: number
14
+ createdAt: string
15
+ updatedAt: string
16
+ startedAt?: string
17
+ finishedAt?: string
18
+ heartbeatAt?: string
19
+ progress?: { completed: number; total?: number }
20
+ }
21
+
22
+ export interface ExecutionNotice {
23
+ id: string
24
+ tenantId: string | null
25
+ operation: string
26
+ revision: number
27
+ }
28
+
29
+ export interface ExecutionQuery {
30
+ /** Obrigatório: null seleciona somente execuções sem tenant, nunca todos os tenants. */
31
+ tenantId: string | null
32
+ operations: readonly string[]
33
+ limit?: number
34
+ }
35
+
36
+ export interface ExecutionStore {
37
+ /** false: revisão obsoleta/repetida ou tentativa de alterar a identidade do registro. */
38
+ observe(snapshot: ExecutionSnapshot): Promise<boolean>
39
+ get(id: string, tenantId: string | null): Promise<ExecutionSnapshot | null>
40
+ list(query: ExecutionQuery): Promise<ExecutionSnapshot[]>
41
+ }
42
+
43
+ export interface ExecutionChanges {
44
+ /** null exige nova fotografia após conectar/reconectar. Não há replay de NOTIFY. */
45
+ subscribe(listener: (notice: ExecutionNotice | null) => void, onUnavailable?: () => void): () => void
46
+ }
47
+
48
+ const statuses: readonly ExecutionStatus[] = ['queued', 'running', 'paused', 'succeeded', 'failed', 'cancelled']
49
+ const triggers: readonly ExecutionTrigger[] = ['manual', 'schedule', 'queue', 'system']
50
+
51
+ function identifier(value: unknown): asserts value is string {
52
+ if (typeof value !== 'string' || value.length < 1 || value.length > 200 || /[\x00-\x1f]/.test(value)) {
53
+ throw new Error('Invalid execution identifier')
54
+ }
55
+ }
56
+
57
+ function integer(value: unknown, min: number): asserts value is number {
58
+ if (!Number.isSafeInteger(value) || (value as number) < min) throw new Error('Invalid execution counter')
59
+ }
60
+
61
+ function date(value: unknown): asserts value is string {
62
+ if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}T.*Z$/.test(value) || !Number.isFinite(Date.parse(value))) {
63
+ throw new Error('Invalid execution timestamp')
64
+ }
65
+ }
66
+
67
+ /** Valida e seleciona explicitamente campos; propriedades extras não chegam à persistência. */
68
+ export function executionSnapshot(input: ExecutionSnapshot): ExecutionSnapshot {
69
+ identifier(input.id)
70
+ identifier(input.operation)
71
+ if (input.tenantId !== null) identifier(input.tenantId)
72
+ integer(input.revision, 1)
73
+ integer(input.attempt, 0)
74
+ if (!statuses.includes(input.status) || !triggers.includes(input.trigger)) throw new Error('Invalid execution state')
75
+ date(input.createdAt)
76
+ date(input.updatedAt)
77
+ const result: ExecutionSnapshot = {
78
+ id: input.id, tenantId: input.tenantId, operation: input.operation,
79
+ revision: input.revision, status: input.status, trigger: input.trigger, attempt: input.attempt,
80
+ createdAt: new Date(input.createdAt).toISOString(), updatedAt: new Date(input.updatedAt).toISOString(),
81
+ }
82
+ for (const key of ['startedAt', 'finishedAt', 'heartbeatAt'] as const) {
83
+ if (input[key] !== undefined) { date(input[key]); result[key] = new Date(input[key]).toISOString() }
84
+ }
85
+ if (Date.parse(result.updatedAt) < Date.parse(result.createdAt)) throw new Error('Invalid execution chronology')
86
+ if (input.progress !== undefined) {
87
+ integer(input.progress.completed, 0)
88
+ result.progress = { completed: input.progress.completed }
89
+ if (input.progress.total !== undefined) {
90
+ integer(input.progress.total, input.progress.completed)
91
+ result.progress.total = input.progress.total
92
+ }
93
+ }
94
+ return result
95
+ }
96
+
97
+ export function executionNotice(snapshot: ExecutionSnapshot): ExecutionNotice {
98
+ return { id: snapshot.id, tenantId: snapshot.tenantId, operation: snapshot.operation, revision: snapshot.revision }
99
+ }
100
+
101
+ export function parseExecutionNotice(value: unknown): ExecutionNotice {
102
+ if (typeof value !== 'object' || value === null) throw new Error('Invalid execution notice')
103
+ const input = value as ExecutionNotice
104
+ identifier(input.id)
105
+ identifier(input.operation)
106
+ if (input.tenantId !== null) identifier(input.tenantId)
107
+ integer(input.revision, 1)
108
+ return { id: input.id, tenantId: input.tenantId, operation: input.operation, revision: input.revision }
109
+ }
110
+
111
+ export function executionQuery(query: ExecutionQuery): Required<ExecutionQuery> {
112
+ if (query.tenantId !== null) identifier(query.tenantId)
113
+ if (!Array.isArray(query.operations) || query.operations.length > 100) throw new Error('Invalid execution operations')
114
+ for (const operation of query.operations) identifier(operation)
115
+ const limit = query.limit ?? 50
116
+ integer(limit, 1)
117
+ if (limit > 200) throw new Error('Execution query limit exceeds 200')
118
+ return { tenantId: query.tenantId, operations: [...new Set(query.operations)], limit }
119
+ }
120
+
121
+ /** Diagnóstico de observação; nunca transforma o estado autoritativo em failed nem autoriza retry. */
122
+ export function executionLiveness(snapshot: ExecutionSnapshot, now: number, staleAfterMs: number): 'inactive' | 'unknown' | 'fresh' | 'stale' {
123
+ if (!Number.isFinite(now) || !Number.isFinite(staleAfterMs) || staleAfterMs <= 0) throw new Error('Invalid liveness clock')
124
+ if (snapshot.status !== 'running') return 'inactive'
125
+ if (snapshot.heartbeatAt === undefined) return 'unknown'
126
+ const heartbeat = Date.parse(snapshot.heartbeatAt)
127
+ if (!Number.isFinite(heartbeat) || heartbeat > now) return 'unknown'
128
+ return now - heartbeat >= staleAfterMs ? 'stale' : 'fresh'
129
+ }
@@ -0,0 +1,123 @@
1
+ import type { ExecutionChanges, ExecutionNotice } from './index.ts'
2
+
3
+ export interface ExecutionStreamOptions {
4
+ changes: ExecutionChanges
5
+ /** null: sessão e direito de acompanhar; notice: também autorizar a execução exata. */
6
+ authorize: (notice: ExecutionNotice | null) => Promise<boolean>
7
+ signal?: AbortSignal
8
+ heartbeatMs?: number
9
+ /** Renova a conexão e a autorização mesmo se não houver atividade. */
10
+ maxDurationMs?: number
11
+ onError: (error: unknown) => void
12
+ }
13
+
14
+ /**
15
+ * SSE sem dados de negócio. Ler a fotografia por actions autorizadas após cada invalidate.
16
+ * Assinar antes da primeira invalidação evita a lacuna entre fotografia e assinatura.
17
+ */
18
+ export async function executionStream(options: ExecutionStreamOptions): Promise<Response> {
19
+ const heartbeatMs = options.heartbeatMs ?? 15_000
20
+ const maxDurationMs = options.maxDurationMs ?? 300_000
21
+ if (!Number.isFinite(heartbeatMs) || heartbeatMs < 100 || !Number.isFinite(maxDurationMs) || maxDurationMs < heartbeatMs) {
22
+ throw new Error('Invalid execution stream timing')
23
+ }
24
+ if (options.signal?.aborted) return new Response(null, { status: 204 })
25
+ if (!await options.authorize(null)) return new Response(null, { status: 403 })
26
+ if (options.signal?.aborted) return new Response(null, { status: 204 })
27
+
28
+ const encoder = new TextEncoder()
29
+ let close = () => {}
30
+ let cancelled = false
31
+ const stream = new ReadableStream<Uint8Array>({
32
+ start(controller) {
33
+ let closed = false
34
+ let busy = false
35
+ let pendingReset = false
36
+ let pinging = false
37
+ let sessionCheck: Promise<boolean> | undefined
38
+ const pending = new Map<string, ExecutionNotice>()
39
+ let unsubscribe: (() => void) | undefined
40
+ let heartbeat: ReturnType<typeof setInterval> | undefined
41
+ let deadline: ReturnType<typeof setTimeout> | undefined
42
+ close = () => {
43
+ if (closed) return
44
+ closed = true
45
+ pending.clear()
46
+ unsubscribe?.()
47
+ clearInterval(heartbeat)
48
+ clearTimeout(deadline)
49
+ options.signal?.removeEventListener('abort', close)
50
+ if (!cancelled) controller.close()
51
+ }
52
+ const send = (message: string) => {
53
+ if (closed) return
54
+ // Uma conexão lenta reconecta e refaz a fotografia; nunca acumula progresso sem limite.
55
+ if ((controller.desiredSize ?? 0) <= 0) { close(); return }
56
+ controller.enqueue(encoder.encode(message))
57
+ }
58
+ const invalidate = () => send('event: invalidate\ndata: {}\n\n')
59
+ const report = (error: unknown) => { try { options.onError(error) } catch { /* não vazar erro no SSE */ } }
60
+
61
+ function sessionAllowed(): Promise<boolean> {
62
+ sessionCheck ??= Promise.resolve().then(() => options.authorize(null)).finally(() => { sessionCheck = undefined })
63
+ return sessionCheck
64
+ }
65
+
66
+ // A fila de autorizações por recurso não pode segurar a renovação da sessão ou o heartbeat.
67
+ async function ping(): Promise<void> {
68
+ if (pinging || closed) return
69
+ pinging = true
70
+ try {
71
+ if (!await sessionAllowed()) close()
72
+ else send(': heartbeat\n\n')
73
+ } catch (error) { report(error); close() }
74
+ finally { pinging = false }
75
+ }
76
+
77
+ async function flush(): Promise<void> {
78
+ if (busy || closed) return
79
+ busy = true
80
+ try {
81
+ while (!closed && (pending.size > 0 || pendingReset)) {
82
+ const notices = [...pending.values()]
83
+ pending.clear()
84
+ let allowed = pendingReset
85
+ pendingReset = false
86
+ if (!await sessionAllowed()) { close(); break }
87
+ for (const notice of notices) {
88
+ if (closed) break
89
+ if (await options.authorize(notice)) allowed = true
90
+ }
91
+ if (allowed && !closed) {
92
+ if (!await sessionAllowed()) { close(); break }
93
+ invalidate()
94
+ }
95
+ }
96
+ } catch (error) { report(error); close() }
97
+ finally { busy = false }
98
+ }
99
+
100
+ const dispose = options.changes.subscribe((notice) => {
101
+ if (closed) return
102
+ if (notice === null) pendingReset = true
103
+ else pending.set(notice.id, notice)
104
+ if (pending.size > 128) { close(); return }
105
+ void flush()
106
+ }, close)
107
+ unsubscribe = dispose
108
+ if (closed) { dispose(); return }
109
+ options.signal?.addEventListener('abort', close, { once: true })
110
+ if (options.signal?.aborted) { close(); return }
111
+ heartbeat = setInterval(() => { void ping() }, heartbeatMs)
112
+ deadline = setTimeout(close, maxDurationMs)
113
+ pendingReset = true
114
+ void flush()
115
+ },
116
+ cancel() { cancelled = true; close() },
117
+ }, { highWaterMark: 16 })
118
+ return new Response(stream, { headers: {
119
+ 'Content-Type': 'text/event-stream',
120
+ 'Cache-Control': 'no-cache, no-store',
121
+ 'X-Accel-Buffering': 'no',
122
+ } })
123
+ }
@@ -75,7 +75,7 @@ export function DataState({
75
75
  if (colSpan !== undefined) {
76
76
  return (
77
77
  <tr data-slot="data-state" data-status={status}>
78
- <td colSpan={colSpan} className="px-4 py-4 text-center text-sm">
78
+ <td colSpan={colSpan} className="px-4 py-4 text-center align-middle text-sm">
79
79
  {surface}
80
80
  </td>
81
81
  </tr>