@softize/opus 18.3.0 → 18.5.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 +20 -0
- package/README.md +1 -0
- package/docs/adr/0020-icon-tiles-are-explicitly-composed.md +46 -0
- package/docs/adr/0021-content-carries-structure-and-semantic-element.md +65 -0
- package/docs/adr/0022-execution-monitoring-is-a-projection.md +39 -0
- package/package.json +21 -1
- package/src/executions/drivers/memory.ts +39 -0
- package/src/executions/drivers/pg-changes.ts +104 -0
- package/src/executions/drivers/pg.ts +74 -0
- package/src/executions/index.ts +129 -0
- package/src/executions/stream.ts +123 -0
- package/src/ui/components/patterns/content-header.tsx +168 -41
- package/src/ui/components/patterns/list.tsx +43 -13
- package/src/ui/components/primitives/detail.tsx +1 -1
- package/src/ui/components/primitives/empty.tsx +2 -1
- package/src/ui/components/primitives/field.tsx +38 -16
- package/src/ui/components/primitives/icon-tile.tsx +91 -0
- package/src/ui/components/primitives/item.tsx +2 -1
- package/src/ui/components/primitives/table.tsx +1 -1
- package/src/ui/docs/content/action-list.md +61 -52
- package/src/ui/docs/content/content.md +84 -8
- package/src/ui/docs/content/executions.md +157 -0
- package/src/ui/docs/content/field.md +78 -30
- package/src/ui/docs/content/icon-tile.md +47 -0
- package/src/ui/docs/registry.tsx +13 -0
- package/src/ui/meta.ts +9 -3
- package/src/ui/react.tsx +17 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,26 @@ 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.5.0 — 2026-09-22
|
|
11
|
+
|
|
12
|
+
`Content` passa a concentrar estrutura e apresentação de blocos com cabeçalho e corpo: aceita
|
|
13
|
+
`as="section" | "fieldset"`, `variant="page" | "section"` e moldura opcional com
|
|
14
|
+
`frameVariant="default" | "legend"`. O novo
|
|
15
|
+
`IconTile` mantém a moldura do ícone como composição explícita, permitindo usar o mesmo ícone com
|
|
16
|
+
ou sem esse apoio visual.
|
|
17
|
+
|
|
18
|
+
`ActionList` deixa de aplicar moldura por padrão. No layout `fill`, mantém o cabeçalho fixo sobre a
|
|
19
|
+
cor da superfície, com borda inferior durante a rolagem. Filtros avançados podem escolher o tratamento das seções sem
|
|
20
|
+
recriar o fieldset no consumidor. Labels de campos e detalhes compartilham a mesma escala tipográfica.
|
|
21
|
+
|
|
22
|
+
## 18.4.0 — 2026-09-21
|
|
23
|
+
|
|
24
|
+
`executions` acrescenta infraestrutura experimental de acompanhamento: snapshots versionados,
|
|
25
|
+
store PostgreSQL com aviso transacional, assinante LISTEN com reconexão e SSE autenticado de
|
|
26
|
+
invalidação. Não há instrumentação automática do runtime, nova fila nem controles de negócio.
|
|
27
|
+
O consumidor precisa adaptar seus executores, persistir a revisão, autorizar leituras/avisos e
|
|
28
|
+
reconciliar a projeção. Nenhuma migration ou conexão é criada ao importar o módulo.
|
|
29
|
+
|
|
10
30
|
## 18.3.0 — 2026-09-17
|
|
11
31
|
|
|
12
32
|
`ActionList` passa a oferecer composição previsível para barras densas: filtros inline de valor
|
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,46 @@
|
|
|
1
|
+
# ADR 0020 — Molduras de ícone são compostas explicitamente
|
|
2
|
+
|
|
3
|
+
- **Status:** aceita.
|
|
4
|
+
- **Data:** 2026-09-18.
|
|
5
|
+
|
|
6
|
+
## Contexto
|
|
7
|
+
|
|
8
|
+
Várias interfaces apresentam um ícone dentro de uma pequena superfície quadrada. A mesma forma
|
|
9
|
+
aparecia incorporada em componentes como `FieldLegend`, `ItemMedia` e `EmptyMedia`, ou era
|
|
10
|
+
reconstruída pelo consumidor. Em `FieldLegend`, isso também vinculava a moldura à variante da
|
|
11
|
+
legenda: o consumidor conseguia escolher o ícone, mas não se ele deveria receber a superfície.
|
|
12
|
+
|
|
13
|
+
## Decisão
|
|
14
|
+
|
|
15
|
+
`IconTile` passa a representar a moldura visual não interativa de um ícone. Ele compartilha a
|
|
16
|
+
escala de tamanhos dos controles, declara a cor por `context` e oferece os tratamentos `subtle` e
|
|
17
|
+
`outline`.
|
|
18
|
+
|
|
19
|
+
Slots que aceitam conteúdo, como `FieldLegend.icon`, continuam genéricos. O consumidor passa um
|
|
20
|
+
ícone diretamente quando quer somente o glifo ou compõe `<IconTile><Icon /></IconTile>` quando a
|
|
21
|
+
moldura reforça a hierarquia. `ItemMedia variant="icon"` e `EmptyMedia variant="icon"` reutilizam
|
|
22
|
+
a mesma base visual porque essas variantes já prometem a moldura.
|
|
23
|
+
|
|
24
|
+
`IconTile` é somente apresentação. Quando o ícone aciona algo, deve-se usar `Button` com tamanho
|
|
25
|
+
de ícone e nome acessível.
|
|
26
|
+
|
|
27
|
+
## Consequências
|
|
28
|
+
|
|
29
|
+
- A presença da moldura deixa de ser uma regra implícita da legenda.
|
|
30
|
+
- Tamanho, arredondamento e preenchimento convergem entre consumidores.
|
|
31
|
+
- Um slot permanece flexível sem acumular propriedades como `iconFramed` ou `iconTile`.
|
|
32
|
+
- Migrações devem distinguir molduras decorativas de botões e indicadores semânticos.
|
|
33
|
+
|
|
34
|
+
## Alternativas descartadas
|
|
35
|
+
|
|
36
|
+
- **Adicionar uma propriedade à `FieldLegend`:** resolveria apenas uma superfície e ampliaria o
|
|
37
|
+
contrato toda vez que outra composição precisasse escolher a moldura.
|
|
38
|
+
- **Manter classes livres em cada consumidor:** preservaria divergências de escala e acabamento.
|
|
39
|
+
- **Usar `Button` desabilitado:** comunicaria interatividade onde não existe ação.
|
|
40
|
+
|
|
41
|
+
## Verificação
|
|
42
|
+
|
|
43
|
+
- teste estrutural cobre tamanho, variante e tamanho do glifo de `IconTile`;
|
|
44
|
+
- teste de `FieldLegend` confirma que o slot não adiciona moldura por conta própria;
|
|
45
|
+
- teste da API pública garante que o componente e seus metadados sejam exportados;
|
|
46
|
+
- documentação de `Field` mostra a composição explícita.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# ADR 0021 — Content reúne estrutura visual e elemento semântico
|
|
2
|
+
|
|
3
|
+
- **Status:** aceita.
|
|
4
|
+
- **Data:** 2026-09-19.
|
|
5
|
+
|
|
6
|
+
## Contexto
|
|
7
|
+
|
|
8
|
+
Regiões com título e corpo usam `Content`, mas grupos de formulário precisam reconstruir a mesma
|
|
9
|
+
estrutura com `FieldSet` e `FieldLegend`. Molduras aplicadas diretamente em `DetailGroup` também
|
|
10
|
+
separam visualmente o título dos dados que ele nomeia. Essa sobreposição aumenta a quantidade de
|
|
11
|
+
estruturas que a documentação e os consumidores precisam combinar.
|
|
12
|
+
|
|
13
|
+
## Decisão
|
|
14
|
+
|
|
15
|
+
`Content` continua responsável pela anatomia `Header + Body` e passa a aceitar:
|
|
16
|
+
|
|
17
|
+
- `framed`, que aplica a moldura à região inteira;
|
|
18
|
+
- `as="fieldset"`, que renderiza a raiz como `fieldset` e o título como `legend`.
|
|
19
|
+
- `frameVariant="legend"`, que integra visualmente a legenda à borda quando esse acabamento for
|
|
20
|
+
escolhido de forma explícita.
|
|
21
|
+
|
|
22
|
+
O default permanece `as="section"` sem moldura. `FieldGroup` organiza campos dentro do corpo e
|
|
23
|
+
`DetailGroup` preserva a semântica de lista de definições sem assumir a moldura da região.
|
|
24
|
+
`FieldSet` permanece disponível para compatibilidade e para composições que não precisam da
|
|
25
|
+
anatomia completa de `Content`.
|
|
26
|
+
|
|
27
|
+
No modo `fieldset`, a legenda aceita somente o título. Contador, descrição e ações não são filhos
|
|
28
|
+
válidos de `legend`; consumidores que precisarem dessas regiões devem mantê-las no corpo ou usar
|
|
29
|
+
uma `section`. A escolha de `fieldset` não define o acabamento visual: quando `framed` estiver
|
|
30
|
+
presente, a moldura é contínua por padrão. Os filtros avançados preservam a legenda integrada à
|
|
31
|
+
borda declarando `frameVariant="legend"`.
|
|
32
|
+
|
|
33
|
+
## Consequências
|
|
34
|
+
|
|
35
|
+
- A mesma API nomeia e delimita regiões de leitura e grupos de formulário.
|
|
36
|
+
- A escolha do elemento semântico não fica acoplada à presença da moldura.
|
|
37
|
+
- O recorte visual da borda deixa de ser consequência implícita do elemento semântico.
|
|
38
|
+
- A forma curta continua sendo o caminho comum, enquanto a composição explícita permanece
|
|
39
|
+
disponível nos dois elementos.
|
|
40
|
+
- A migração de `FieldSet` pode ser gradual e não exige remoção nesta versão.
|
|
41
|
+
|
|
42
|
+
## Alternativas consideradas
|
|
43
|
+
|
|
44
|
+
### Criar Panel
|
|
45
|
+
|
|
46
|
+
Separaria a moldura de `Content`, mas criaria outra raiz com header e body para documentar e
|
|
47
|
+
compor. Foi descartada porque `Content` já possui essa responsabilidade.
|
|
48
|
+
|
|
49
|
+
### Renomear FieldSet para FieldGroup
|
|
50
|
+
|
|
51
|
+
Produziria simetria nominal com `DetailGroup`, mas perderia a distinção entre a fronteira semântica
|
|
52
|
+
do formulário e o layout interno dos campos. Foi descartada porque `FieldGroup` já representa esse
|
|
53
|
+
layout.
|
|
54
|
+
|
|
55
|
+
### Adicionar um booleano fieldset
|
|
56
|
+
|
|
57
|
+
Permitiria alternar o elemento, mas trataria semântica como estado visual. Foi descartada em favor
|
|
58
|
+
de `as`, que explicita o elemento renderizado.
|
|
59
|
+
|
|
60
|
+
## Verificação
|
|
61
|
+
|
|
62
|
+
- Testes de UI confirmam `section` e heading como defaults.
|
|
63
|
+
- Testes de UI confirmam `fieldset`, `legend`, props nativas e ausência de heading nesse modo.
|
|
64
|
+
- Testes reprovam slots incompatíveis com a legenda.
|
|
65
|
+
- A documentação demonstra `Content + DetailGroup` e `Content as="fieldset" + FieldGroup`.
|
|
@@ -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.
|
|
3
|
+
"version": "18.5.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"
|
|
@@ -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
|
+
}
|