cvx-kit 0.0.1 → 0.0.3
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/README.md +17 -0
- package/dist/auth.d.mts +52 -5
- package/dist/auth.mjs +1 -1
- package/dist/auth.mjs.map +1 -1
- package/dist/components/approvals/audit.mjs +1 -1
- package/dist/components/foundation/client.d.mts +40 -3
- package/dist/components/foundation/client.mjs +1 -1
- package/dist/components/foundation/client.mjs.map +1 -1
- package/dist/index.d.mts +6 -5
- package/dist/index.mjs +1 -1
- package/dist/tenancy.d.mts +34 -0
- package/dist/tenancy.mjs +2 -0
- package/dist/tenancy.mjs.map +1 -0
- package/dist/triggers.d.mts +9 -1
- package/dist/triggers.mjs +1 -1
- package/dist/triggers.mjs.map +1 -1
- package/dist/zod-table.d.mts +21 -1
- package/dist/zod-table.mjs +1 -1
- package/dist/zod-table.mjs.map +1 -1
- package/docs/approvals.md +152 -0
- package/docs/architecture.md +124 -0
- package/docs/auth.md +167 -0
- package/docs/commands.md +229 -0
- package/docs/conventions.md +291 -0
- package/docs/explain/convex-architecture-explainer.html +460 -0
- package/docs/explain/modules-and-multitenancy.html +469 -0
- package/docs/llms.md +206 -0
- package/docs/maintainability.md +124 -0
- package/docs/migration.md +351 -0
- package/docs/tenancy.md +179 -0
- package/docs/triggers.md +75 -0
- package/docs/zod-table.md +173 -0
- package/package.json +10 -3
- package/src/auth.ts +127 -14
- package/src/components/foundation/client.ts +114 -3
- package/src/index.ts +15 -0
- package/src/tenancy.ts +110 -0
- package/src/triggers.ts +34 -0
- package/src/zod-table.ts +86 -0
package/docs/llms.md
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# cvx-kit — condensed reference for LLMs and coding agents
|
|
2
|
+
|
|
3
|
+
> Paste this file (installed at `node_modules/cvx-kit/docs/`) into an agent's
|
|
4
|
+
> context when it works on a cvx-kit application. Detailed docs:
|
|
5
|
+
> `architecture.md`, `conventions.md`, `zod-table.md`, `auth.md`,
|
|
6
|
+
> `commands.md`, `triggers.md`, `approvals.md`, `maintainability.md`,
|
|
7
|
+
> `migration.md` (same directory).
|
|
8
|
+
> Structure, file anatomy, and naming rules → `conventions.md` is
|
|
9
|
+
> authoritative. Restructuring an existing raw project → `migration.md`.
|
|
10
|
+
|
|
11
|
+
> **Placeholders.** Names like `documents`, `users`, `history`, `title`,
|
|
12
|
+
> `ownerId`, and `documentPublish` in the examples below are illustrative
|
|
13
|
+
> only — replace them with the application's own entities and fields. No
|
|
14
|
+
> specific table, entity, field, or workflow is required by the kit. Names in
|
|
15
|
+
> `<angle brackets>` are always placeholders.
|
|
16
|
+
|
|
17
|
+
cvx-kit is a reusable Convex application kit: zod table boundaries, auth-aware
|
|
18
|
+
function constructors, a trigger registry, an audited command protocol
|
|
19
|
+
(Foundation component), and a declarative approvals component. Peer deps:
|
|
20
|
+
`convex ^1.43`, `zod ^4`.
|
|
21
|
+
|
|
22
|
+
## Non-negotiable rules
|
|
23
|
+
|
|
24
|
+
1. **One `zodTable` per entity**, in `domain/<entity>/schema.ts`. Never write
|
|
25
|
+
`defineTable` inline or a second zod object for the same entity.
|
|
26
|
+
2. **Raw `query`/`mutation`/`action`/`internal*` builders appear only in
|
|
27
|
+
`convex/functions.ts`** (the `createAuthFunctions` call). All other
|
|
28
|
+
functions use `authQuery/authMutation/authAction`, `roleQuery/...`,
|
|
29
|
+
`adminQuery/...` (public) or `systemMutation/systemAction` (internal).
|
|
30
|
+
This is what guarantees auth, triggers, and bounded reads.
|
|
31
|
+
3. **Every public query returns DTOs** via `<table>.toPublicDto(row)` —
|
|
32
|
+
runtime redaction, not just types.
|
|
33
|
+
4. **Every read is bounded**: `ctx.include(ctx.db.query('t')).matching(...)
|
|
34
|
+
.execute(limit)` with `1 ≤ limit ≤ 100`. `.resolve()` falls back to a full
|
|
35
|
+
table scan — avoid it.
|
|
36
|
+
5. **State changes are commands**: an operations registry with mandatory
|
|
37
|
+
`classification` and `audit()` per operation, executed via
|
|
38
|
+
`commands.exec({ operation, handler })`. Audit writes happen in the same
|
|
39
|
+
transaction. Optional per-operation `permission` (checked via the
|
|
40
|
+
Foundation's injected `checkPermission`) and `guard(ctx, command)`
|
|
41
|
+
preconditions run BEFORE the handler, plus a registry-wide default guard
|
|
42
|
+
(`new Command(operations, { guard })`). Order: permission → default guard
|
|
43
|
+
→ operation guard → handler → audit.
|
|
44
|
+
6. **Never write timestamps by hand.** `createdAt`/`updatedAt` are maintained
|
|
45
|
+
by the `timestamps` trigger; `archivedAt` is the app-controlled soft-delete
|
|
46
|
+
marker.
|
|
47
|
+
7. **Vocabularies are named readonly tuples in `constants.ts`** —
|
|
48
|
+
`z.enum([...])` with an inline literal is banned in kit-style codebases.
|
|
49
|
+
8. **Components are consumed only through their client facades**
|
|
50
|
+
(`Foundation`, `Approvals`) — no deep imports, no touching component
|
|
51
|
+
tables, no reaching private children (`components.approvals.workflow`).
|
|
52
|
+
9. **Operation names are `domain.verb` lowercase-dotted; error codes are
|
|
53
|
+
UPPER_SNAKE** — otherwise observability silently drops the events.
|
|
54
|
+
10. **Row-level security is configured, not hand-rolled**: the optional
|
|
55
|
+
`security` config on `createAuthFunctions` takes role-level `rules`
|
|
56
|
+
(works standalone) and/or `tenancy` (a table registry; adds
|
|
57
|
+
`ctx.tenant`, deny-default isolation, and pairs with `tenantTable` +
|
|
58
|
+
`tenantOwnership`). Multi-tenant apps stamp inserts with
|
|
59
|
+
`tenant: ctx.tenant` and re-verify client ids with
|
|
60
|
+
`requireTenantReference`. See `tenancy.md`.
|
|
61
|
+
|
|
62
|
+
## Exports map
|
|
63
|
+
|
|
64
|
+
| Import | Provides |
|
|
65
|
+
|---|---|
|
|
66
|
+
| `cvx-kit` | everything below re-exported (except components' defaults) |
|
|
67
|
+
| `cvx-kit/zod-table` | `zodTable`, `tenantTable`, `createModule`, `zodVariantTable`, `jsonSafeZid`, `TIMESTAMP_FIELDS` |
|
|
68
|
+
| `cvx-kit/auth` | `createAuthFunctions` (incl. optional `security` RLS config), `createInclude`, `defaultRoleMap` |
|
|
69
|
+
| `cvx-kit/tenancy` | `createTenantRules`, `composeRules`, `requireTenantReference`, `assertTenantOwned`, `TENANT_FIELD` |
|
|
70
|
+
| `cvx-kit/triggers` | `createTriggers`, `timestamps`, `appendOnly`, `noDelete`, `tenantOwnership`, `Triggers` |
|
|
71
|
+
| `cvx-kit/errors` | `KitError`, `defaultErrors`, `ErrorFactory` |
|
|
72
|
+
| `cvx-kit/components/foundation` | `Foundation`, `executeResultBoundary`, `projectResult`, `Observability`; default export = component config for `app.use` |
|
|
73
|
+
| `cvx-kit/components/approvals` | `Approvals` client; default export = component config for `app.use` |
|
|
74
|
+
| `cvx-kit/test` | `registerFoundation(t)`, `registerApprovals(t)` for convex-test |
|
|
75
|
+
|
|
76
|
+
## Minimal app wiring (the five root files)
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
// convex/convex.config.ts
|
|
80
|
+
import { defineApp } from 'convex/server'
|
|
81
|
+
import foundation from 'cvx-kit/components/foundation'
|
|
82
|
+
import approvals from 'cvx-kit/components/approvals'
|
|
83
|
+
const app = defineApp()
|
|
84
|
+
app.use(foundation)
|
|
85
|
+
app.use(approvals)
|
|
86
|
+
export default app
|
|
87
|
+
|
|
88
|
+
// convex/triggers.ts
|
|
89
|
+
import { createTriggers, timestamps, appendOnly } from 'cvx-kit/triggers'
|
|
90
|
+
export const triggers = createTriggers<DataModel>()
|
|
91
|
+
timestamps(triggers, 'documents')
|
|
92
|
+
appendOnly(triggers, 'history')
|
|
93
|
+
|
|
94
|
+
// convex/functions.ts
|
|
95
|
+
export const { authQuery, authMutation, authAction, adminQuery, adminMutation,
|
|
96
|
+
adminAction, roleQuery, roleMutation, roleAction, systemMutation,
|
|
97
|
+
systemAction, include } = createAuthFunctions<DataModel>({
|
|
98
|
+
query, mutation, action, internalMutation, internalAction, // from ./_generated/server
|
|
99
|
+
getAuthUser: (ctx) => authKit.getAuthUser(ctx),
|
|
100
|
+
mapRole: defaultRoleMap, // 'member'→'writer'; reader|writer|admin pass
|
|
101
|
+
adminRoles: ['admin'],
|
|
102
|
+
triggers,
|
|
103
|
+
verifyMembership: async ({ userId, organizationId }) => { /* live check; actions only */ },
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
// convex/foundation.ts
|
|
107
|
+
export const { Command, Query, observability } = new Foundation(
|
|
108
|
+
components.foundation,
|
|
109
|
+
{ observability: {
|
|
110
|
+
enabled: () => process.env.OBS === 'true',
|
|
111
|
+
classifyError, // → { outcome: 'denied'|'failed', errorCode }
|
|
112
|
+
writeAudit: (ctx, entry) => writeAuditEntry(ctx, entry),
|
|
113
|
+
} },
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
// convex/approvals.ts
|
|
117
|
+
export const approvals = new Approvals(components.approvals)
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Entity pattern
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
// domain/documents/schema.ts
|
|
124
|
+
export const documents = zodTable('documents', (id) => ({
|
|
125
|
+
title: z.string(), ownerId: id('users'), secretNote: z.string(),
|
|
126
|
+
}), {
|
|
127
|
+
commandFields: ['title'], // what a command may say
|
|
128
|
+
publicFields: ['title', 'ownerId'], // the DTO allowlist
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
// convex/schema.ts (via domain/table.ts)
|
|
132
|
+
defineSchema({ documents: documents.table.index('by_owner', ['ownerId']) })
|
|
133
|
+
|
|
134
|
+
// domain/documents/commands.ts
|
|
135
|
+
const operations = {
|
|
136
|
+
'documents.rename': Command.operation({
|
|
137
|
+
command: documents.commandInput.extend({ id: zid('documents'), actorId: z.string() }),
|
|
138
|
+
result: z.object({ ok: z.literal(true) }).strict(),
|
|
139
|
+
classification: 'business',
|
|
140
|
+
audit: ({ command }) => ({ operation: 'documents.rename', actorId: command.actorId,
|
|
141
|
+
aggregate: { type: 'document', id: command.id } }),
|
|
142
|
+
}),
|
|
143
|
+
} as const
|
|
144
|
+
const commands = new Command<MutationCtx, typeof operations>(operations)
|
|
145
|
+
export const executeRename = commands.exec({ operation: 'documents.rename',
|
|
146
|
+
handler: async (ctx, cmd) => { await ctx.db.patch(cmd.id, { title: cmd.title }); return { ok: true } } })
|
|
147
|
+
|
|
148
|
+
// api/documents.ts — thin public adapter
|
|
149
|
+
export const rename = authMutation({
|
|
150
|
+
args: documents.commandInput.extend({ id: zid('documents') }),
|
|
151
|
+
returns: z.object({ ok: z.literal(true) }).strict(),
|
|
152
|
+
handler: (ctx, args) => executeRename(ctx, { ...args, actorId: ctx.actor.userId }),
|
|
153
|
+
})
|
|
154
|
+
export const list = authQuery({
|
|
155
|
+
args: { limit: z.number() },
|
|
156
|
+
returns: z.array(documents.publicDto),
|
|
157
|
+
handler: (ctx, { limit }) =>
|
|
158
|
+
ctx.include(ctx.db.query('documents'))
|
|
159
|
+
.matching('by_owner', (ix) => ix.eq('ownerId', ctx.actor.userId))
|
|
160
|
+
.execute(limit, (rows) => rows.map(documents.toPublicDto)),
|
|
161
|
+
})
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## Auth ctx contents
|
|
165
|
+
|
|
166
|
+
Authenticated handlers receive frozen `ctx.identity` (JWT), `ctx.user.id`,
|
|
167
|
+
`ctx.org = { organizationId, role }`, `ctx.role`, `ctx.actor = { userId,
|
|
168
|
+
organizationId, role }`, and `ctx.include`. Queries/mutations trust the JWT;
|
|
169
|
+
**actions live-verify membership and fail closed**. Use `ctx.actor` as the
|
|
170
|
+
canonical actor for audits/approvals.
|
|
171
|
+
|
|
172
|
+
## Approvals in one block
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
export const publishApproval = approvals.define({ name: 'documentPublish', steps: [
|
|
176
|
+
approvals.decision('managerDecision', { decisions: ['approved','rejected'],
|
|
177
|
+
quorum: { kind: 'count', approvals: 1 }, makerChecker: true, expiresAfterMs: 604_800_000 }),
|
|
178
|
+
approvals.branch('applyDecision', { approvedStepKey: 'publish', rejectedStepKey: 'notify' }),
|
|
179
|
+
approvals.mutation('publish', { handler: internal.domain.documents.approval_functions.applyDecision }),
|
|
180
|
+
approvals.notify('notify', { handler: internal.domain.documents.approval_functions.notifyRejected }),
|
|
181
|
+
]})
|
|
182
|
+
// start(ctx, { scopeRef, resourceType, resourceRef, requester: ctx.actor, metadata? })
|
|
183
|
+
// decide(ctx, { runId, decision, reason? }, ctx.actor) · status/evidence/list/cancel/restart
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Callback handlers are `systemMutation`/`systemAction`, receive opaque strings,
|
|
187
|
+
and must `normalizeId` + re-check `resourceType` + verify the resource still
|
|
188
|
+
points at `runId`.
|
|
189
|
+
|
|
190
|
+
## Footguns (top 8)
|
|
191
|
+
|
|
192
|
+
1. `t.run(ctx => ctx.db...)` in convex-test and raw builders bypass ALL
|
|
193
|
+
triggers — seeding only.
|
|
194
|
+
2. `executeResultBoundary` returns a typed failure **only when the
|
|
195
|
+
transaction has no effects yet**; after any write/schedule it rethrows for
|
|
196
|
+
rollback. Handled-vs-thrown is decided by transaction state.
|
|
197
|
+
3. Observability drops events on identifier-regex mismatch — see rule 9.
|
|
198
|
+
4. Mis-declared `serverFields` silently make server-owned fields
|
|
199
|
+
client-writable; masks are the security boundary.
|
|
200
|
+
5. `include(...).resolve()` silently falls back to `fullTableScan()`.
|
|
201
|
+
6. Audit callback ctx is untyped (`never`) — commands must run in mutations;
|
|
202
|
+
wiring a query ctx fails at runtime.
|
|
203
|
+
7. Changing approval steps without bumping `compatibilityKey` lets old runs
|
|
204
|
+
be driven by an incompatible shape.
|
|
205
|
+
8. When composing ctx manually, spread `wrapDB(ctx)` first — later spreads
|
|
206
|
+
carrying `db` restore the unwrapped database.
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# Maintaining a Convex application built on cvx-kit
|
|
2
|
+
|
|
3
|
+
Practices that keep a cvx-kit backend maintainable over years, taken from the
|
|
4
|
+
origin application. The theme throughout: make every rule either *structural*
|
|
5
|
+
(the code can't be written any other way) or *executable* (a test fails when
|
|
6
|
+
it's violated). Conventions that live only in documentation decay.
|
|
7
|
+
|
|
8
|
+
## Schema and indexes
|
|
9
|
+
|
|
10
|
+
- `convex/schema.ts` stays at ~4 lines: `defineSchema(domainTables)`.
|
|
11
|
+
Per-domain `table.ts` files attach indexes to `<zodTable>.table` and
|
|
12
|
+
`domain/table.ts` merges the maps. Shapes (zod) and topology (indexes) are
|
|
13
|
+
deliberately separate files.
|
|
14
|
+
- **Index naming**: `by_<field>_and_<field>...`, camelCase field names in
|
|
15
|
+
declaration order, nested paths flattened with `_`
|
|
16
|
+
(`by_assignedToUserId_and_state_status` for `['assignedToUserId',
|
|
17
|
+
'state.status']`). A reader should reconstruct the index definition from its
|
|
18
|
+
name.
|
|
19
|
+
- Every read goes through `ctx.include(...)` with an explicit limit ≤ 100.
|
|
20
|
+
If a query can't be served by an index, that's a schema problem, not a
|
|
21
|
+
reason to `fullTableScan`.
|
|
22
|
+
|
|
23
|
+
## Schema evolution without downtime
|
|
24
|
+
|
|
25
|
+
Convex validates the whole table against the schema on push, so evolution is
|
|
26
|
+
additive:
|
|
27
|
+
|
|
28
|
+
1. **New fields arrive `.optional()`** with a comment explaining the epoch:
|
|
29
|
+
`// Optional only for documents created before priority was frozen.`
|
|
30
|
+
2. Deploy code that writes the new field on every new write and tolerates its
|
|
31
|
+
absence on reads.
|
|
32
|
+
3. Backfill if needed (a `systemMutation` batch or `@convex-dev/migrations`),
|
|
33
|
+
then — and only then — consider tightening the schema.
|
|
34
|
+
4. Never rename in place: add the new field, dual-write, backfill, drop the
|
|
35
|
+
old one release(s) later.
|
|
36
|
+
|
|
37
|
+
zod-table makes step 1 cheap: change the shape once and every boundary mask
|
|
38
|
+
updates together, with `tsc` pointing at every consumer that must adapt.
|
|
39
|
+
|
|
40
|
+
## Function hygiene
|
|
41
|
+
|
|
42
|
+
- Raw `query`/`mutation`/`action` builders appear **only** in `functions.ts`.
|
|
43
|
+
Everything public is `auth*`/`role*`/`admin*`; everything
|
|
44
|
+
internal/scheduled is `system*`. This is what makes triggers, auth, and
|
|
45
|
+
bounded reads impossible to forget.
|
|
46
|
+
- Public functions in `api/` are thin: parse args (zod-table masks), call the
|
|
47
|
+
domain executor with `ctx.actor`, project through `toPublicDto`. Business
|
|
48
|
+
logic that lives in an adapter can't be reused by workflows or callbacks —
|
|
49
|
+
keep it in the domain.
|
|
50
|
+
- Vocabularies (states, kinds, decisions) are single-owner readonly tuples in
|
|
51
|
+
`constants.ts`. Grep for the tuple name to find every consumer.
|
|
52
|
+
|
|
53
|
+
## Audit and observability as maintenance tools
|
|
54
|
+
|
|
55
|
+
- Every state change is a command with a mandatory classification and audit
|
|
56
|
+
derivation — so "what happened to this record" is a query over the audit
|
|
57
|
+
log, not archaeology. Keep operation names stable (`domain.verb`); they are
|
|
58
|
+
your telemetry and audit vocabulary.
|
|
59
|
+
- Observability is payload-free by construction — you can leave it on in
|
|
60
|
+
production and grep durable one-line JSON events
|
|
61
|
+
(`event: 'command.execution'`) without PII review.
|
|
62
|
+
- Set retention deliberately: audit entries carry the classification as their
|
|
63
|
+
retention category; the approvals component ships `cleanupAudit` for its own
|
|
64
|
+
trail.
|
|
65
|
+
|
|
66
|
+
## Testing strategy
|
|
67
|
+
|
|
68
|
+
Three test layers, all in-memory via `convex-test` on `edge-runtime`:
|
|
69
|
+
|
|
70
|
+
1. **Behavioral tests** — drive functions through the real constructors
|
|
71
|
+
(`t.withIdentity(...)`), assert DTO shapes and trigger effects. Remember:
|
|
72
|
+
`t.run(ctx => ctx.db...)` bypasses triggers — use it for seeding, not for
|
|
73
|
+
asserting write behavior.
|
|
74
|
+
2. **Architecture tests** — glob the source and assert the conventions
|
|
75
|
+
(no inline tables/enums, no deep imports, no sibling-domain imports, one
|
|
76
|
+
`app.use` per component, no raw builders outside `functions.ts`). Prove
|
|
77
|
+
each rule against a synthetic violating fixture first.
|
|
78
|
+
3. **Boundary/copyability tests** — components contain no host imports and no
|
|
79
|
+
vendor/env references; optionally `cp -r` the component to a tmpdir and
|
|
80
|
+
run `tsc` to prove copy-portability.
|
|
81
|
+
|
|
82
|
+
Consumers register the kit's components on their test instance:
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
import { convexTest } from 'convex-test'
|
|
86
|
+
import { registerApprovals, registerFoundation } from 'cvx-kit/test'
|
|
87
|
+
|
|
88
|
+
const t = convexTest(schema, modules)
|
|
89
|
+
registerFoundation(t)
|
|
90
|
+
registerApprovals(t)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
(`cvx-kit/test` ships as source and needs a vitest-compatible runner —
|
|
94
|
+
`import.meta.glob`.) Nested components used by approvals register their own
|
|
95
|
+
test modules the same way (`@convex-dev/workflow/test`,
|
|
96
|
+
`convex-audit-log/test`). Stub required env vars (declared on
|
|
97
|
+
`defineApp({ env })`) in test setup.
|
|
98
|
+
|
|
99
|
+
## Operational habits
|
|
100
|
+
|
|
101
|
+
- **Env flags over redeploys**: observability is toggled by an env var read
|
|
102
|
+
at call time (`enabled: () => env.OBS === 'true'`).
|
|
103
|
+
- **Fail closed**: membership verification errors reject; unknown roles
|
|
104
|
+
reject; unreadable transaction metrics rethrow. Preserve this bias in app
|
|
105
|
+
code.
|
|
106
|
+
- **Soft deletes** (`archivedAt` + `noDelete`) for anything a human might ask
|
|
107
|
+
about later; `appendOnly` for evidence tables. Hard deletes are a data-loss
|
|
108
|
+
bug you opt into per table.
|
|
109
|
+
- **Component upgrades**: components own their tables; upgrade them like
|
|
110
|
+
services — read the component's migration notes, bump, run codegen, run the
|
|
111
|
+
suite. Never write to a component's tables from the host.
|
|
112
|
+
|
|
113
|
+
## Checklist for a new entity
|
|
114
|
+
|
|
115
|
+
1. `domain/<entity>/schema.ts` — `zodTable` with the three masks thought
|
|
116
|
+
through (`serverFields`, `commandFields`, `publicFields`).
|
|
117
|
+
2. `domain/<entity>/table.ts` — indexes, named `by_...`.
|
|
118
|
+
3. Register in `domain/table.ts`; add `timestamps(triggers, '<table>')` and
|
|
119
|
+
any `appendOnly`/`noDelete` discipline.
|
|
120
|
+
4. `constants.ts` — vocabularies as readonly tuples.
|
|
121
|
+
5. `commands.ts` — operation registry (`domain.verb`, classification, audit)
|
|
122
|
+
+ executors.
|
|
123
|
+
6. `api/<entity>.ts` — thin `auth*` adapters, DTO projection.
|
|
124
|
+
7. Tests: behavior + add the entity to the architecture globs.
|