@softize/opus 24.1.0 → 25.0.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.
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Opus — identidade de sistema (ADR 0028).
3
+ *
4
+ * Mora fora do `runtime.ts` para que declarações puras do core, como o contrato de expurgo que o
5
+ * `flattenDomain` deriva (ADR 0051), possam usar `isSystemActor` sem importar o runtime.
6
+ */
7
+
8
+ import type { ActionContext } from './types.ts'
9
+
10
+ /**
11
+ * Contextos que o próprio runtime criou para uma execução do sistema. Um conjunto interno, e não
12
+ * a provenance, decide `isSystemActor`: qualquer código servidor pode montar uma provenance
13
+ * `schedule`, mas só o runtime acrescenta um contexto aqui.
14
+ */
15
+ const systemContexts = new WeakSet<object>()
16
+
17
+ /**
18
+ * Diz se a action está executando como o sistema: disparada por um schedule, ou no job que ele
19
+ * enfileirou, sem usuário. Use no `authorize` de uma action `automatable` para aceitar o sistema
20
+ * de forma explícita: `authorize: (ctx) => isSystemActor(ctx) || ctx.can('caixa:fechar')`.
21
+ * Passe o próprio `ctx` recebido: uma cópia, como `{ ...ctx }`, não é reconhecida.
22
+ */
23
+ export function isSystemActor(ctx: ActionContext): boolean {
24
+ return systemContexts.has(ctx)
25
+ }
26
+
27
+ /**
28
+ * Marca um contexto como execução do sistema. Exportado fora do index público, só para o runtime e
29
+ * o harness de `testing` aplicarem a mesma regra.
30
+ */
31
+ export function markSystemActor(ctx: ActionContext): void {
32
+ if (ctx.user === null) systemContexts.add(ctx)
33
+ }
package/src/core/types.ts CHANGED
@@ -21,6 +21,8 @@
21
21
  * 13. RuntimeConfig (§14)
22
22
  */
23
23
 
24
+ import type { SoftDeleteEntity } from './retention.ts'
25
+
24
26
  // =============================================================================
25
27
  // 1. Primitivos & shared
26
28
  // =============================================================================
@@ -1156,6 +1158,16 @@ export interface ServerAdapter extends Adapter {
1156
1158
  export interface DataAdapter extends Adapter {
1157
1159
  kind: 'data'
1158
1160
  contextExtension(): { db: unknown }
1161
+ /**
1162
+ * Apaga um lote de até `batchSize` linhas excluídas logicamente com `deletedAt` anterior a
1163
+ * `cutoff`, das mais antigas para as mais novas, e devolve os ids removidos. É o handler padrão
1164
+ * do expurgo (ADR 0051): o driver Kysely o implementa. Com um adapter sem este método, cada
1165
+ * entidade com `retainDays` precisa de `bindPurge`, senão o `start()` recusa.
1166
+ */
1167
+ purgeDeleted?(
1168
+ entity: SoftDeleteEntity,
1169
+ options: { cutoff: string; batchSize: number },
1170
+ ): Promise<{ ids: string[] }>
1159
1171
  }
1160
1172
 
1161
1173
  export interface AuthAdapter extends Adapter {
@@ -29,6 +29,7 @@ import { defineAction, error, type ActionDef, type AuthorizeFn } from '../../cor
29
29
  import type { DataAdapter, HealthStatus, Paginated } from '../../core/index.ts'
30
30
  import {
31
31
  desiredColumns,
32
+ hasSoftDelete,
32
33
  diffColumns,
33
34
  entityTable,
34
35
  entityPk,
@@ -81,9 +82,50 @@ export function kyselyData<DB>(options: KyselyDataOptions<DB>): DataAdapter {
81
82
  async dispose() {
82
83
  await db.destroy()
83
84
  },
85
+
86
+ // Handler padrão do expurgo (ADR 0051): o runtime o usa para toda entidade com `retainDays`
87
+ // sem `bindPurge` no domínio.
88
+ async purgeDeleted(entity, options) {
89
+ return purgeDeletedBatch(db as Kysely<unknown>, entity as EntityConfig, options)
90
+ },
84
91
  }
85
92
  }
86
93
 
94
+ /**
95
+ * Apaga um lote de linhas excluídas antes do corte, das mais antigas para as mais novas. A exclusão
96
+ * é uma instrução só: se uma chave estrangeira restritiva recusar uma linha, o banco recusa o lote
97
+ * inteiro e nada dele sai. A coluna segue o mesmo nome que o `kyselyRepo` usa (`deletedAt`).
98
+ */
99
+ async function purgeDeletedBatch(
100
+ db: Kysely<unknown>,
101
+ entity: EntityConfig,
102
+ options: { cutoff: string; batchSize: number },
103
+ ): Promise<{ ids: string[] }> {
104
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
105
+ const qb = db as Kysely<any>
106
+ const table = entityTable(entity)
107
+ const pk = entityPk(entity).name
108
+ const candidates = await qb
109
+ .selectFrom(table)
110
+ .select(pk)
111
+ .where('deletedAt', 'is not', null)
112
+ .where('deletedAt', '<', options.cutoff)
113
+ .orderBy('deletedAt')
114
+ .orderBy(pk)
115
+ .limit(options.batchSize)
116
+ .execute()
117
+ if (candidates.length === 0) return { ids: [] }
118
+ const keys = candidates.map((row) => row[pk] as unknown)
119
+ const deleted = await qb
120
+ .deleteFrom(table)
121
+ .where(pk, 'in', keys)
122
+ .where('deletedAt', '<', options.cutoff)
123
+ .returning(pk)
124
+ .execute()
125
+ const removed = new Set(deleted.map((row) => String(row[pk])))
126
+ return { ids: keys.map(String).filter((id) => removed.has(id)) }
127
+ }
128
+
87
129
  function round(n: number): number {
88
130
  return Math.round(n * 100) / 100
89
131
  }
@@ -136,7 +178,7 @@ export function kyselyRepo<E extends EntityConfig>(
136
178
  const now = options.now ?? (() => new Date().toISOString())
137
179
  const genId = options.genId ?? (() => randomUUID())
138
180
  const hasTimestamps = entity.timestamps === true
139
- const hasSoftDelete = entity.softDelete === true
181
+ const softDelete = hasSoftDelete(entity)
140
182
  // Campos t.json(): o repo serializa NA ESCRITA (insert/update). Sem isso, o pg
141
183
  // até stringifica objeto plano sozinho — mas ARRAY vira literal de array PG
142
184
  // (errado pra jsonb) sem quebrar typecheck. A declaração já sabe o tipo; a
@@ -175,7 +217,7 @@ export function kyselyRepo<E extends EntityConfig>(
175
217
 
176
218
  async findById(id) {
177
219
  let q = qb.selectFrom(table).selectAll().where(pk.name, '=', id)
178
- if (hasSoftDelete) q = q.where('deletedAt', 'is', null)
220
+ if (softDelete) q = q.where('deletedAt', 'is', null)
179
221
  const found = await q.executeTakeFirst()
180
222
  return (found ?? null) as EntityRow<E> | null
181
223
  },
@@ -193,7 +235,7 @@ export function kyselyRepo<E extends EntityConfig>(
193
235
  },
194
236
 
195
237
  async remove(id) {
196
- if (hasSoftDelete) {
238
+ if (softDelete) {
197
239
  await qb.updateTable(table).set({ deletedAt: now() }).where(pk.name, '=', id).execute()
198
240
  return
199
241
  }
@@ -202,7 +244,7 @@ export function kyselyRepo<E extends EntityConfig>(
202
244
 
203
245
  async all() {
204
246
  let q = qb.selectFrom(table).selectAll()
205
- if (hasSoftDelete) q = q.where('deletedAt', 'is', null)
247
+ if (softDelete) q = q.where('deletedAt', 'is', null)
206
248
  const rows = await q.execute()
207
249
  return rows as EntityRow<E>[]
208
250
  },
@@ -211,7 +253,7 @@ export function kyselyRepo<E extends EntityConfig>(
211
253
  const limit = params.limit ?? 50
212
254
  const offset = params.offset ?? 0
213
255
  const applyFilters = (query: any): any => {
214
- let out = hasSoftDelete ? query.where('deletedAt', 'is', null) : query
256
+ let out = softDelete ? query.where('deletedAt', 'is', null) : query
215
257
  if (params.where !== undefined) {
216
258
  for (const [col, value] of Object.entries(params.where)) {
217
259
  out = out.where(col, '=', value)
package/src/data/index.ts CHANGED
@@ -1,10 +1,28 @@
1
1
  /**
2
- * @softize/opus/data — shared helpers/types for data drivers.
2
+ * @softize/opus/data — helpers e tipos compartilhados da camada de dados, server-only.
3
3
  *
4
- * Empty for now — drivers (kysely, drizzle) são thin wrappers que apenas
5
- * implementam o contrato `DataAdapter` do core. Quando aparecer código
6
- * compartilhado (helpers de transação, observability hooks, etc),
7
- * mora aqui.
4
+ * Os drivers (kysely) implementam o contrato `DataAdapter` do core. Aqui mora o que o domínio
5
+ * liga no servidor sobre os dados, como o handler próprio de expurgo (`bindPurge`, ADR 0051).
8
6
  */
9
7
 
10
- export {}
8
+ import type { PurgeBinding, PurgeHandler, SoftDeleteEntity } from '../core/retention.ts'
9
+
10
+ export type { PurgeBatchArgs, PurgeBinding, PurgeHandler } from '../core/retention.ts'
11
+
12
+ /**
13
+ * Liga um handler próprio ao expurgo de uma entidade com `retainDays`, para quando apagar a linha
14
+ * não basta: levar junto registros dependentes, apagar dados em outro serviço ou preservar parte do
15
+ * histórico. Registre o resultado em `purges` no `defineDomain` que lista a entidade.
16
+ *
17
+ * O handler apaga um lote de até `batchSize` registros com `deletedAt` anterior a `cutoff` e
18
+ * devolve os ids removidos, cada um uma vez. Respeitar o `cutoff` é obrigação do handler: o Opus
19
+ * recusa um `olderThanDays` abaixo do `retainDays`, mas só o handler decide o que apaga, e a
20
+ * garantia de nunca expurgar antes do prazo depende dele. Um id repetido na mesma execução, ou mais
21
+ * ids que `batchSize`, interrompe o expurgo com `purge.no_progress`.
22
+ *
23
+ * O Opus continua dono do contrato, da rotina, da repetição dos lotes e do registro `opus.purge`;
24
+ * sem `bindPurge`, vale o handler padrão do driver Kysely.
25
+ */
26
+ export function bindPurge<E extends SoftDeleteEntity>(entity: E, handler: PurgeHandler): PurgeBinding<E> {
27
+ return { entity, handler }
28
+ }
package/src/mcp/index.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  import { Server } from '@modelcontextprotocol/sdk/server/index.js'
13
13
  import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
14
14
  import { zodToJsonSchema } from 'zod-to-json-schema'
15
- import type { ContextBase, Runtime } from '../core/index.ts'
15
+ import { declaredJsonSchema, type ContextBase, type Runtime } from '../core/index.ts'
16
16
  import { readPackageVersion } from '../core/package-version.ts'
17
17
  import { aiToolNotFound } from '../core/runtime.ts'
18
18
 
@@ -35,7 +35,8 @@ const ANON: ContextBase = { user: null, tenantId: null, can: () => false, meta:
35
35
  */
36
36
  export function createOpusMcpServer(runtime: Runtime, opts: OpusMcpOptions = {}): Server {
37
37
  const toJsonSchema =
38
- opts.toJsonSchema ?? ((schema: unknown) => zodToJsonSchema(schema as never, { $refStrategy: 'none' }))
38
+ opts.toJsonSchema ??
39
+ ((schema: unknown) => declaredJsonSchema(schema) ?? zodToJsonSchema(schema as never, { $refStrategy: 'none' }))
39
40
 
40
41
  const server = new Server(
41
42
  { name: opts.name ?? 'opus', version: opts.version ?? readPackageVersion() },
@@ -15,6 +15,14 @@
15
15
 
16
16
  import { z } from 'zod'
17
17
  import { error } from '../core/index.ts'
18
+ import {
19
+ entityTableName,
20
+ hasSoftDelete,
21
+ softDeleteProblems,
22
+ type SoftDelete,
23
+ type SoftDeleteForever,
24
+ type SoftDeleteRetention,
25
+ } from '../core/retention.ts'
18
26
  import type { ColumnMeta, LogicalType } from './drivers/zod.ts'
19
27
 
20
28
  /* eslint-disable @typescript-eslint/no-explicit-any */
@@ -90,10 +98,18 @@ export interface EntityConfig<F extends EntityFields = EntityFields> {
90
98
  indexes?: IndexSpec[]
91
99
  /** Injeta `createdAt`/`updatedAt` (geridos pelo runtime). */
92
100
  timestamps?: boolean
93
- /** Injeta `deletedAt`; search/view filtram deletados por default. */
94
- softDelete?: boolean
101
+ /**
102
+ * Exclusão lógica: injeta `deletedAt`, e o `kyselyRepo` filtra os excluídos e marca a data em
103
+ * vez de apagar. Declare a retenção — `retainDays`, com fuso e os textos da action de expurgo —
104
+ * ou `{ retain: 'forever', reason }`. `true` ainda é aceito pelo tipo, mas o `opus check` o
105
+ * reprova (ADR 0051).
106
+ */
107
+ softDelete?: SoftDelete | false
95
108
  }
96
109
 
110
+ export { hasSoftDelete }
111
+ export type { SoftDelete, SoftDeleteForever, SoftDeleteRetention }
112
+
97
113
  // =============================================================================
98
114
  // Inferência de tipos
99
115
  // =============================================================================
@@ -115,7 +131,7 @@ type IdPart<F> = HasPk<F> extends true ? {} : { id: string }
115
131
  type TimestampPart<E> = E extends { timestamps: true }
116
132
  ? { createdAt: string; updatedAt: string }
117
133
  : {}
118
- type SoftDeletePart<E> = E extends { softDelete: true } ? { deletedAt: string | null } : {}
134
+ type SoftDeletePart<E> = E extends { softDelete: SoftDelete } ? { deletedAt: string | null } : {}
119
135
 
120
136
  /** Linha completa da entidade (id auto + campos + timestamps + softDelete). */
121
137
  export type EntityRow<E extends EntityConfig> = Simplify<
@@ -188,6 +204,15 @@ function validateEntity(config: EntityConfig): void {
188
204
  })
189
205
  }
190
206
 
207
+ const retentionProblems = softDeleteProblems(config.softDelete)
208
+ if (retentionProblems.length > 0) {
209
+ throw error({
210
+ code: 'entity.invalid_soft_delete',
211
+ category: 'internal',
212
+ message: `A retenção de "${config.name}" é inválida: ${retentionProblems.join('; ')}.`,
213
+ })
214
+ }
215
+
191
216
  if (config.relations !== undefined) {
192
217
  for (const [relName, rel] of Object.entries(config.relations)) {
193
218
  if (typeof rel.target !== 'string' || rel.target.length === 0) {
@@ -239,7 +264,7 @@ export function entityColumns(config: EntityConfig): ResolvedColumn[] {
239
264
  cols.push({ name: 'createdAt', logicalType: 'datetime', column: {}, generated: true })
240
265
  cols.push({ name: 'updatedAt', logicalType: 'datetime', column: {}, generated: true })
241
266
  }
242
- if (config.softDelete === true) {
267
+ if (hasSoftDelete(config)) {
243
268
  cols.push({
244
269
  name: 'deletedAt',
245
270
  logicalType: 'datetime',
@@ -301,11 +326,7 @@ export function entityUpdateSchema(config: EntityConfig): z.ZodObject<z.ZodRawSh
301
326
 
302
327
  /** Nome da tabela: `table` explícito ou plural simples de `name`. */
303
328
  export function entityTable(config: EntityConfig): string {
304
- if (config.table !== undefined) return config.table
305
- const n = config.name
306
- if (n.endsWith('y')) return `${n.slice(0, -1)}ies`
307
- if (/(s|x|z|ch|sh)$/.test(n)) return `${n}es`
308
- return `${n}s`
329
+ return entityTableName(config)
309
330
  }
310
331
 
311
332
  /**
@@ -41,6 +41,7 @@ export {
41
41
  desiredColumns,
42
42
  diffColumns,
43
43
  isEntityConfig,
44
+ hasSoftDelete,
44
45
  snakeCase,
45
46
  resolveNaming,
46
47
  } from './entity.ts'
@@ -58,6 +59,9 @@ export type {
58
59
  DriftKind,
59
60
  DriftFinding,
60
61
  NamingStrategy,
62
+ SoftDelete,
63
+ SoftDeleteForever,
64
+ SoftDeleteRetention,
61
65
  } from './entity.ts'
62
66
 
63
67
  // — Scaffold de migration ——————————————————————————————————————————————————————
@@ -14,7 +14,7 @@
14
14
  * — apenas actions registradas viram operations OpenAPI.
15
15
  */
16
16
 
17
- import type { ActionDef, ErrorSpec } from '../core/index.ts'
17
+ import { declaredJsonSchema, type ActionDef, type ErrorSpec } from '../core/index.ts'
18
18
  import { zodToJsonSchema } from 'zod-to-json-schema'
19
19
 
20
20
  // =============================================================================
@@ -119,6 +119,9 @@ export function toOpenAPISpec(
119
119
  // =============================================================================
120
120
 
121
121
  function defaultToJsonSchema(schema: unknown): unknown {
122
+ // Standard Schema que declara o próprio JSON Schema (como as actions de expurgo, ADR 0051).
123
+ const declared = declaredJsonSchema(schema, { target: 'openapi-3.0' })
124
+ if (declared !== null) return declared
122
125
  return zodToJsonSchema(schema as Parameters<typeof zodToJsonSchema>[0], {
123
126
  target: 'openApi3',
124
127
  $refStrategy: 'none',
@@ -20,11 +20,13 @@ opus db check # drift entidade ↔ banco (read-only)
20
20
  opus seed check # bindings, dependências, ciclos e scripts paralelos de seed
21
21
  ```
22
22
 
23
- `opus check` lê o código-fonte sem executá-lo e aplica regras em três grupos: actions
23
+ `opus check` lê o código-fonte sem executá-lo e aplica regras em quatro grupos: actions
24
24
  (`action-name`, `kind`, `field-order`, `export`, `requires-sem-authorize`, `ignored-by-kind`), UI
25
- (`ui-structure`, `ui-structure-split`, `ui-semantic-api`, `removed-ui-token`, `unpaired-ui-surface`) e
25
+ (`ui-structure`, `ui-structure-split`, `ui-semantic-api`, `removed-ui-token`, `unpaired-ui-surface`),
26
26
  [Produtos de Dados](/docs/data-products) (`data-product-export`, `-id`, `-duplicate`, `-version`,
27
- `-interfaces`, `-interface`, `-entities` e `-entity`, todas com o prefixo `data-product`). Um projeto
27
+ `-interfaces`, `-interface`, `-entities` e `-entity`, todas com o prefixo `data-product`) e
28
+ [exclusão lógica](/docs/data) (`soft-delete-retention`, que cobra a retenção de cada entidade e a
29
+ ligação com o domínio). Um projeto
28
30
  marcado com `opus.json` e ainda sem actions passa sem achados; sem o marcador, nenhuma action
29
31
  encontrada é falha, porque um gate que não verificou nada não é aprovação. `opus check --help` descreve cada regra.
30
32
 
@@ -30,6 +30,48 @@ export const SkillEntity = defineEntity({
30
30
  })
31
31
  ```
32
32
 
33
+ ## Exclusão lógica e expurgo
34
+
35
+ Use `softDelete` quando um registro excluído precisa continuar no banco por um tempo, como
36
+ histórico ou para atender uma regra de guarda. `kyselyRepo` passa a marcar `deletedAt` em
37
+ `remove`, e `findById`, `all` e `list` deixam os excluídos de fora.
38
+
39
+ A declaração também diz quando o registro excluído sai de vez. Informe o prazo em `retainDays`, o
40
+ fuso da rotina e os textos da action que o Opus gera para expurgar:
41
+
42
+ ```ts
43
+ export const ReportEntity = defineEntity({
44
+ name: 'report',
45
+ fields: { title: t.string() },
46
+ softDelete: {
47
+ retainDays: 90,
48
+ timezone: 'America/Sao_Paulo',
49
+ label: 'Expurgar relatórios excluídos',
50
+ description: 'Apaga de vez os relatórios excluídos há mais de 90 dias.',
51
+ },
52
+ })
53
+ ```
54
+
55
+ O domínio que lista a entidade em `entities` ganha a action `report.purge-deleted` e a rotina
56
+ diária `report.purge-deleted.daily`, às 3h30 no fuso declarado. Só o sistema executa a action,
57
+ que fica fora do REST e da IA, e ela nunca expurga antes do prazo. Com `kyselyData` no runtime, o
58
+ handler padrão apaga as linhas em lotes, das mais antigas para as mais novas; a rotina exige um
59
+ [agendador](/docs/scheduler). Cada lote que apaga algo registra `opus.purge` no log com os ids
60
+ removidos, e a execução termina com `opus.purge.total`, que traz a quantidade.
61
+
62
+ Antes de declarar `retainDays`, confira as chaves estrangeiras que apontam para a tabela. Uma chave
63
+ restritiva faz cada execução falhar no lote da linha travada, que está entre as mais antigas, e o
64
+ expurgo fica parado até a chave ganhar cascata ou um `bindPurge` apagar os dependentes primeiro.
65
+
66
+ Quando apagar a linha não basta, como ao remover arquivos em outro serviço, registre um handler
67
+ próprio com `bindPurge`, de `@softize/opus/data`, em `purges` no mesmo domínio. O handler recebe o
68
+ corte e só apaga o que foi excluído antes dele: é isso que garante o prazo. Para guardar o
69
+ registro excluído para sempre, declare o motivo: `softDelete: { retain: 'forever', reason }`.
70
+
71
+ O [`opus check`](/docs/cli) reprova `softDelete: true`, que não diz por quanto tempo o registro
72
+ fica guardado. Opções, nomes gerados e erros estão no `docs/protocol.md` do pacote, §15, "Retenção
73
+ e expurgo".
74
+
33
75
  ## Código em camelCase, banco em snake_case
34
76
 
35
77
  O schema tipado do Kysely e todas as consultas usam camelCase (`workspaceId`, `ghRepo`). O banco
@@ -79,6 +79,10 @@ seção). Com schedules registrados e sem `scheduler`, o `start()` falha com
79
79
  `runtime.scheduler_required`. Um schedule com `enabled: false` não é registrado nem
80
80
  conferido, então pode apontar para uma action que ainda não existe no ambiente.
81
81
 
82
+ Uma entidade com exclusão lógica e `retainDays` traz a própria rotina de expurgo, que o domínio
83
+ registra junto com a action, como `report.purge-deleted.daily`. Ela também exige o `scheduler`.
84
+ Veja "Exclusão lógica e expurgo" na página [Camada de dados](/docs/data).
85
+
82
86
  ## Preparar a action agendada
83
87
 
84
88
  O schedule executa a action como o sistema, sem uma pessoa por trás. Uma action que exige usuário