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