@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
package/src/core/domain.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
273
|
-
|
|
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'
|