@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.
- package/CHANGELOG.md +77 -0
- package/README.md +1 -1
- package/bin/cli.mjs +9 -0
- package/bin/lib/check.mjs +390 -1
- package/bin/lib/copy.mjs +39 -2
- package/bin/lib/gen-runner.mjs +25 -12
- package/bin/lib/mcp.mjs +1 -1
- package/docs/adr/0051-soft-delete-declares-retention-and-purge.md +224 -0
- package/docs/data-layer.md +31 -3
- package/docs/protocol.md +98 -2
- package/package.json +1 -1
- package/src/ai/drivers/anthropic.ts +3 -0
- package/src/core/domain.ts +189 -7
- package/src/core/index.ts +19 -2
- package/src/core/retention.ts +493 -0
- package/src/core/runtime.ts +93 -29
- package/src/core/system-actor.ts +33 -0
- package/src/core/types.ts +12 -0
- package/src/data/drivers/kysely.ts +47 -5
- package/src/data/index.ts +24 -6
- package/src/mcp/index.ts +3 -2
- package/src/schema/entity.ts +30 -9
- package/src/schema/index.ts +4 -0
- package/src/schema/openapi.ts +4 -1
- package/src/ui/docs/content/cli.md +5 -3
- package/src/ui/docs/content/data.md +42 -0
- package/src/ui/docs/content/scheduler.md +4 -0
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opus — retenção e expurgo da exclusão lógica (ADR 0051).
|
|
3
|
+
*
|
|
4
|
+
* Uma entidade com `softDelete` declara por quanto tempo guarda o registro excluído
|
|
5
|
+
* (`retainDays`) ou, com motivo, que guarda para sempre. Para cada entidade com prazo, o
|
|
6
|
+
* `flattenDomain` deriva um contrato de expurgo e a rotina diária que o executa; o runtime anexa o
|
|
7
|
+
* handler no servidor (padrão do driver de dados ou `bindPurge`) e repete os lotes até acabar ou
|
|
8
|
+
* esgotar o tempo.
|
|
9
|
+
*
|
|
10
|
+
* Este módulo é isomorfo: declara tipos, nomes e o contrato, e a repetição dos lotes, que não toca
|
|
11
|
+
* banco. O SQL do handler padrão mora no driver (`@softize/opus/data/kysely`). Não depende de zod,
|
|
12
|
+
* que é peer opcional: os schemas da action são Standard Schema escritos à mão, com o JSON Schema
|
|
13
|
+
* anexado para o manifest, o OpenAPI e o MCP.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { SimpleContract } from './contracts.ts'
|
|
17
|
+
import { error } from './errors.ts'
|
|
18
|
+
import { isSystemActor } from './system-actor.ts'
|
|
19
|
+
import type { ActionContext, HandlerFn, Logger, Schema, ScheduleDef, SchemaIssue } from './types.ts'
|
|
20
|
+
|
|
21
|
+
// =============================================================================
|
|
22
|
+
// Declaração na entidade
|
|
23
|
+
// =============================================================================
|
|
24
|
+
|
|
25
|
+
/** Rotina do expurgo: padrão `'30 3 * * *'`, habilitada. */
|
|
26
|
+
export interface SoftDeleteSchedule {
|
|
27
|
+
cron?: string
|
|
28
|
+
enabled?: boolean
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Exclusão lógica com prazo: o registro excluído sai de vez depois de `retainDays`. */
|
|
32
|
+
export interface SoftDeleteRetention {
|
|
33
|
+
/** Inteiro positivo, até 3650; conta a partir de `deletedAt`. */
|
|
34
|
+
retainDays: number
|
|
35
|
+
/** Fuso IANA da rotina, obrigatório: o expurgo não depende do fuso da máquina. */
|
|
36
|
+
timezone: string
|
|
37
|
+
/** Título da action de expurgo, completo e literal: `'Expurgar relatórios excluídos'`. */
|
|
38
|
+
label: string
|
|
39
|
+
/** Descrição da action de expurgo, completa e literal. */
|
|
40
|
+
description: string
|
|
41
|
+
schedule?: SoftDeleteSchedule
|
|
42
|
+
/** Linhas por lote. Padrão 200. */
|
|
43
|
+
batchSize?: number
|
|
44
|
+
/** Tempo máximo de uma execução, em ms. Padrão 60.000. */
|
|
45
|
+
maxDurationMs?: number
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Exclusão lógica sem expurgo: guarda o registro excluído para sempre, com o motivo. */
|
|
49
|
+
export interface SoftDeleteForever {
|
|
50
|
+
retain: 'forever'
|
|
51
|
+
reason: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Forma de `softDelete` numa entidade. `true` continua aceito pelo tipo, mas o `opus check`
|
|
56
|
+
* reprova: toda exclusão lógica declara a retenção ou o motivo para guardar (ADR 0051).
|
|
57
|
+
*/
|
|
58
|
+
export type SoftDelete = true | SoftDeleteRetention | SoftDeleteForever
|
|
59
|
+
|
|
60
|
+
/** O recorte estrutural da entidade que o core lê. `EntityConfig` (schema) o satisfaz. */
|
|
61
|
+
export interface SoftDeleteEntity {
|
|
62
|
+
name: string
|
|
63
|
+
table?: string
|
|
64
|
+
softDelete?: SoftDelete | false
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Nome da tabela: `table` explícito ou plural simples de `name`. Mora no core para que a checagem
|
|
69
|
+
* de entidade repetida compare tabelas; `entityTable`, em `@softize/opus/schema`, delega para cá.
|
|
70
|
+
*/
|
|
71
|
+
export function entityTableName(entity: { name: string; table?: string }): string {
|
|
72
|
+
if (entity.table !== undefined) return entity.table
|
|
73
|
+
const n = entity.name
|
|
74
|
+
if (n.endsWith('y')) return `${n.slice(0, -1)}ies`
|
|
75
|
+
if (/(s|x|z|ch|sh)$/.test(n)) return `${n}es`
|
|
76
|
+
return `${n}s`
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const PURGE_DEFAULT_CRON = '30 3 * * *'
|
|
80
|
+
export const PURGE_DEFAULT_BATCH_SIZE = 200
|
|
81
|
+
/** Teto do `batchSize`: um lote maior pesa no banco e no registro de cada execução. */
|
|
82
|
+
export const PURGE_MAX_BATCH_SIZE = 10_000
|
|
83
|
+
/** Ids por registro `opus.purge`: um lote grande vira vários registros, nunca uma linha sem teto. */
|
|
84
|
+
export const PURGE_LOG_IDS_PER_RECORD = 500
|
|
85
|
+
export const PURGE_DEFAULT_MAX_DURATION_MS = 60_000
|
|
86
|
+
/** Teto do `olderThanDays` aceito pela action, e portanto do `retainDays` declarado. */
|
|
87
|
+
export const PURGE_MAX_OLDER_THAN_DAYS = 3650
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* `true` quando a entidade faz exclusão lógica, em qualquer das três formas de `softDelete`. É o
|
|
91
|
+
* único ponto que decide a injeção de `deletedAt`, o filtro de leitura e a marcação no `remove`.
|
|
92
|
+
*/
|
|
93
|
+
export function hasSoftDelete(entity: { softDelete?: unknown }): boolean {
|
|
94
|
+
const value = entity.softDelete
|
|
95
|
+
return value === true || (typeof value === 'object' && value !== null)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A retenção com prazo da entidade, ou `null` quando não há prazo declarado. */
|
|
99
|
+
export function softDeleteRetention(entity: { softDelete?: unknown }): SoftDeleteRetention | null {
|
|
100
|
+
const value = entity.softDelete
|
|
101
|
+
if (typeof value !== 'object' || value === null) return null
|
|
102
|
+
return 'retainDays' in value ? (value as SoftDeleteRetention) : null
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Problemas da declaração de `softDelete`, em pt-BR, para o `defineEntity` recusar. Vazio quando a
|
|
107
|
+
* forma é válida. `true` não é problema aqui: o tipo o aceita e o `opus check` o reprova.
|
|
108
|
+
*/
|
|
109
|
+
export function softDeleteProblems(softDelete: unknown): string[] {
|
|
110
|
+
if (softDelete === undefined || softDelete === true || softDelete === false) return []
|
|
111
|
+
if (typeof softDelete !== 'object' || softDelete === null) {
|
|
112
|
+
return ['`softDelete` precisa ser `true`, um objeto com `retainDays` ou `{ retain: \'forever\', reason }`']
|
|
113
|
+
}
|
|
114
|
+
const value = softDelete as Record<string, unknown>
|
|
115
|
+
const hasDays = 'retainDays' in value
|
|
116
|
+
const hasForever = 'retain' in value
|
|
117
|
+
if (hasDays && hasForever) {
|
|
118
|
+
return ['`retainDays` e `retain: \'forever\'` são formas excludentes; declare só uma']
|
|
119
|
+
}
|
|
120
|
+
if (hasForever) {
|
|
121
|
+
const problems: string[] = []
|
|
122
|
+
if (value.retain !== 'forever') problems.push('`retain` só aceita `\'forever\'`')
|
|
123
|
+
if (!nonEmptyString(value.reason)) problems.push('`retain: \'forever\'` precisa de `reason` com o motivo')
|
|
124
|
+
return problems
|
|
125
|
+
}
|
|
126
|
+
if (!hasDays) {
|
|
127
|
+
return ['`softDelete` em objeto precisa de `retainDays` ou de `retain: \'forever\'` com `reason`']
|
|
128
|
+
}
|
|
129
|
+
const problems: string[] = []
|
|
130
|
+
const days = value.retainDays
|
|
131
|
+
if (typeof days !== 'number' || !Number.isInteger(days) || days < 1) {
|
|
132
|
+
problems.push('`retainDays` precisa ser um inteiro positivo')
|
|
133
|
+
} else if (days > PURGE_MAX_OLDER_THAN_DAYS) {
|
|
134
|
+
problems.push(`\`retainDays\` aceita no máximo ${PURGE_MAX_OLDER_THAN_DAYS} dias`)
|
|
135
|
+
}
|
|
136
|
+
if (!nonEmptyString(value.timezone)) problems.push('`timezone` é obrigatório (fuso IANA)')
|
|
137
|
+
else if (!validTimezone(value.timezone)) problems.push(`\`timezone\` "${value.timezone}" não é um fuso IANA válido`)
|
|
138
|
+
if (!nonEmptyString(value.label)) problems.push('`label` é obrigatório (título completo da action de expurgo)')
|
|
139
|
+
if (!nonEmptyString(value.description)) problems.push('`description` é obrigatória (descrição completa da action de expurgo)')
|
|
140
|
+
for (const key of ['batchSize', 'maxDurationMs'] as const) {
|
|
141
|
+
const n = value[key]
|
|
142
|
+
if (n !== undefined && (typeof n !== 'number' || !Number.isInteger(n) || n < 1)) {
|
|
143
|
+
problems.push(`\`${key}\` precisa ser um inteiro positivo`)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const batch = value.batchSize
|
|
147
|
+
if (typeof batch === 'number' && Number.isInteger(batch) && batch > PURGE_MAX_BATCH_SIZE) {
|
|
148
|
+
problems.push(`\`batchSize\` aceita no máximo ${PURGE_MAX_BATCH_SIZE} linhas por lote`)
|
|
149
|
+
}
|
|
150
|
+
const schedule = value.schedule
|
|
151
|
+
if (schedule !== undefined) {
|
|
152
|
+
if (typeof schedule !== 'object' || schedule === null) problems.push('`schedule` precisa ser um objeto')
|
|
153
|
+
else {
|
|
154
|
+
const { cron, enabled } = schedule as Record<string, unknown>
|
|
155
|
+
if (cron !== undefined && !nonEmptyString(cron)) problems.push('`schedule.cron` precisa ser uma expressão cron')
|
|
156
|
+
if (enabled !== undefined && typeof enabled !== 'boolean') problems.push('`schedule.enabled` precisa ser booleano')
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return problems
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function nonEmptyString(value: unknown): value is string {
|
|
163
|
+
return typeof value === 'string' && value.trim() !== ''
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function validTimezone(timezone: string): boolean {
|
|
167
|
+
try {
|
|
168
|
+
new Intl.DateTimeFormat('en-US', { timeZone: timezone })
|
|
169
|
+
return true
|
|
170
|
+
} catch {
|
|
171
|
+
return false
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// =============================================================================
|
|
176
|
+
// Handler próprio (bindPurge)
|
|
177
|
+
// =============================================================================
|
|
178
|
+
|
|
179
|
+
/** O que o handler de um lote recebe: o contexto da execução, o corte e o tamanho do lote. */
|
|
180
|
+
export interface PurgeBatchArgs {
|
|
181
|
+
ctx: ActionContext
|
|
182
|
+
/** Instante ISO: apaga somente registros com `deletedAt` anterior a ele. */
|
|
183
|
+
cutoff: string
|
|
184
|
+
batchSize: number
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Apaga um lote de até `batchSize` registros com `deletedAt` anterior a `cutoff` e devolve os ids
|
|
189
|
+
* removidos; vazio quando acabou. Respeitar o `cutoff` é obrigação do handler: a garantia de nunca
|
|
190
|
+
* expurgar antes do prazo depende dele. Um id repetido na mesma execução, ou mais ids que
|
|
191
|
+
* `batchSize`, interrompe o expurgo com `purge.no_progress`.
|
|
192
|
+
*/
|
|
193
|
+
export type PurgeHandler = (args: PurgeBatchArgs) => Promise<{ ids: string[] }> | { ids: string[] }
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Handler próprio de expurgo, server-only, criado por `bindPurge` e registrado em `purges` no
|
|
197
|
+
* `defineDomain` que lista a entidade. O Opus continua dono do contrato, da rotina, dos lotes e do
|
|
198
|
+
* registro `opus.purge`.
|
|
199
|
+
*/
|
|
200
|
+
export interface PurgeBinding<E extends SoftDeleteEntity = SoftDeleteEntity> {
|
|
201
|
+
entity: E
|
|
202
|
+
handler: PurgeHandler
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// =============================================================================
|
|
206
|
+
// Nomes e contrato derivados
|
|
207
|
+
// =============================================================================
|
|
208
|
+
|
|
209
|
+
/** `reportShare` → `report-share`; `report_share` → `report-share`. */
|
|
210
|
+
export function entityKebab(name: string): string {
|
|
211
|
+
return name
|
|
212
|
+
.replace(/_/g, '-')
|
|
213
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
214
|
+
.toLowerCase()
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* `<caminho-do-domínio>.<entidade-em-kebab>.purge-deleted`. Quando a entidade em kebab é igual ao
|
|
219
|
+
* último segmento do domínio, o segmento não se repete. O caminho entra como está.
|
|
220
|
+
*/
|
|
221
|
+
export function purgeActionName(domainPath: readonly string[], entityName: string): string {
|
|
222
|
+
const kebab = entityKebab(entityName)
|
|
223
|
+
const last = domainPath[domainPath.length - 1]
|
|
224
|
+
const segments = last === kebab ? [...domainPath] : [...domainPath, kebab]
|
|
225
|
+
return [...segments, 'purge-deleted'].join('.')
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** A rotina diária que dispara o expurgo: `<action>.daily`. */
|
|
229
|
+
export function purgeScheduleName(actionName: string): string {
|
|
230
|
+
return `${actionName}.daily`
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Entrada da action de expurgo, já com o padrão aplicado. */
|
|
234
|
+
export interface PurgeInput {
|
|
235
|
+
olderThanDays: number
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Saída da action de expurgo. */
|
|
239
|
+
export interface PurgeOutput {
|
|
240
|
+
purged: number
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** JSON Schema anexado a um Standard Schema, no formato da extensão Standard JSON Schema. */
|
|
244
|
+
export interface StandardJsonSchema {
|
|
245
|
+
input(options?: { target?: string }): Record<string, unknown>
|
|
246
|
+
output(options?: { target?: string }): Record<string, unknown>
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* JSON Schema declarado por um Standard Schema que o anexa em `~standard.jsonSchema`, ou `null`.
|
|
251
|
+
* O manifest, o OpenAPI e o MCP usam este caminho antes de recorrer ao zod.
|
|
252
|
+
*/
|
|
253
|
+
export function declaredJsonSchema(
|
|
254
|
+
schema: unknown,
|
|
255
|
+
options: { target?: string } = {},
|
|
256
|
+
): Record<string, unknown> | null {
|
|
257
|
+
if (typeof schema !== 'object' || schema === null) return null
|
|
258
|
+
const standard = (schema as { '~standard'?: { jsonSchema?: Partial<StandardJsonSchema> } })['~standard']
|
|
259
|
+
const input = standard?.jsonSchema?.input
|
|
260
|
+
return typeof input === 'function' ? input(options) : null
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
type PurgeWireInput = { olderThanDays?: number }
|
|
264
|
+
|
|
265
|
+
function standardSchema<In, Out>(
|
|
266
|
+
validate: (value: unknown) => { value: Out } | { issues: SchemaIssue[] },
|
|
267
|
+
jsonSchema: Record<string, unknown>,
|
|
268
|
+
): Schema<In, Out> & { '~standard': { jsonSchema: StandardJsonSchema } } {
|
|
269
|
+
return {
|
|
270
|
+
'~standard': {
|
|
271
|
+
version: 1,
|
|
272
|
+
vendor: 'opus',
|
|
273
|
+
validate,
|
|
274
|
+
jsonSchema: {
|
|
275
|
+
input: () => structuredClone(jsonSchema),
|
|
276
|
+
output: () => structuredClone(jsonSchema),
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
283
|
+
typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
284
|
+
|
|
285
|
+
/** Entrada: `olderThanDays` inteiro entre `retainDays` e 3650, com `retainDays` como padrão. */
|
|
286
|
+
function purgeInputSchema(days: number): Schema<PurgeWireInput, PurgeInput> {
|
|
287
|
+
return standardSchema<PurgeWireInput, PurgeInput>(
|
|
288
|
+
(value) => {
|
|
289
|
+
if (!isRecord(value)) {
|
|
290
|
+
return { issues: [{ message: 'Envie um objeto, vazio ou com `olderThanDays`.' }] }
|
|
291
|
+
}
|
|
292
|
+
const raw = value.olderThanDays
|
|
293
|
+
if (raw === undefined) return { value: { olderThanDays: days } }
|
|
294
|
+
const path = ['olderThanDays']
|
|
295
|
+
if (typeof raw !== 'number' || !Number.isInteger(raw)) {
|
|
296
|
+
return { issues: [{ message: 'Informe um número inteiro de dias.', path }] }
|
|
297
|
+
}
|
|
298
|
+
if (raw < days) {
|
|
299
|
+
return {
|
|
300
|
+
issues: [{ message: `O expurgo respeita a retenção declarada: informe pelo menos ${days} dias.`, path }],
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (raw > PURGE_MAX_OLDER_THAN_DAYS) {
|
|
304
|
+
return { issues: [{ message: `Informe no máximo ${PURGE_MAX_OLDER_THAN_DAYS} dias.`, path }] }
|
|
305
|
+
}
|
|
306
|
+
return { value: { olderThanDays: raw } }
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
type: 'object',
|
|
310
|
+
properties: {
|
|
311
|
+
olderThanDays: {
|
|
312
|
+
type: 'integer',
|
|
313
|
+
minimum: days,
|
|
314
|
+
maximum: PURGE_MAX_OLDER_THAN_DAYS,
|
|
315
|
+
default: days,
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
additionalProperties: false,
|
|
319
|
+
},
|
|
320
|
+
)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Saída: `{ purged }`, inteiro não negativo. */
|
|
324
|
+
function purgeOutputSchema(): Schema<PurgeOutput> {
|
|
325
|
+
return standardSchema<PurgeOutput, PurgeOutput>(
|
|
326
|
+
(value) => {
|
|
327
|
+
const purged = isRecord(value) ? value.purged : undefined
|
|
328
|
+
if (typeof purged === 'number' && Number.isInteger(purged) && purged >= 0) {
|
|
329
|
+
return { value: { purged } }
|
|
330
|
+
}
|
|
331
|
+
return { issues: [{ message: '`purged` precisa ser um inteiro maior ou igual a zero.', path: ['purged'] }] }
|
|
332
|
+
},
|
|
333
|
+
{
|
|
334
|
+
type: 'object',
|
|
335
|
+
properties: { purged: { type: 'integer', minimum: 0 } },
|
|
336
|
+
required: ['purged'],
|
|
337
|
+
additionalProperties: false,
|
|
338
|
+
},
|
|
339
|
+
)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* O contrato da action de expurgo, sem handler. Isomorfo: o manifest, o catálogo de design e o
|
|
344
|
+
* runtime leem o mesmo objeto.
|
|
345
|
+
*/
|
|
346
|
+
export function purgeContract(
|
|
347
|
+
actionName: string,
|
|
348
|
+
retention: SoftDeleteRetention,
|
|
349
|
+
): SimpleContract<PurgeWireInput, PurgeOutput, PurgeInput> {
|
|
350
|
+
return {
|
|
351
|
+
name: actionName,
|
|
352
|
+
kind: 'simple',
|
|
353
|
+
// Literais da retenção: o Opus não monta frase a partir do nome do recurso, porque um modelo
|
|
354
|
+
// fixo erraria a concordância (ADR 0051).
|
|
355
|
+
label: retention.label,
|
|
356
|
+
description: retention.description,
|
|
357
|
+
input: purgeInputSchema(retention.retainDays),
|
|
358
|
+
output: purgeOutputSchema(),
|
|
359
|
+
effect: 'write',
|
|
360
|
+
personalData: false,
|
|
361
|
+
// Só o sistema executa o expurgo: a rotina diária roda como o sistema (ADR 0028), e não há
|
|
362
|
+
// execução manual por pessoa nesta decisão.
|
|
363
|
+
authorize: (ctx) => isSystemActor(ctx),
|
|
364
|
+
// Fora da IA: o expurgo é operação do sistema, irreversível e agendada, não pedido de uma
|
|
365
|
+
// pessoa numa conversa (ADR 0051).
|
|
366
|
+
ai: false,
|
|
367
|
+
automatable: true,
|
|
368
|
+
// Fora do REST: nenhuma rota HTTP dispara o expurgo; só o runtime, pela rotina.
|
|
369
|
+
internal: true,
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** A rotina diária do expurgo, no fuso declarado. Roda como o sistema (ADR 0028). */
|
|
374
|
+
export function purgeSchedule(actionName: string, retention: SoftDeleteRetention): ScheduleDef {
|
|
375
|
+
return {
|
|
376
|
+
name: purgeScheduleName(actionName),
|
|
377
|
+
action: actionName,
|
|
378
|
+
cron: retention.schedule?.cron ?? PURGE_DEFAULT_CRON,
|
|
379
|
+
timezone: retention.timezone,
|
|
380
|
+
enabled: retention.schedule?.enabled ?? true,
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// =============================================================================
|
|
385
|
+
// Execução: lotes, tempo e registro
|
|
386
|
+
// =============================================================================
|
|
387
|
+
|
|
388
|
+
export interface PurgeRunSpec {
|
|
389
|
+
entity: string
|
|
390
|
+
action: string
|
|
391
|
+
retention: SoftDeleteRetention
|
|
392
|
+
/** Apaga um lote: o `bindPurge` do domínio ou o handler padrão do driver de dados. */
|
|
393
|
+
batch: PurgeHandler
|
|
394
|
+
/** Logger do runtime, usado quando o contexto não traz `log`. */
|
|
395
|
+
fallbackLog: Logger
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** Cede a vez ao event loop entre lotes, em Node e no navegador. */
|
|
399
|
+
function yieldToEventLoop(): Promise<void> {
|
|
400
|
+
const immediate = (globalThis as { setImmediate?: (callback: () => void) => unknown }).setImmediate
|
|
401
|
+
return new Promise((resolve) => {
|
|
402
|
+
if (typeof immediate === 'function') immediate(resolve)
|
|
403
|
+
else setTimeout(resolve, 0)
|
|
404
|
+
})
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Handler da action de expurgo: calcula o corte, repete os lotes enquanto houver ids e tempo, e
|
|
409
|
+
* registra o que saiu. Cada lote vira registros `opus.purge` com no máximo 500 ids; no fim, um
|
|
410
|
+
* registro `opus.purge.total` traz a quantidade e se restou trabalho. Um lote que repete um id ou
|
|
411
|
+
* excede o `batchSize` interrompe com `purge.no_progress`, depois de registrar os ids novos que ele
|
|
412
|
+
* trouxe; uma falha de lote interrompe com o erro original. Nos dois casos, o que já saiu fica
|
|
413
|
+
* registrado, com o total e `remaining: true`.
|
|
414
|
+
*/
|
|
415
|
+
export function createPurgeHandler(spec: PurgeRunSpec): HandlerFn<PurgeInput, PurgeOutput> {
|
|
416
|
+
const batchSize = spec.retention.batchSize ?? PURGE_DEFAULT_BATCH_SIZE
|
|
417
|
+
const maxDurationMs = spec.retention.maxDurationMs ?? PURGE_DEFAULT_MAX_DURATION_MS
|
|
418
|
+
return async (ctx, input) => {
|
|
419
|
+
const startedAt = Date.now()
|
|
420
|
+
const olderThanDays = input.olderThanDays
|
|
421
|
+
const cutoff = new Date(startedAt - olderThanDays * 86_400_000).toISOString()
|
|
422
|
+
const log = (ctx as Partial<ActionContext>).log ?? spec.fallbackLog
|
|
423
|
+
const base = { entity: spec.entity, action: spec.action, cutoff, olderThanDays }
|
|
424
|
+
const purged = new Set<string>()
|
|
425
|
+
let batches = 0
|
|
426
|
+
let remaining = false
|
|
427
|
+
const recordBatch = (ids: string[]): void => {
|
|
428
|
+
for (let start = 0; start < ids.length; start += PURGE_LOG_IDS_PER_RECORD) {
|
|
429
|
+
log.info('opus.purge', { ...base, batch: batches, ids: ids.slice(start, start + PURGE_LOG_IDS_PER_RECORD) })
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
const recordTotal = (): void => {
|
|
433
|
+
if (purged.size === 0) return
|
|
434
|
+
log.info('opus.purge.total', { ...base, purged: purged.size, batches, remaining })
|
|
435
|
+
}
|
|
436
|
+
const stop = (thrown: unknown): never => {
|
|
437
|
+
remaining = true
|
|
438
|
+
recordTotal()
|
|
439
|
+
throw thrown
|
|
440
|
+
}
|
|
441
|
+
for (;;) {
|
|
442
|
+
let removed: string[] = []
|
|
443
|
+
try {
|
|
444
|
+
removed = (await spec.batch({ ctx, cutoff, batchSize })).ids
|
|
445
|
+
} catch (thrown) {
|
|
446
|
+
// O lote que falhou não apagou nada; os anteriores, sim, e já estão registrados.
|
|
447
|
+
stop(thrown)
|
|
448
|
+
}
|
|
449
|
+
if (removed.length === 0) break
|
|
450
|
+
const repeated = removed.find((id) => purged.has(id))
|
|
451
|
+
const invalid =
|
|
452
|
+
removed.length > batchSize || repeated !== undefined || new Set(removed).size < removed.length
|
|
453
|
+
// Os ids novos do lote saem do banco mesmo quando o lote é inválido: entram no registro e no
|
|
454
|
+
// total antes da parada, porque os registros são o único rastro de uma exclusão irreversível.
|
|
455
|
+
const fresh = [...new Set(removed)].filter((id) => !purged.has(id))
|
|
456
|
+
if (fresh.length > 0) {
|
|
457
|
+
batches += 1
|
|
458
|
+
for (const id of fresh) purged.add(id)
|
|
459
|
+
recordBatch(fresh)
|
|
460
|
+
}
|
|
461
|
+
if (invalid) stop(noProgress(spec.entity, batchSize, removed.length, repeated))
|
|
462
|
+
if (Date.now() - startedAt >= maxDurationMs) {
|
|
463
|
+
remaining = removed.length >= batchSize
|
|
464
|
+
break
|
|
465
|
+
}
|
|
466
|
+
await yieldToEventLoop()
|
|
467
|
+
}
|
|
468
|
+
recordTotal()
|
|
469
|
+
return { purged: purged.size }
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function noProgress(
|
|
474
|
+
entity: string,
|
|
475
|
+
batchSize: number,
|
|
476
|
+
returned: number,
|
|
477
|
+
repeated: string | undefined,
|
|
478
|
+
): ReturnType<typeof error> {
|
|
479
|
+
const problem = returned > batchSize
|
|
480
|
+
? `devolveu ${returned} ids num lote de ${batchSize}`
|
|
481
|
+
: repeated !== undefined
|
|
482
|
+
? `devolveu de novo o id "${repeated}", já removido nesta execução`
|
|
483
|
+
: 'devolveu o mesmo id duas vezes no lote'
|
|
484
|
+
return error({
|
|
485
|
+
code: 'purge.no_progress',
|
|
486
|
+
category: 'internal',
|
|
487
|
+
message:
|
|
488
|
+
`O expurgo de "${entity}" parou porque o handler ${problem}. Sem essa parada, os lotes se ` +
|
|
489
|
+
'repetiriam sem avançar até o fim do tempo. Confira se o handler apaga os registros que devolve, ' +
|
|
490
|
+
'devolve cada id uma vez e respeita o `batchSize`.',
|
|
491
|
+
meta: { entity, batchSize, returned, ...(repeated !== undefined ? { repeated } : {}) },
|
|
492
|
+
})
|
|
493
|
+
}
|
package/src/core/runtime.ts
CHANGED
|
@@ -14,13 +14,20 @@
|
|
|
14
14
|
import { isReaction } from './reactions.ts'
|
|
15
15
|
import { isBackgroundAction } from './actions.ts'
|
|
16
16
|
import { isSchedule } from './schedules.ts'
|
|
17
|
-
import {
|
|
18
|
-
|
|
17
|
+
import {
|
|
18
|
+
collectDomainWarnings,
|
|
19
|
+
entityRegisteredTwice,
|
|
20
|
+
flattenDomain,
|
|
21
|
+
isDomainConfig,
|
|
22
|
+
} from './domain.ts'
|
|
23
|
+
import type { DerivedPurge, DomainConfig } from './domain.ts'
|
|
24
|
+
import { createPurgeHandler, entityTableName, type PurgeHandler } from './retention.ts'
|
|
19
25
|
import type { DataProductConfig } from './data-product.ts'
|
|
20
26
|
import { AuditEmitter } from './audit.ts'
|
|
21
27
|
import { countReturnedRecords, hasInvalidEffect, resolveActionEffect } from './action-nature.ts'
|
|
22
28
|
import { error, isActionError, normalizeError } from './errors.ts'
|
|
23
29
|
import { normalizeTraceContext } from './trace.ts'
|
|
30
|
+
import { isSystemActor, markSystemActor } from './system-actor.ts'
|
|
24
31
|
import { evalExpression } from '../dsl/eval.ts'
|
|
25
32
|
import { parseExpression } from '../dsl/parser.ts'
|
|
26
33
|
import { parseLoad, resolveLoadArgs } from '../dsl/loads.ts'
|
|
@@ -318,6 +325,10 @@ export class Runtime {
|
|
|
318
325
|
private readonly schedules = new Map<string, ScheduleDef>()
|
|
319
326
|
private readonly dataProducts = new Map<string, DataProductConfig>()
|
|
320
327
|
private readonly actionDataProducts = new Map<string, Set<string>>()
|
|
328
|
+
/** Tabela de entidade com retenção → domínio que a registrou; detecta o expurgo duplicado. */
|
|
329
|
+
private readonly retainedTables = new Map<string, string>()
|
|
330
|
+
/** Expurgos sem handler (sem `bindPurge` e sem driver de dados capaz): o `start()` recusa. */
|
|
331
|
+
private readonly purgesWithoutHandler: Array<{ entity: string; action: string }> = []
|
|
321
332
|
|
|
322
333
|
/**
|
|
323
334
|
* Map entidade → resolver, usado pra carregar specs DSL de loads
|
|
@@ -387,8 +398,15 @@ export class Runtime {
|
|
|
387
398
|
for (const item of items) {
|
|
388
399
|
if (isDomainConfig(item)) {
|
|
389
400
|
collectDomainWarnings(item)
|
|
390
|
-
const { actions, reactions, schedules, dataProducts } = flattenDomain(item)
|
|
391
|
-
this.
|
|
401
|
+
const { actions, reactions, schedules, dataProducts, purges } = flattenDomain(item)
|
|
402
|
+
this.claimRetainedEntities(purges)
|
|
403
|
+
const bound = new Map(purges.map((purge) => [purge.action, this.bindPurgeHandler(purge)]))
|
|
404
|
+
this.registerFlat(
|
|
405
|
+
actions.map((action) => bound.get(action) ?? action),
|
|
406
|
+
reactions,
|
|
407
|
+
schedules,
|
|
408
|
+
dataProducts,
|
|
409
|
+
)
|
|
392
410
|
} else if (isSchedule(item)) {
|
|
393
411
|
this.registerSchedules([item])
|
|
394
412
|
} else if (isReaction(item)) {
|
|
@@ -424,6 +442,47 @@ export class Runtime {
|
|
|
424
442
|
}
|
|
425
443
|
}
|
|
426
444
|
|
|
445
|
+
/**
|
|
446
|
+
* A mesma entidade com retenção em dois domínios registrados separadamente geraria dois expurgos
|
|
447
|
+
* da mesma tabela. O `validateDomain` cobre a árvore de um domínio; aqui, o runtime inteiro.
|
|
448
|
+
*/
|
|
449
|
+
private claimRetainedEntities(purges: DerivedPurge[]): void {
|
|
450
|
+
for (const purge of purges) {
|
|
451
|
+
const table = entityTableName(purge.entity)
|
|
452
|
+
const owner = this.retainedTables.get(table)
|
|
453
|
+
if (owner !== undefined) throw entityRegisteredTwice(purge.entity.name, table, owner, purge.domain)
|
|
454
|
+
}
|
|
455
|
+
for (const purge of purges) this.retainedTables.set(entityTableName(purge.entity), purge.domain)
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Anexa o handler ao contrato de expurgo derivado (ADR 0051): o `bindPurge` do domínio ou, sem
|
|
460
|
+
* ele, o handler padrão do driver de dados. Sem nenhum dos dois, o contrato fica sem handler e o
|
|
461
|
+
* `start()` recusa com `runtime.purge_handler_missing`.
|
|
462
|
+
*/
|
|
463
|
+
private bindPurgeHandler(purge: DerivedPurge): ActionDef {
|
|
464
|
+
const data = this.data
|
|
465
|
+
const batch: PurgeHandler | undefined =
|
|
466
|
+
purge.binding?.handler ??
|
|
467
|
+
(data?.purgeDeleted !== undefined
|
|
468
|
+
? ({ cutoff, batchSize }) => data.purgeDeleted!(purge.entity, { cutoff, batchSize })
|
|
469
|
+
: undefined)
|
|
470
|
+
if (batch === undefined) {
|
|
471
|
+
this.purgesWithoutHandler.push({ entity: purge.entity.name, action: purge.action.name })
|
|
472
|
+
return purge.action
|
|
473
|
+
}
|
|
474
|
+
return {
|
|
475
|
+
...purge.action,
|
|
476
|
+
handler: createPurgeHandler({
|
|
477
|
+
entity: purge.entity.name,
|
|
478
|
+
action: purge.action.name,
|
|
479
|
+
retention: purge.retention,
|
|
480
|
+
batch,
|
|
481
|
+
fallbackLog: this.log,
|
|
482
|
+
}),
|
|
483
|
+
} as ActionDef
|
|
484
|
+
}
|
|
485
|
+
|
|
427
486
|
private registerFlat(
|
|
428
487
|
actions: ActionDef[],
|
|
429
488
|
reactions: Array<ReactionDef<any>>,
|
|
@@ -547,6 +606,10 @@ export class Runtime {
|
|
|
547
606
|
})
|
|
548
607
|
}
|
|
549
608
|
|
|
609
|
+
// Antes de inicializar adapters, como as conferências de schedule abaixo: um expurgo sem
|
|
610
|
+
// handler falharia em todo disparo da rotina.
|
|
611
|
+
this.assertPurgeHandlers()
|
|
612
|
+
|
|
550
613
|
// Antes de inicializar adapters: é uma checagem do registro, e falhar aqui não deixa
|
|
551
614
|
// conexão aberta para trás. Sem scheduler, a checagem não roda porque nenhum schedule
|
|
552
615
|
// executaria: os adapters inicializam e o start falha depois, com
|
|
@@ -746,7 +809,7 @@ export class Runtime {
|
|
|
746
809
|
ctxBase.trace,
|
|
747
810
|
async (trace) => {
|
|
748
811
|
const ctx = this.buildActionContext(action, actionId, ctxBase, trace)
|
|
749
|
-
if (system
|
|
812
|
+
if (system) markSystemActor(ctx)
|
|
750
813
|
|
|
751
814
|
try {
|
|
752
815
|
// — 1. Validate input ————————————————————————————————————————————————
|
|
@@ -1364,6 +1427,28 @@ export class Runtime {
|
|
|
1364
1427
|
})
|
|
1365
1428
|
}
|
|
1366
1429
|
|
|
1430
|
+
/**
|
|
1431
|
+
* Uma entidade com `retainDays` cujo expurgo não tem quem apague: sem `bindPurge` no domínio e
|
|
1432
|
+
* sem um `DataAdapter` que implemente o handler padrão (o driver Kysely). ADR 0051.
|
|
1433
|
+
*/
|
|
1434
|
+
private assertPurgeHandlers(): void {
|
|
1435
|
+
const missing = this.purgesWithoutHandler
|
|
1436
|
+
if (missing.length === 0) return
|
|
1437
|
+
const list = missing.map((item) => `"${item.entity}" (${item.action})`).join(', ')
|
|
1438
|
+
throw error({
|
|
1439
|
+
code: 'runtime.purge_handler_missing',
|
|
1440
|
+
category: 'internal',
|
|
1441
|
+
message:
|
|
1442
|
+
`O runtime não iniciou porque ${
|
|
1443
|
+
missing.length === 1
|
|
1444
|
+
? 'uma entidade com retenção não tem handler de expurgo'
|
|
1445
|
+
: `${missing.length} entidades com retenção não têm handler de expurgo`
|
|
1446
|
+
}: ${list}. Configure \`data\` com o driver Kysely (\`kyselyData\`), que traz o handler ` +
|
|
1447
|
+
'padrão, ou registre um `bindPurge` em `purges` no domínio que lista a entidade.',
|
|
1448
|
+
meta: { purges: missing.map((item) => ({ ...item })) },
|
|
1449
|
+
})
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1367
1452
|
/**
|
|
1368
1453
|
* Um schedule ativo cuja action não aceita o sistema falharia em todo disparo com
|
|
1369
1454
|
* `auth.system_forbidden`. Como o `schedule_action_not_found`, o erro sai no boot, antes de
|
|
@@ -1805,30 +1890,9 @@ async function settledSuccessfully<T>(promise: Promise<T>): Promise<boolean> {
|
|
|
1805
1890
|
// Identidade de sistema (ADR 0028)
|
|
1806
1891
|
// =============================================================================
|
|
1807
1892
|
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
* `schedule`, mas só o runtime acrescenta um contexto aqui.
|
|
1812
|
-
*/
|
|
1813
|
-
const systemContexts = new WeakSet<object>()
|
|
1814
|
-
|
|
1815
|
-
/**
|
|
1816
|
-
* Diz se a action está executando como o sistema: disparada por um schedule, ou no job que ele
|
|
1817
|
-
* enfileirou, sem usuário. Use no `authorize` de uma action `automatable` para aceitar o sistema
|
|
1818
|
-
* de forma explícita: `authorize: (ctx) => isSystemActor(ctx) || ctx.can('caixa:fechar')`.
|
|
1819
|
-
* Passe o próprio `ctx` recebido: uma cópia, como `{ ...ctx }`, não é reconhecida.
|
|
1820
|
-
*/
|
|
1821
|
-
export function isSystemActor(ctx: ActionContext): boolean {
|
|
1822
|
-
return systemContexts.has(ctx)
|
|
1823
|
-
}
|
|
1824
|
-
|
|
1825
|
-
/**
|
|
1826
|
-
* Marca um contexto como execução do sistema. Exportado fora do index público, só para o harness
|
|
1827
|
-
* de `testing` aplicar a mesma regra no `runAction`.
|
|
1828
|
-
*/
|
|
1829
|
-
export function markSystemActor(ctx: ActionContext): void {
|
|
1830
|
-
if (ctx.user === null) systemContexts.add(ctx)
|
|
1831
|
-
}
|
|
1893
|
+
// `isSystemActor` e `markSystemActor` moram em `system-actor.ts`; o re-export mantém os caminhos
|
|
1894
|
+
// de import existentes (o harness de `testing` importa daqui).
|
|
1895
|
+
export { isSystemActor, markSystemActor }
|
|
1832
1896
|
|
|
1833
1897
|
/**
|
|
1834
1898
|
* O gate de autenticação do `execute` e do harness: sem usuário, a action precisa ser pública ou,
|