@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.
@@ -9,8 +9,11 @@
9
9
  *
10
10
  * Validações em runtime (em `defineDomain`):
11
11
  * - `name` é string não-vazia no formato `[a-z][a-zA-Z0-9_]*`.
12
- * - Actions têm nomes únicos dentro do domínio (incluindo subdomains).
12
+ * - Actions e schedules têm nomes únicos dentro do domínio (incluindo subdomains e as peças de
13
+ * expurgo derivadas das entidades com retenção — ADR 0051).
13
14
  * - Subdomains têm nomes únicos entre siblings.
15
+ * - Uma entidade com retenção aparece em um só domínio da árvore, e cada `bindPurge` em `purges`
16
+ * aponta para uma entidade com `retainDays` do próprio domínio.
14
17
  *
15
18
  * Warnings (em `Runtime.register`, via `collectDomainWarnings`):
16
19
  * - Actions cujo `name` não casa com `<root>(\.<sub>)*\.<verb>` emitem
@@ -24,6 +27,16 @@
24
27
  import type { ActionContract } from './contracts.ts'
25
28
  import type { DataProductConfig } from './data-product.ts'
26
29
  import { error } from './errors.ts'
30
+ import {
31
+ entityTableName,
32
+ purgeActionName,
33
+ purgeContract,
34
+ purgeSchedule,
35
+ softDeleteRetention,
36
+ type PurgeBinding,
37
+ type SoftDeleteEntity,
38
+ type SoftDeleteRetention,
39
+ } from './retention.ts'
27
40
  import type { ActionDef, ReactionDef, ScheduleDef } from './types.ts'
28
41
 
29
42
  /* eslint-disable @typescript-eslint/no-explicit-any */
@@ -83,6 +96,13 @@ export interface DomainConfig {
83
96
  /** Schedules registrados no domínio. */
84
97
  schedules?: ScheduleDef[]
85
98
 
99
+ /**
100
+ * Handlers próprios de expurgo (`bindPurge`, server-only) das entidades com `retainDays` listadas
101
+ * em `entities` deste mesmo domínio. Sem um deles, o runtime usa o handler padrão do driver de
102
+ * dados (ADR 0051).
103
+ */
104
+ purges?: Array<PurgeBinding<any>>
105
+
86
106
  /** Subdomínios (recursivo). */
87
107
  subdomains?: DomainConfig[]
88
108
  }
@@ -95,6 +115,26 @@ export interface FlattenedDomain {
95
115
  reactions: Array<ReactionDef<any>>
96
116
  schedules: ScheduleDef[]
97
117
  dataProducts: DataProductConfig[]
118
+ /**
119
+ * O expurgo derivado de cada entidade com `retainDays` (ADR 0051). A action e o schedule também
120
+ * estão em `actions` e `schedules`; aqui ficam ligados à entidade e ao `bindPurge`, se houver,
121
+ * para o runtime anexar o handler.
122
+ */
123
+ purges: DerivedPurge[]
124
+ }
125
+
126
+ /** O expurgo que o `flattenDomain` deriva de uma entidade com retenção. */
127
+ export interface DerivedPurge {
128
+ /** Caminho completo do domínio que lista a entidade, como `report.share`. */
129
+ domain: string
130
+ entity: SoftDeleteEntity
131
+ retention: SoftDeleteRetention
132
+ /** O contrato da action, sem handler — o mesmo objeto que está em `actions`. */
133
+ action: ActionDef
134
+ /** A rotina diária — o mesmo objeto que está em `schedules`. */
135
+ schedule: ScheduleDef
136
+ /** O handler próprio, quando o domínio registrou um `bindPurge` para a entidade. */
137
+ binding?: PurgeBinding<any>
98
138
  }
99
139
 
100
140
  // =============================================================================
@@ -156,6 +196,7 @@ export function isDomainConfig(value: unknown): value is DomainConfig {
156
196
  'dicts' in v ||
157
197
  'entities' in v ||
158
198
  'dataProducts' in v ||
199
+ 'purges' in v ||
159
200
  'repository' in v ||
160
201
  'service' in v
161
202
  )
@@ -166,8 +207,33 @@ export function isDomainConfig(value: unknown): value is DomainConfig {
166
207
  * pro `Runtime.register`.
167
208
  */
168
209
  export function flattenDomain(domain: DomainConfig): FlattenedDomain {
169
- const out: FlattenedDomain = { actions: [], reactions: [], schedules: [], dataProducts: [] }
170
- collect(domain, out)
210
+ const out = emptyFlattened()
211
+ collect(domain, out, [])
212
+ return out
213
+ }
214
+
215
+ /**
216
+ * O expurgo derivado das entidades com `retainDays` de UM nível do domínio, sem descer aos
217
+ * subdomínios. `parentPath` é a trilha dos ancestrais, que entra no nome da action. Puro e isomorfo,
218
+ * como o `flattenDomain`; o gerador do manifest o usa para projetar cada domínio no seu nível.
219
+ */
220
+ export function domainPurges(domain: DomainConfig, parentPath: readonly string[] = []): DerivedPurge[] {
221
+ const path = [...parentPath, domain.name]
222
+ const out: DerivedPurge[] = []
223
+ for (const entity of retainedEntities(domain)) {
224
+ const retention = softDeleteRetention(entity) as SoftDeleteRetention
225
+ const name = purgeActionName(path, entity.name)
226
+ const binding = (domain.purges ?? []).find((item) => item?.entity === entity)
227
+ out.push({
228
+ domain: path.join('.'),
229
+ entity,
230
+ retention,
231
+ // Contrato puro: o runtime anexa o handler no `register` (ADR 0051).
232
+ action: purgeContract(name, retention) as unknown as ActionDef,
233
+ schedule: purgeSchedule(name, retention),
234
+ ...(binding !== undefined ? { binding } : {}),
235
+ })
236
+ }
171
237
  return out
172
238
  }
173
239
 
@@ -175,7 +241,11 @@ export function flattenDomain(domain: DomainConfig): FlattenedDomain {
175
241
  // Internos
176
242
  // =============================================================================
177
243
 
178
- function collect(domain: DomainConfig, out: FlattenedDomain): void {
244
+ function emptyFlattened(): FlattenedDomain {
245
+ return { actions: [], reactions: [], schedules: [], dataProducts: [], purges: [] }
246
+ }
247
+
248
+ function collect(domain: DomainConfig, out: FlattenedDomain, parentPath: readonly string[]): void {
179
249
  for (const action of iterateActions(domain.actions)) {
180
250
  out.actions.push(action)
181
251
  }
@@ -185,6 +255,11 @@ function collect(domain: DomainConfig, out: FlattenedDomain): void {
185
255
  if (domain.schedules !== undefined) {
186
256
  for (const schedule of domain.schedules) out.schedules.push(schedule)
187
257
  }
258
+ for (const purge of domainPurges(domain, parentPath)) {
259
+ out.actions.push(purge.action)
260
+ out.schedules.push(purge.schedule)
261
+ out.purges.push(purge)
262
+ }
188
263
  if (domain.dataProducts !== undefined) {
189
264
  const products = Array.isArray(domain.dataProducts)
190
265
  ? domain.dataProducts
@@ -192,10 +267,29 @@ function collect(domain: DomainConfig, out: FlattenedDomain): void {
192
267
  for (const product of products) out.dataProducts.push(product)
193
268
  }
194
269
  if (domain.subdomains !== undefined) {
195
- for (const sub of domain.subdomains) collect(sub, out)
270
+ const path = [...parentPath, domain.name]
271
+ for (const sub of domain.subdomains) collect(sub, out, path)
196
272
  }
197
273
  }
198
274
 
275
+ /** Entidades de `entities` com `retainDays`, sem repetir a mesma referência. */
276
+ function retainedEntities(domain: DomainConfig): SoftDeleteEntity[] {
277
+ const out: SoftDeleteEntity[] = []
278
+ for (const raw of Object.values(domain.entities ?? {})) {
279
+ if (!isNamedEntity(raw) || softDeleteRetention(raw) === null) continue
280
+ if (!out.includes(raw)) out.push(raw)
281
+ }
282
+ return out
283
+ }
284
+
285
+ function isNamedEntity(value: unknown): value is SoftDeleteEntity {
286
+ return (
287
+ typeof value === 'object' &&
288
+ value !== null &&
289
+ typeof (value as { name?: unknown }).name === 'string'
290
+ )
291
+ }
292
+
199
293
  /**
200
294
  * Normaliza `actions` (record | array | record-of-array) em iterável plano
201
295
  * de `ActionDef`.
@@ -269,8 +363,11 @@ function validateDomain(domain: DomainConfig, path: string[]): void {
269
363
  // — actions: nomes únicos no domínio (e subdomínios) —
270
364
  // (Roda *depois* dos subdomains: assim erros de nome em subs aparecem
271
365
  // primeiro, antes de detectar duplicatas cruzadas.)
272
- const collected: FlattenedDomain = { actions: [], reactions: [], schedules: [], dataProducts: [] }
273
- collect(domain, collected)
366
+ assertRetainedEntitiesOnce(domain, path)
367
+ assertPurgeBindings(domain, fullPathStr)
368
+
369
+ const collected = emptyFlattened()
370
+ collect(domain, collected, path)
274
371
 
275
372
  const actionNames = new Set<string>()
276
373
  for (const action of collected.actions) {
@@ -284,6 +381,18 @@ function validateDomain(domain: DomainConfig, path: string[]): void {
284
381
  actionNames.add(action.name)
285
382
  }
286
383
 
384
+ const scheduleNames = new Set<string>()
385
+ for (const schedule of collected.schedules) {
386
+ if (scheduleNames.has(schedule.name)) {
387
+ throw error({
388
+ code: 'domain.duplicate_schedule',
389
+ category: 'internal',
390
+ message: `Schedule "${schedule.name}" duplicado em "${fullPathStr}"`,
391
+ })
392
+ }
393
+ scheduleNames.add(schedule.name)
394
+ }
395
+
287
396
  const entityNames = new Set<string>()
288
397
  const collectEntityNames = (current: DomainConfig): void => {
289
398
  for (const [key, raw] of Object.entries(current.entities ?? {})) {
@@ -327,6 +436,79 @@ function validateDomain(domain: DomainConfig, path: string[]): void {
327
436
  }
328
437
  }
329
438
 
439
+ /**
440
+ * A action e a rotina do expurgo nascem no domínio que lista a entidade: a mesma entidade com
441
+ * retenção em dois pontos da árvore geraria dois expurgos da mesma tabela (ADR 0051). A comparação
442
+ * é pela tabela, para pegar também duas declarações `defineEntity` da mesma tabela.
443
+ */
444
+ function assertRetainedEntitiesOnce(domain: DomainConfig, path: string[]): void {
445
+ const owners = new Map<string, string>()
446
+ const walk = (current: DomainConfig, parent: string[]): void => {
447
+ const here = [...parent, current.name].join('.')
448
+ const listed = Object.values(current.entities ?? {}).filter(
449
+ (raw): raw is SoftDeleteEntity => isNamedEntity(raw) && softDeleteRetention(raw) !== null,
450
+ )
451
+ for (const entity of listed) {
452
+ const table = entityTableName(entity)
453
+ const owner = owners.get(table)
454
+ if (owner !== undefined) {
455
+ throw entityRegisteredTwice(entity.name, table, owner, here)
456
+ }
457
+ owners.set(table, here)
458
+ }
459
+ for (const sub of current.subdomains ?? []) walk(sub, [...parent, current.name])
460
+ }
461
+ walk(domain, path)
462
+ }
463
+
464
+ /** Erro compartilhado com o `register`, que confere o mesmo entre domínios separados. */
465
+ export function entityRegisteredTwice(
466
+ entity: string,
467
+ table: string,
468
+ first: string,
469
+ second: string,
470
+ ): ReturnType<typeof error> {
471
+ const where = first === second ? `duas vezes em "${first}"` : `em "${first}" e em "${second}"`
472
+ return error({
473
+ code: 'domain.entity_registered_twice',
474
+ category: 'internal',
475
+ message:
476
+ `A entidade "${entity}" (tabela "${table}") tem retenção e está registrada ${where}. O ` +
477
+ 'expurgo nasce no domínio que lista a entidade em `entities`, e duas declarações da mesma ' +
478
+ 'tabela gerariam dois expurgos dela; mantenha uma só entidade com retenção por tabela, num só domínio.',
479
+ meta: { entity, table, domains: [first, second] },
480
+ })
481
+ }
482
+
483
+ /** Cada `bindPurge` em `purges` aponta para uma entidade com `retainDays` do próprio domínio. */
484
+ function assertPurgeBindings(domain: DomainConfig, domainPath: string): void {
485
+ const retained = retainedEntities(domain)
486
+ const listed = Object.values(domain.entities ?? {})
487
+ const bound = new Set<unknown>()
488
+ for (const binding of domain.purges ?? []) {
489
+ const entity = binding?.entity as SoftDeleteEntity | undefined
490
+ const name = typeof entity?.name === 'string' ? entity.name : '(sem nome)'
491
+ if (entity === undefined || !retained.includes(entity)) {
492
+ const reason = entity !== undefined && listed.includes(entity)
493
+ ? 'não declara `softDelete` com `retainDays`: sem prazo, não há expurgo para o handler executar'
494
+ : 'não está em `entities` deste domínio. Registre o bindPurge no domínio que lista a entidade'
495
+ throw error({
496
+ code: 'domain.invalid_purge',
497
+ category: 'internal',
498
+ message: `O bindPurge de "${name}" em "${domainPath}" aponta para uma entidade que ${reason}.`,
499
+ })
500
+ }
501
+ if (bound.has(entity)) {
502
+ throw error({
503
+ code: 'domain.duplicate_purge',
504
+ category: 'internal',
505
+ message: `A entidade "${name}" tem mais de um bindPurge em "${domainPath}".`,
506
+ })
507
+ }
508
+ bound.add(entity)
509
+ }
510
+ }
511
+
330
512
  /**
331
513
  * Caminha a árvore emitindo warnings de nomenclatura. `path` é a trilha de
332
514
  * ancestrais (excluindo o próprio `domain`).
package/src/core/index.ts CHANGED
@@ -198,8 +198,25 @@ export type { ReactionContract, ReactionBinding } from './reactions.ts'
198
198
  export { defineSchedule, isSchedule } from './schedules.ts'
199
199
 
200
200
  // — Domain ————————————————————————————————————————————————————————————————————
201
- export { defineDomain, flattenDomain, isDomainConfig } from './domain.ts'
202
- export type { DomainConfig, FlattenedDomain } from './domain.ts'
201
+ export { defineDomain, domainPurges, flattenDomain, isDomainConfig } from './domain.ts'
202
+ export type { DerivedPurge, DomainConfig, FlattenedDomain } from './domain.ts'
203
+
204
+ // — Retenção da exclusão lógica (ADR 0051) ——————————————————————————————————————
205
+ // `hasSoftDelete` mora em `@softize/opus/schema`, com a entidade; `bindPurge`, em
206
+ // `@softize/opus/data`, porque o handler é server-only.
207
+ export { declaredJsonSchema } from './retention.ts'
208
+ export type {
209
+ PurgeBatchArgs,
210
+ PurgeBinding,
211
+ PurgeHandler,
212
+ PurgeInput,
213
+ PurgeOutput,
214
+ SoftDelete,
215
+ SoftDeleteForever,
216
+ SoftDeleteRetention,
217
+ SoftDeleteSchedule,
218
+ StandardJsonSchema,
219
+ } from './retention.ts'
203
220
 
204
221
  // — Produtos de Dados ————————————————————————————————————————————————————————
205
222
  export { defineDataProduct, isDataProduct } from './data-product.ts'