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/commands.md
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# `cvx-kit/components/foundation` — the audited command protocol
|
|
2
|
+
|
|
3
|
+
The Foundation is a Convex component that **owns zero tables and zero
|
|
4
|
+
capability** — its schema is empty by design. What it provides is the
|
|
5
|
+
application kernel: the `Command` protocol (validate → observe → execute →
|
|
6
|
+
audit, in one transaction), the `Query` kernel, and payload-free
|
|
7
|
+
`Observability`. It is declared **once** per app and everything destructures
|
|
8
|
+
from it; there is no separate command import.
|
|
9
|
+
|
|
10
|
+
## Mounting and declaring
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
// convex/convex.config.ts
|
|
14
|
+
import { defineApp } from 'convex/server'
|
|
15
|
+
import foundation from 'cvx-kit/components/foundation'
|
|
16
|
+
|
|
17
|
+
const app = defineApp()
|
|
18
|
+
app.use(foundation)
|
|
19
|
+
export default app
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
// convex/foundation.ts — declared once, the single kernel source
|
|
24
|
+
import { Foundation } from 'cvx-kit/components/foundation'
|
|
25
|
+
import { components } from './_generated/api'
|
|
26
|
+
import { writeAuditEntry } from './audit'
|
|
27
|
+
|
|
28
|
+
export const { Command, Query, observability } = new Foundation(
|
|
29
|
+
components.foundation,
|
|
30
|
+
{
|
|
31
|
+
observability: {
|
|
32
|
+
enabled: () => process.env.COMMAND_OBSERVABILITY_ENABLED === 'true',
|
|
33
|
+
classifyError: (error) => classify(error), // → { outcome: 'denied' | 'failed', errorCode }
|
|
34
|
+
writeAudit: (ctx, entry) => writeAuditEntry(ctx, entry),
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Host code consumes the Foundation **only through this facade** — never deep
|
|
41
|
+
imports of the component's internal modules.
|
|
42
|
+
|
|
43
|
+
## Defining commands — per domain
|
|
44
|
+
|
|
45
|
+
Each domain declares a frozen registry of operations and derives typed
|
|
46
|
+
executors from it. `classification` and `audit()` are **mandatory per
|
|
47
|
+
operation** — auditing is type-enforced, not opt-in.
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
// convex/domain/documents/commands.ts
|
|
51
|
+
import { z } from 'zod'
|
|
52
|
+
import { Command } from '../../foundation'
|
|
53
|
+
import { documents } from './schema'
|
|
54
|
+
|
|
55
|
+
const operations = {
|
|
56
|
+
'documents.rename': Command.operation({
|
|
57
|
+
command: documents.commandInput.extend({ id: zid('documents') }),
|
|
58
|
+
result: z.object({ ok: z.literal(true) }).strict(),
|
|
59
|
+
classification: 'business',
|
|
60
|
+
audit: ({ command }) => ({
|
|
61
|
+
operation: 'documents.rename',
|
|
62
|
+
actorId: command.actorId,
|
|
63
|
+
aggregate: { type: 'document', id: command.id },
|
|
64
|
+
metadata: { title: command.title },
|
|
65
|
+
}),
|
|
66
|
+
}),
|
|
67
|
+
} as const
|
|
68
|
+
|
|
69
|
+
const commands = new Command<MutationCtx, typeof operations>(operations)
|
|
70
|
+
|
|
71
|
+
export const executeRename = commands.exec({
|
|
72
|
+
operation: 'documents.rename',
|
|
73
|
+
handler: async (ctx, command) => {
|
|
74
|
+
await ctx.db.patch(command.id, { title: command.title })
|
|
75
|
+
return { ok: true }
|
|
76
|
+
},
|
|
77
|
+
})
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The public API layer then wraps the executor in an `authMutation` and passes
|
|
81
|
+
`ctx.actor` in. Public functions stay thin adapters; domain logic lives behind
|
|
82
|
+
`execute*` functions.
|
|
83
|
+
|
|
84
|
+
## What one execution does, in order
|
|
85
|
+
|
|
86
|
+
1. **Parse the command** through the operation's `command` schema (strict zod).
|
|
87
|
+
2. **Observe**: start the observation clock.
|
|
88
|
+
3. **Permission check** — if the operation declares `permission`, the
|
|
89
|
+
Foundation's injected `checkPermission(ctx, { permission, operation })`
|
|
90
|
+
runs; throw to deny. Declaring a permission with no injected checker
|
|
91
|
+
**fails closed** (`COMMAND_PERMISSION_NOT_CONFIGURED`).
|
|
92
|
+
4. **Default guard** — the registry-wide guard passed as the Command's second
|
|
93
|
+
argument (`new Command(operations, { guard })`), if any.
|
|
94
|
+
5. **Operation guard** — the operation's own `guard(ctx, command)`:
|
|
95
|
+
preconditions like state-machine legality, ownership beyond roles, or
|
|
96
|
+
invariants over the parsed command. Throw to deny — nothing has run yet,
|
|
97
|
+
so a denial is always clean.
|
|
98
|
+
6. **Run the handler**; its return value is parsed through the `result` schema
|
|
99
|
+
— outputs are validated too.
|
|
100
|
+
7. **Audit**: call the operation's `audit({ command, result }, ctx)`. If it
|
|
101
|
+
returns non-null, the entry (plus the operation's `classification`) is
|
|
102
|
+
written through the injected `writeAudit` — **in the same transaction** as
|
|
103
|
+
the handler's writes. Returning `null` skips the audit (the ontology
|
|
104
|
+
convention: failures are not audited). When the operation declares
|
|
105
|
+
`aggregates: [...]`, an audit whose `aggregate.type` is not in that
|
|
106
|
+
allowlist throws (`COMMAND_AGGREGATE_NOT_DECLARED`) — the audit
|
|
107
|
+
vocabulary is enforced, not advisory. The per-operation catalog is
|
|
108
|
+
introspectable as `commands.aggregates` for tests.
|
|
109
|
+
8. **Emit the observation**: `{ operation, classification, outcome,
|
|
110
|
+
errorCode?, durationMs }` — completed, denied, or failed per
|
|
111
|
+
`classifyError`.
|
|
112
|
+
|
|
113
|
+
### Guards and permissions
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
// convex/foundation.ts — permission semantics are host policy, injected once
|
|
117
|
+
export const { Command, Query, observability } = new Foundation(
|
|
118
|
+
components.foundation,
|
|
119
|
+
{
|
|
120
|
+
observability: { ... },
|
|
121
|
+
checkPermission: (ctx, { permission }) =>
|
|
122
|
+
requirePermission(ctx.identity, permission), // throw to deny
|
|
123
|
+
},
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
// domain/<module>/commands.ts
|
|
127
|
+
const operations = {
|
|
128
|
+
'<entities>.publish': Command.operation({
|
|
129
|
+
command: <entities>.commandInput.extend({ id: zid('<entities>') }),
|
|
130
|
+
result: z.object({ ok: z.literal(true) }).strict(),
|
|
131
|
+
classification: 'business',
|
|
132
|
+
permission: '<domain>.manage', // checked first
|
|
133
|
+
aggregates: ['<aggregate-type>'], // audit vocabulary allowlist
|
|
134
|
+
guard: async (ctx, command) => { // precondition, pre-handler
|
|
135
|
+
const row = await requireTenantReference(ctx.tenant, () => ctx.db.get(command.id))
|
|
136
|
+
if (row.state !== 'draft') errors.throw({ code: 'INVALID_STATE' })
|
|
137
|
+
},
|
|
138
|
+
audit: ({ command }) => ({ ... }),
|
|
139
|
+
}),
|
|
140
|
+
} as const
|
|
141
|
+
|
|
142
|
+
const commands = new Command<MutationCtx, typeof operations>(operations, {
|
|
143
|
+
guard: (ctx) => assertNotReadonlyWindow(ctx), // registry-wide default
|
|
144
|
+
})
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Guards deny by throwing; a denial before the handler never needs rollback
|
|
148
|
+
because nothing has executed. Keep guards read-only — a guard that writes is
|
|
149
|
+
a handler in disguise.
|
|
150
|
+
|
|
151
|
+
If any step throws, the Convex transaction rolls back — handler writes and
|
|
152
|
+
audit entry together. Audit and effects can never disagree.
|
|
153
|
+
|
|
154
|
+
### Dynamic dispatch
|
|
155
|
+
|
|
156
|
+
`exec` has a second overload for operation selection at runtime:
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
export const executeAny = commands.exec({
|
|
160
|
+
dispatcher: z.object({ kind: z.enum(OPERATION_KINDS) /* from constants.ts */ }),
|
|
161
|
+
select: (input) => `documents.${input.kind}` as const,
|
|
162
|
+
handler,
|
|
163
|
+
})
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
An unconfigured operation throws `COMMAND_OPERATION_NOT_CONFIGURED`.
|
|
167
|
+
|
|
168
|
+
## Observability — payload-free by construction
|
|
169
|
+
|
|
170
|
+
Observations carry identifiers and a duration, never payloads. Events are
|
|
171
|
+
silently dropped unless:
|
|
172
|
+
|
|
173
|
+
- `enabled` resolves to `true`,
|
|
174
|
+
- `operation` and `classification` match `/^[A-Za-z][A-Za-z0-9_.-]{0,159}$/`,
|
|
175
|
+
- `errorCode` (when present) matches `/^[A-Z][A-Z0-9_]{0,95}$/`.
|
|
176
|
+
|
|
177
|
+
Practical consequence: **dotted lowercase operation names**
|
|
178
|
+
(`documents.rename`) and **UPPER_SNAKE error codes** are effectively
|
|
179
|
+
mandatory. Every telemetry path is wrapped in try/catch — observability is
|
|
180
|
+
behaviorally inert and can never change command behavior or mask the original
|
|
181
|
+
failure. Default sink is one JSON line on `console.info`
|
|
182
|
+
(`event: 'command.execution'`); inject `emit` to redirect.
|
|
183
|
+
|
|
184
|
+
## `executeResultBoundary` — typed failures, but only while it's safe
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
import { executeResultBoundary } from 'cvx-kit/components/foundation'
|
|
188
|
+
|
|
189
|
+
const result = await executeResultBoundary(ctx, () => run(), boundary)
|
|
190
|
+
// Result<Value, Failure> = { ok: true, value } | { ok: false, error }
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Semantics — read carefully, they are decided by **transaction state**, not by
|
|
194
|
+
the error:
|
|
195
|
+
|
|
196
|
+
- Success → `{ ok: true, value }`.
|
|
197
|
+
- A failure the boundary recognizes (`dataOf(error)` non-undefined) is
|
|
198
|
+
returned as `{ ok: false, error }` **only if the transaction has produced no
|
|
199
|
+
effects yet** (nothing written, nothing scheduled — checked via
|
|
200
|
+
`ctx.meta.getTransactionMetrics()`).
|
|
201
|
+
- If anything was already written or scheduled, the error is **rethrown** so
|
|
202
|
+
Convex rolls the transaction back — a returned failure after effects would
|
|
203
|
+
commit those effects.
|
|
204
|
+
- Unrecognized errors and unreadable metrics always rethrow.
|
|
205
|
+
|
|
206
|
+
Use it at the outermost edge of a mutation when the client needs a typed
|
|
207
|
+
`Result` over the wire instead of a thrown error. `projectResult(result,
|
|
208
|
+
onFailure)` unwraps on the caller side.
|
|
209
|
+
|
|
210
|
+
## The `Query` kernel
|
|
211
|
+
|
|
212
|
+
`Query` mirrors the pattern for reads: an injected `execute` receives
|
|
213
|
+
`{ context, metadata, run }`, letting the host apply uniform read policy
|
|
214
|
+
(authorization checks, tracing) around every domain query without the domains
|
|
215
|
+
knowing. Metadata defaults are set at construction and merged per-executor.
|
|
216
|
+
|
|
217
|
+
## Rules and footguns
|
|
218
|
+
|
|
219
|
+
1. One `new Foundation(...)` per app, in `convex/foundation.ts`. Everything
|
|
220
|
+
else destructures from it.
|
|
221
|
+
2. Never deep-import the component's `modules/*` or `result.ts` paths from app
|
|
222
|
+
code — the `client.ts` facade is the contract.
|
|
223
|
+
3. The audit callback receives the ctx untyped (`never`); wiring a query ctx
|
|
224
|
+
into an audited command fails at runtime, not compile time. Commands run in
|
|
225
|
+
mutations.
|
|
226
|
+
4. Name operations `domain.verb` and error codes `UPPER_SNAKE` or your
|
|
227
|
+
telemetry silently disappears (see regexes above).
|
|
228
|
+
5. Keep operation registries frozen (`as const`) in the domain's
|
|
229
|
+
`commands.ts`; vocabulary tuples live in `constants.ts`.
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
# cvx-kit conventions — folder structure, file anatomy, and naming
|
|
2
|
+
|
|
3
|
+
This is the kit's doctrine. Not a style suggestion: the structure below is
|
|
4
|
+
what the kit's structural guarantees (auth, triggers, bounded reads, audited
|
|
5
|
+
commands) assume, and what its recommended architecture tests enforce. Follow
|
|
6
|
+
it as written; deviate only with a documented reason and a matching change to
|
|
7
|
+
the tests.
|
|
8
|
+
|
|
9
|
+
Names in `<angle brackets>` are placeholders for your vocabulary. Everything
|
|
10
|
+
else — the folder roles, the file anatomy, the naming grammar — is fixed.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## 1. Folder structure
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
convex/
|
|
18
|
+
convex.config.ts
|
|
19
|
+
schema.ts
|
|
20
|
+
functions.ts
|
|
21
|
+
triggers.ts
|
|
22
|
+
foundation.ts
|
|
23
|
+
<component>.ts (one per mounted component: audit.ts, approvals.ts, ...)
|
|
24
|
+
auth.ts auth.config.ts http.ts crons.ts
|
|
25
|
+
api/
|
|
26
|
+
<entity>.ts
|
|
27
|
+
domain/
|
|
28
|
+
table.ts
|
|
29
|
+
shared/
|
|
30
|
+
<provider>.ts
|
|
31
|
+
<entity>/
|
|
32
|
+
schema.ts table.ts constants.ts commands.ts queries.ts
|
|
33
|
+
rules.ts shared.ts actions.ts
|
|
34
|
+
<workflow>.ts <workflow>_functions.ts
|
|
35
|
+
__tests__/
|
|
36
|
+
components/ (only if you author portable components)
|
|
37
|
+
<name>/
|
|
38
|
+
__tests__/
|
|
39
|
+
setup.ts architecture.test.ts facades.test.ts
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Three zones, three privileges:
|
|
43
|
+
|
|
44
|
+
- **Root** — configuration and facades. May import kit modules, generated
|
|
45
|
+
code, and component clients. May NOT export public functions or contain
|
|
46
|
+
business decisions.
|
|
47
|
+
- **`api/`** — the public surface. May import `functions.ts` constructors and
|
|
48
|
+
its entity's domain modules. May NOT contain business logic, private
|
|
49
|
+
helpers, or reads/writes beyond delegation.
|
|
50
|
+
- **`domain/`** — all business logic. May import `functions.ts`,
|
|
51
|
+
`foundation.ts`, facades, `domain/shared/`, and its own entity directory.
|
|
52
|
+
May NOT import `api/`, sibling entities, `_generated/server` builders, or
|
|
53
|
+
component internals.
|
|
54
|
+
|
|
55
|
+
Dependency direction is one-way: `api → domain → shared → kit`. Anything
|
|
56
|
+
pointing the other way is a violation.
|
|
57
|
+
|
|
58
|
+
### Module grain
|
|
59
|
+
|
|
60
|
+
`domain/<entity>/` is really `domain/<module>/`: the directory is a **bounded
|
|
61
|
+
context that may own several related tables**, not necessarily one. Two
|
|
62
|
+
tables belong in the same module when a single command routinely writes both,
|
|
63
|
+
or when one's vocabulary is meaningless without the other; otherwise they are
|
|
64
|
+
separate modules. The file anatomy in §2 is unchanged — a module's
|
|
65
|
+
`schema.ts` simply declares several `zodTable`s and its `table.ts` exports
|
|
66
|
+
one merged map (combine maps in `domain/table.ts` with `createModule`, which
|
|
67
|
+
rejects duplicate owners). A one-table module is the degenerate case, so
|
|
68
|
+
small apps look exactly like before.
|
|
69
|
+
|
|
70
|
+
Cross-module rules:
|
|
71
|
+
|
|
72
|
+
- Sibling modules may import each other's `rules.ts` and `queries.ts`
|
|
73
|
+
**only** — never `commands.ts`: one module never mutates another's state.
|
|
74
|
+
- A workflow that must atomically write across modules lives in an optional
|
|
75
|
+
`application/` layer above `domain/` (`application/<workflow>.ts`); `api/`
|
|
76
|
+
adapters then import `application/*` and their own module, never a second
|
|
77
|
+
module.
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## 2. File anatomy — what each file contains, exactly
|
|
82
|
+
|
|
83
|
+
Every file has one job. The lists below are exhaustive: if content isn't
|
|
84
|
+
listed for a file, it doesn't belong there.
|
|
85
|
+
|
|
86
|
+
### Universal file order
|
|
87
|
+
|
|
88
|
+
Every source file reads from public purpose to private implementation:
|
|
89
|
+
|
|
90
|
+
1. Imports.
|
|
91
|
+
2. Exported API — types, schemas, constants, functions.
|
|
92
|
+
3. File-local types and constants supporting the implementation.
|
|
93
|
+
4. Private helpers as **named function declarations**, at the bottom.
|
|
94
|
+
|
|
95
|
+
No helper implementations above the exported API. No helper
|
|
96
|
+
arrow-function constants where a `function` declaration works. No barrel
|
|
97
|
+
files (`index.ts` re-exports) anywhere inside `convex/`. No re-exporting
|
|
98
|
+
another file's function to shorten an import.
|
|
99
|
+
|
|
100
|
+
### Root files
|
|
101
|
+
|
|
102
|
+
| File | Contains | Never contains |
|
|
103
|
+
|---|---|---|
|
|
104
|
+
| `convex.config.ts` | `defineApp()`, one `app.use(...)` per component, `defineApp({ env })` declarations, `export default app` | anything else |
|
|
105
|
+
| `schema.ts` | `defineSchema(domainTables)` | table definitions, validators |
|
|
106
|
+
| `functions.ts` | the single `createAuthFunctions<DataModel>()` call and its exported constructors | handlers, business policy beyond the injected config |
|
|
107
|
+
| `triggers.ts` | `createTriggers()`, `timestamps`/`appendOnly`/`noDelete` registrations, calls to per-entity `register<Entity>Triggers` | trigger *logic* for a specific entity (that lives in the entity) |
|
|
108
|
+
| `foundation.ts` | the single `new Foundation(...)`, destructured exports | command definitions |
|
|
109
|
+
| `<component>.ts` | `new <Client>(components.<name>)` + minimal admin plumbing | workflow/business definitions |
|
|
110
|
+
| `http.ts` | routes: verify signature → parse payload → delegate to a domain function | webhook business logic |
|
|
111
|
+
| `crons.ts` | `cronJobs()` declarations targeting `internal.domain.<entity>...` | handler logic |
|
|
112
|
+
|
|
113
|
+
### `api/<entity>.ts` — the adapter file
|
|
114
|
+
|
|
115
|
+
One file per entity exposed to clients. Each exported function follows one
|
|
116
|
+
template and stays within ~10 lines of glue:
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
export const <verb> = authMutation({
|
|
120
|
+
args: <entity>.commandInput.extend({ /* ids, etc. */ }),
|
|
121
|
+
returns: <resultSchema>,
|
|
122
|
+
handler: (ctx, args) => execute<Verb><Entity>(ctx, { ...args, actorId: ctx.actor.userId }),
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
export const list = authQuery({
|
|
126
|
+
args: { limit: z.number() },
|
|
127
|
+
returns: z.array(<entity>.publicDto),
|
|
128
|
+
handler: (ctx, { limit }) =>
|
|
129
|
+
ctx.include(ctx.db.query('<entities>'))
|
|
130
|
+
.matching('by_<field>', (ix) => ix.eq('<field>', ctx.actor.userId))
|
|
131
|
+
.execute(limit, (rows) => rows.map(<entity>.toPublicDto)),
|
|
132
|
+
})
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Adapters do four things — parse (boundary schemas), authorize (constructor
|
|
136
|
+
choice), delegate (`execute*` with `ctx.actor`), project (`toPublicDto`).
|
|
137
|
+
The moment an adapter needs a private helper or a business `if`, that code
|
|
138
|
+
moves to the domain.
|
|
139
|
+
|
|
140
|
+
### `domain/<entity>/schema.ts` — shapes only
|
|
141
|
+
|
|
142
|
+
- The entity's single `zodTable(...)` call, with `serverFields`,
|
|
143
|
+
`commandFields`, `publicFields` decided deliberately.
|
|
144
|
+
- DTO compositions derived from the table's boundaries
|
|
145
|
+
(`z.object({ ...<entity>.publicDto.shape, ... }).strict()`).
|
|
146
|
+
- Inferred types (`export type <Entity> = z.infer<typeof <entity>.schema>`).
|
|
147
|
+
- NO indexes, NO `defineTable`, NO enum literals (consume `constants.ts`).
|
|
148
|
+
|
|
149
|
+
### `domain/<entity>/table.ts` — topology only
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
export const <entity>Tables = {
|
|
153
|
+
<entities>: <entity>.table
|
|
154
|
+
.index('by_<field>', ['<field>'])
|
|
155
|
+
.index('by_<field>_and_<field2>', ['<field>', '<field2>']),
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Registered in `domain/table.ts`, which merges all entity maps into
|
|
160
|
+
`domainTables`. Shapes change with the data model; indexes change with query
|
|
161
|
+
patterns — that's why they are separate files.
|
|
162
|
+
|
|
163
|
+
### `domain/<entity>/constants.ts` — vocabularies
|
|
164
|
+
|
|
165
|
+
Every finite value set the entity owns, one owner per vocabulary:
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
export const <ENTITY>_STATES = ['<a>', '<b>', '<c>'] as const
|
|
169
|
+
export type <Entity>State = (typeof <ENTITY>_STATES)[number]
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Schemas, validators, filters, transition maps, and tests consume the tuple.
|
|
173
|
+
Transition maps are typed against the value type so an invalid literal cannot
|
|
174
|
+
compile. Numeric limits and durations the entity owns also live here.
|
|
175
|
+
|
|
176
|
+
### `domain/<entity>/commands.ts` — every state change
|
|
177
|
+
|
|
178
|
+
1. The frozen operation registry: `Command.operation({ command, result,
|
|
179
|
+
classification, audit })` per operation, keys `'<entity>.<verb>'`.
|
|
180
|
+
2. One `new Command<Ctx, typeof operations>(operations)`.
|
|
181
|
+
3. Exported executors: `export const execute<Verb><Entity> = commands.exec(...)`.
|
|
182
|
+
4. Private handler helpers at the bottom.
|
|
183
|
+
|
|
184
|
+
Audit derivation lives here, next to the operation it describes. No reads
|
|
185
|
+
that belong in `queries.ts`; no pure predicates that belong in `rules.ts`.
|
|
186
|
+
|
|
187
|
+
### `domain/<entity>/queries.ts` — domain reads
|
|
188
|
+
|
|
189
|
+
Read logic worth reusing beyond one adapter: multi-step lookups, DTO
|
|
190
|
+
assembly, cross-index selection. Bounded like everything else.
|
|
191
|
+
|
|
192
|
+
### `domain/<entity>/rules.ts` — pure invariants
|
|
193
|
+
|
|
194
|
+
Predicates and transition checks with **no ctx and no db**: unit-testable
|
|
195
|
+
business truth (`can<Verb>(doc, actor)`, `isTransitionLegal(from, to)`).
|
|
196
|
+
Handlers call rules; rules never call handlers.
|
|
197
|
+
|
|
198
|
+
### `domain/<entity>/shared.ts` — entity-private helpers
|
|
199
|
+
|
|
200
|
+
Code shared by two or more files *of this entity*. If a second entity needs
|
|
201
|
+
it, it moves to `domain/shared/` — never a sibling import.
|
|
202
|
+
|
|
203
|
+
### `domain/<entity>/actions.ts` — external side effects
|
|
204
|
+
|
|
205
|
+
Action-side logic using the shared provider clients from
|
|
206
|
+
`domain/shared/<provider>.ts`. Domains import the shared client; they never
|
|
207
|
+
instantiate their own vendor SDK.
|
|
208
|
+
|
|
209
|
+
### `domain/<entity>/<workflow>.ts` + `<workflow>_functions.ts`
|
|
210
|
+
|
|
211
|
+
The workflow/approval definition the entity owns, and its internal callback
|
|
212
|
+
targets built with `system*` constructors. Callbacks re-validate everything:
|
|
213
|
+
`normalizeId`, `resourceType` check, staleness guard.
|
|
214
|
+
|
|
215
|
+
### `domain/shared/` — cross-entity, and only cross-entity
|
|
216
|
+
|
|
217
|
+
One configured client per external provider (`<provider>.ts`), plus zod/util
|
|
218
|
+
helpers genuinely used by multiple entities. This directory is not a dumping
|
|
219
|
+
ground: code with one consumer moves back to that consumer.
|
|
220
|
+
|
|
221
|
+
### Tests
|
|
222
|
+
|
|
223
|
+
- `domain/<entity>/__tests__/` — behavioral tests for the entity, driven
|
|
224
|
+
through the real constructors (`t.withIdentity(...)`).
|
|
225
|
+
- `convex/__tests__/architecture.test.ts` — the executable conventions;
|
|
226
|
+
every rule proved against a synthetic violating fixture first.
|
|
227
|
+
- `convex/__tests__/facades.test.ts` — every `app.use` exactly once; no
|
|
228
|
+
private-child access.
|
|
229
|
+
- `convex/__tests__/setup.ts` — env stubs for `defineApp({ env })` vars.
|
|
230
|
+
- Test files are named `<subject>.test.ts` and live in a `__tests__/`
|
|
231
|
+
directory beside the code they test — never in a parallel top-level tree.
|
|
232
|
+
|
|
233
|
+
---
|
|
234
|
+
|
|
235
|
+
## 3. Naming grammar
|
|
236
|
+
|
|
237
|
+
| Thing | Convention | Example shape |
|
|
238
|
+
|---|---|---|
|
|
239
|
+
| Entity directory | plural camelCase, matches table name | `domain/<entities>/` |
|
|
240
|
+
| Table | plural camelCase | `<entities>` |
|
|
241
|
+
| Fields | camelCase; foreign keys `<other>Id`; timestamps only via the kit | `ownerId`, `archivedAt` |
|
|
242
|
+
| Index | `by_<field>` / `by_<field>_and_<field>`, declaration order, nested paths flattened with `_` | `by_ownerId_and_state_status` |
|
|
243
|
+
| Command operation | `<entity>.<verb>` lowercase dotted; must match `/^[A-Za-z][A-Za-z0-9_.-]{0,159}$/` | `<entities>.approve` |
|
|
244
|
+
| Error code | `UPPER_SNAKE`, matching `/^[A-Z][A-Z0-9_]{0,95}$/`, defined in one owner | `INVALID_REQUEST_BOUNDARY` |
|
|
245
|
+
| Vocabulary tuple | `UPPER_SNAKE_CASE` `as const`; derived type PascalCase singular | `<ENTITY>_STATES` / `<Entity>State` |
|
|
246
|
+
| Domain executor | `execute<Verb><Entity>` | `executeApprove<Entity>` |
|
|
247
|
+
| Rule predicate | `can<Verb>` / `is<Condition>` | `canApprove`, `isTransitionLegal` |
|
|
248
|
+
| Trigger registration | `register<Entity>Triggers(triggers)` | exported from the entity, called in root `triggers.ts` |
|
|
249
|
+
| Internal-callback file | `<purpose>_functions.ts`, sibling of `<purpose>.ts` | `approval_functions.ts` |
|
|
250
|
+
| zodTable export | camelCase plural, same as table | `export const <entities> = zodTable(...)` |
|
|
251
|
+
| DTO schema | `<entity><Purpose>Dto` | `<entity>SummaryDto` |
|
|
252
|
+
| Zod schema value | camelCase noun | `commandInput`, `publicDto` |
|
|
253
|
+
| Type | PascalCase; no `I`/`T` prefixes | `<Entity>State` |
|
|
254
|
+
| Test file | `<subject>.test.ts` in `__tests__/` | `commands.test.ts` |
|
|
255
|
+
| Public function path | `api/<entity>:<fn>` — clients never call a root path | — |
|
|
256
|
+
|
|
257
|
+
The two regexes are enforced at runtime: observability silently drops events
|
|
258
|
+
whose operation/classification/errorCode don't match. The rest is enforced by
|
|
259
|
+
the architecture tests — a naming rule without a test is a wish.
|
|
260
|
+
|
|
261
|
+
---
|
|
262
|
+
|
|
263
|
+
## 4. Import rules
|
|
264
|
+
|
|
265
|
+
Allowed, per zone (anything not listed is forbidden):
|
|
266
|
+
|
|
267
|
+
| From | May import |
|
|
268
|
+
|---|---|
|
|
269
|
+
| `api/<entity>.ts` | `functions.ts`, `domain/<entity>/*`, `domain/shared/*`, zod |
|
|
270
|
+
| `domain/<entity>/*` | `functions.ts`, `foundation.ts`, component facades, `domain/shared/*`, own directory, generated `internal`/`api` (references only), kit modules |
|
|
271
|
+
| `domain/shared/*` | kit modules, vendor SDKs (this is the ONLY home for vendor clients) |
|
|
272
|
+
| root facades | kit modules, `components` from generated api |
|
|
273
|
+
| `functions.ts` | `_generated/server` (the only file allowed to) |
|
|
274
|
+
| `components/<name>/**` | its own directory + npm deps only — no host imports, no `process.env` |
|
|
275
|
+
|
|
276
|
+
Forbidden everywhere: sibling-entity imports, `api/` from `domain/`,
|
|
277
|
+
component internals (anything below a component's client facade),
|
|
278
|
+
`makeFunctionReference`, barrel files.
|
|
279
|
+
|
|
280
|
+
---
|
|
281
|
+
|
|
282
|
+
## 5. Formatting and language
|
|
283
|
+
|
|
284
|
+
- TypeScript strict; no `any` in exported signatures; `as never` only at kit
|
|
285
|
+
seams that document it.
|
|
286
|
+
- Zod objects that cross a boundary are `.strict()` — always.
|
|
287
|
+
- `Object.freeze` for registries and long-lived configuration objects.
|
|
288
|
+
- Comments state constraints the code can't (`// Optional only for rows
|
|
289
|
+
created before <epoch>.`) — never narration of the next line.
|
|
290
|
+
- One entity concept per file; when a file serves two purposes, split it
|
|
291
|
+
along the anatomy in §2.
|