@softize/opus 24.1.0 → 25.1.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +121 -0
  2. package/README.md +1 -1
  3. package/bin/cli.mjs +9 -0
  4. package/bin/lib/check.mjs +390 -1
  5. package/bin/lib/copy.mjs +39 -2
  6. package/bin/lib/gen-runner.mjs +25 -12
  7. package/bin/lib/mcp.mjs +1 -1
  8. package/docs/adr/0051-soft-delete-declares-retention-and-purge.md +224 -0
  9. package/docs/adr/0052-floating-layer-coexists-with-modal-surfaces.md +668 -0
  10. package/docs/data-layer.md +31 -3
  11. package/docs/protocol.md +98 -2
  12. package/docs/releasing.md +8 -2
  13. package/docs/stacking-scale.md +53 -0
  14. package/package.json +6 -1
  15. package/src/ai/drivers/anthropic.ts +3 -0
  16. package/src/core/domain.ts +189 -7
  17. package/src/core/index.ts +19 -2
  18. package/src/core/retention.ts +493 -0
  19. package/src/core/runtime.ts +93 -29
  20. package/src/core/system-actor.ts +33 -0
  21. package/src/core/types.ts +12 -0
  22. package/src/data/drivers/kysely.ts +47 -5
  23. package/src/data/index.ts +24 -6
  24. package/src/mcp/index.ts +3 -2
  25. package/src/schema/entity.ts +30 -9
  26. package/src/schema/index.ts +4 -0
  27. package/src/schema/openapi.ts +4 -1
  28. package/src/ui/components/primitives/dialog.tsx +285 -26
  29. package/src/ui/components/primitives/drawer.tsx +104 -13
  30. package/src/ui/components/primitives/floating-layer.tsx +79 -0
  31. package/src/ui/components/primitives/menu.tsx +17 -2
  32. package/src/ui/components/primitives/popover.tsx +6 -1
  33. package/src/ui/components/primitives/tooltip.tsx +6 -1
  34. package/src/ui/docs/content/cli.md +5 -3
  35. package/src/ui/docs/content/data.md +42 -0
  36. package/src/ui/docs/content/dialog.md +3 -0
  37. package/src/ui/docs/content/drawer.md +3 -0
  38. package/src/ui/docs/content/floating-layer.md +178 -0
  39. package/src/ui/docs/content/scheduler.md +4 -0
  40. package/src/ui/docs/registry.tsx +6 -0
  41. package/src/ui/lib/dismiss-on-iframe.ts +4 -1
  42. package/src/ui/lib/modal-layers.ts +547 -0
  43. package/src/ui/meta.ts +6 -0
  44. package/src/ui/react.tsx +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
+ }
@@ -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 { collectDomainWarnings, flattenDomain, isDomainConfig } from './domain.ts'
18
- import type { DomainConfig } from './domain.ts'
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.registerFlat(actions, reactions, schedules, dataProducts)
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 && ctx.user === null) systemContexts.add(ctx)
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
- * Contextos que o próprio runtime criou para uma execução do sistema. Um conjunto interno, e não
1810
- * a provenance, decide `isSystemActor`: qualquer código servidor pode montar uma provenance
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,