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
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
# Migrating an existing Convex project to the cvx-kit architecture
|
|
2
|
+
|
|
3
|
+
This guide takes a "raw" Convex project — the typical shape where every file at
|
|
4
|
+
the root of `convex/` exports public queries and mutations, tables are defined
|
|
5
|
+
inline in `schema.ts`, and validators are `v.*` objects repeated per function —
|
|
6
|
+
and restructures it into the cvx-kit architecture.
|
|
7
|
+
|
|
8
|
+
This is an **opinionated infrastructure**. The rules below are not
|
|
9
|
+
suggestions; they are the contract. Where the kit makes something structural
|
|
10
|
+
(auth, triggers, bounded reads, mandatory audit), the migration's job is to
|
|
11
|
+
remove every code path that circumvents the structure. Names in `<angle
|
|
12
|
+
brackets>` are placeholders for your own vocabulary — no specific entity,
|
|
13
|
+
table, field, or workflow is mandated, only the roles and rules.
|
|
14
|
+
|
|
15
|
+
Migrate incrementally: each step leaves the app deployable, and old and new
|
|
16
|
+
shapes can coexist while you move entity by entity.
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## 1. The target structure — complete
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
convex/
|
|
24
|
+
convex.config.ts # component mounting. Nothing else.
|
|
25
|
+
schema.ts # defineSchema(domainTables). Nothing else.
|
|
26
|
+
functions.ts # the single createAuthFunctions(...) call
|
|
27
|
+
triggers.ts # the single trigger registry + helper registrations
|
|
28
|
+
foundation.ts # the single new Foundation(...) facade
|
|
29
|
+
<component>.ts # one facade per mounted component (audit.ts, approvals.ts, pool.ts, workflows.ts, ...)
|
|
30
|
+
auth.ts # identity-provider client wiring
|
|
31
|
+
auth.config.ts # Convex auth providers config
|
|
32
|
+
http.ts # HTTP router (webhook endpoints delegate to domains)
|
|
33
|
+
crons.ts # cron declarations only; targets are internal domain functions
|
|
34
|
+
|
|
35
|
+
api/ # ═══ THE ONLY PUBLIC SURFACE ═══
|
|
36
|
+
<entity>.ts # thin adapters: auth* constructors → domain executors → DTOs
|
|
37
|
+
|
|
38
|
+
domain/ # ═══ ALL BUSINESS LOGIC ═══
|
|
39
|
+
table.ts # merges per-entity table maps into domainTables
|
|
40
|
+
shared/ # genuinely cross-entity code ONLY
|
|
41
|
+
schema.ts # cross-entity zod helpers (if any)
|
|
42
|
+
<provider>.ts # one configured vendor client per provider (sql, cache, email, ...)
|
|
43
|
+
<entity>/ # one directory per entity/aggregate
|
|
44
|
+
schema.ts # the zodTable declaration — shapes and masks, nothing else
|
|
45
|
+
table.ts # index topology attached to <zodTable>.table
|
|
46
|
+
constants.ts # finite vocabularies: UPPER_SNAKE readonly tuples
|
|
47
|
+
commands.ts # operation registry + command executors
|
|
48
|
+
queries.ts # domain read logic
|
|
49
|
+
rules.ts # pure domain invariants/predicates (no ctx, no db)
|
|
50
|
+
shared.ts # helpers shared by multiple files of THIS entity
|
|
51
|
+
<workflow>.ts # approval/workflow definitions this entity owns
|
|
52
|
+
<workflow>_functions.ts # internal callback targets (system* constructors)
|
|
53
|
+
actions.ts # external-side-effect logic (via shared provider clients)
|
|
54
|
+
__tests__/ # behavioral tests for this entity
|
|
55
|
+
|
|
56
|
+
components/ # only if the app authors its own portable components
|
|
57
|
+
<name>/ # own convex.config.ts, schema.ts, _generated/, __tests__/
|
|
58
|
+
|
|
59
|
+
__tests__/ # ═══ APP-WIDE GUARDRAILS ═══
|
|
60
|
+
setup.ts # env stubs for vars declared on defineApp({ env })
|
|
61
|
+
architecture.test.ts # the executable conventions (see §6)
|
|
62
|
+
facades.test.ts # each app.use exactly once; no private-child access
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### The root rule
|
|
66
|
+
|
|
67
|
+
**The root of `convex/` contains configuration and facades only.** Every root
|
|
68
|
+
file is a *declaration point* — the single place one piece of infrastructure
|
|
69
|
+
is configured — and none of them exports a public function or contains
|
|
70
|
+
business logic. If a client can call it, it lives in `api/`. If it decides
|
|
71
|
+
anything about the business, it lives in `domain/`. If `ls convex/*.ts` shows
|
|
72
|
+
anything else, the migration is not done.
|
|
73
|
+
|
|
74
|
+
### Hard prohibitions (grep-able, test-enforceable)
|
|
75
|
+
|
|
76
|
+
These patterns must not exist anywhere after migration — each one is a hole in
|
|
77
|
+
a structural guarantee:
|
|
78
|
+
|
|
79
|
+
| Forbidden | Where it's allowed instead | What it breaks otherwise |
|
|
80
|
+
|---|---|---|
|
|
81
|
+
| `query(`, `mutation(`, `action(`, `internalMutation(`, `internalAction(` from `_generated/server` | `functions.ts` only | auth, triggers, bounded reads |
|
|
82
|
+
| `defineTable(` | `domain/<entity>/table.ts` via `<zodTable>.table` | single source of truth per entity |
|
|
83
|
+
| `defineSchema(` | root `schema.ts` only | schema assembly |
|
|
84
|
+
| inline `z.enum([...])` / repeated literal unions | tuple in `constants.ts` | vocabulary ownership |
|
|
85
|
+
| `.collect()` / unbounded reads in public paths | `ctx.include(...).execute(limit)` | read bounds |
|
|
86
|
+
| `makeFunctionReference` | generated `internal` / `api` | refactoring safety |
|
|
87
|
+
| deep imports of a component's internals | the component's client facade | component boundary |
|
|
88
|
+
| sibling-domain imports (`domain/a` → `domain/b`) | `domain/shared/` or the public API | ownership |
|
|
89
|
+
| `ctx.db.insert/patch/delete` on a mounted component's tables | the component's client methods | component persistence |
|
|
90
|
+
| vendor SDK instantiation inside a domain | one shared client in `domain/shared/<provider>.ts` | connection discipline |
|
|
91
|
+
| `new Date().toISOString()` / hand-written timestamp fields | the `timestamps` trigger | server-owned lifecycle |
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## 2. Where each kind of existing code goes
|
|
96
|
+
|
|
97
|
+
A raw project's root files usually mix several kinds of code. Pull them apart:
|
|
98
|
+
|
|
99
|
+
| You have today | It becomes |
|
|
100
|
+
|---|---|
|
|
101
|
+
| `export const get/list = query({...})` at root | adapter in `api/<entity>.ts` + read logic in `domain/<entity>/queries.ts` |
|
|
102
|
+
| `export const create/update = mutation({...})` at root | adapter in `api/<entity>.ts` + operation in `domain/<entity>/commands.ts` |
|
|
103
|
+
| `internalMutation`/`internalAction` helpers | `system*` functions in `domain/<entity>/<purpose>_functions.ts` |
|
|
104
|
+
| inline `defineTable({...})` in `schema.ts` | `zodTable` in `domain/<entity>/schema.ts` + indexes in `domain/<entity>/table.ts` |
|
|
105
|
+
| `v.union(v.literal(...))` / inline `z.enum` repeated around | one `UPPER_SNAKE` tuple in `domain/<entity>/constants.ts` |
|
|
106
|
+
| validation/business `if`-chains inside handlers | pure predicates in `domain/<entity>/rules.ts` |
|
|
107
|
+
| shared grab-bag (`helpers.ts`, `utils.ts`, `lib.ts`) | split by owner into `domain/<entity>/shared.ts`; only genuinely cross-entity code into `domain/shared/` |
|
|
108
|
+
| ad-hoc vendor clients (HTTP SDKs, caches, SQL) | one configured client per provider in `domain/shared/<provider>.ts` |
|
|
109
|
+
| webhook handlers with logic in `http.ts` | route in `http.ts`, logic in `domain/<entity>/actions.ts` |
|
|
110
|
+
| cron handlers with logic in `crons.ts` | declaration in `crons.ts`, target `system*` function in the domain |
|
|
111
|
+
| audit/log/history tables mirroring component state | the component's own persistence via its facade |
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## 3. Naming — the full convention set
|
|
116
|
+
|
|
117
|
+
Naming is part of the architecture: several kit mechanisms key off it at
|
|
118
|
+
runtime (observability drops non-conforming events), and the rest makes the
|
|
119
|
+
codebase greppable.
|
|
120
|
+
|
|
121
|
+
- **Directories**: `domain/<entity>/` uses the plural camelCase table name.
|
|
122
|
+
One entity, one directory — no `domain/misc/`.
|
|
123
|
+
- **Tables**: plural camelCase (`<entities>`).
|
|
124
|
+
- **Indexes**: `by_<field>` / `by_<field>_and_<field>`, fields in declaration
|
|
125
|
+
order, nested paths flattened with `_` (`by_ownerId_and_state_status` for
|
|
126
|
+
`['ownerId', 'state.status']`). A reader must be able to reconstruct the
|
|
127
|
+
index definition from its name.
|
|
128
|
+
- **Command operations**: `<entity>.<verb>` lowercase dotted
|
|
129
|
+
(`invoices.approve`). Must match `/^[A-Za-z][A-Za-z0-9_.-]{0,159}$/` or
|
|
130
|
+
observability drops the events.
|
|
131
|
+
- **Error codes**: `UPPER_SNAKE`, matching `/^[A-Z][A-Z0-9_]{0,95}$/`. Define
|
|
132
|
+
the full set once (one owner), not per callsite.
|
|
133
|
+
- **Vocabulary tuples**: `UPPER_SNAKE_CASE` exported `as const`; derived type
|
|
134
|
+
`PascalCase` singular (`const <ENTITY>_STATES = [...] as const;
|
|
135
|
+
type <Entity>State = (typeof <ENTITY>_STATES)[number]`).
|
|
136
|
+
- **Domain executors**: `execute<Verb><Entity>` (`executeApproveInvoice`) —
|
|
137
|
+
the grep-able seam between adapters and domain.
|
|
138
|
+
- **Internal callback files**: `<purpose>_functions.ts` next to the
|
|
139
|
+
`<purpose>.ts` that references them.
|
|
140
|
+
- **Public function names** are `api/<entity>:fn` — clients never reference a
|
|
141
|
+
root-level path.
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## 4. Step-by-step migration
|
|
146
|
+
|
|
147
|
+
### Step 0 — Inventory
|
|
148
|
+
|
|
149
|
+
List every exported function and classify it: public read, public write,
|
|
150
|
+
internal, cron target, HTTP handler. List every table, every index, every
|
|
151
|
+
literal string union, every vendor client instantiation. This is the
|
|
152
|
+
checklist; entities with the fewest dependencies migrate first.
|
|
153
|
+
|
|
154
|
+
### Step 1 — Mount the kit, declare the root files
|
|
155
|
+
|
|
156
|
+
Install `cvx-kit`, mount `foundation` (and `approvals` if needed) in
|
|
157
|
+
`convex.config.ts`, run codegen. Create `functions.ts`, `triggers.ts`,
|
|
158
|
+
`foundation.ts`, and one facade per mounted component. Declare required env
|
|
159
|
+
vars on `defineApp({ env: { ... } })` so deploys fail fast, and stub them in
|
|
160
|
+
`__tests__/setup.ts`. Nothing breaks: raw functions keep working alongside
|
|
161
|
+
the new constructors.
|
|
162
|
+
|
|
163
|
+
### Step 2 — Convert tables entity by entity
|
|
164
|
+
|
|
165
|
+
For each table:
|
|
166
|
+
|
|
167
|
+
1. `domain/<entity>/schema.ts`: a `zodTable` mirroring the current shape,
|
|
168
|
+
`v.*` converted to zod. Decide the three masks **deliberately** —
|
|
169
|
+
`serverFields` (server-assigned), `commandFields` (what a write request
|
|
170
|
+
may say), `publicFields` (what a client may see). This is where implicit
|
|
171
|
+
trust becomes explicit policy; a mis-declared mask is a security bug, so
|
|
172
|
+
review masks like auth code.
|
|
173
|
+
2. `domain/<entity>/table.ts`: move the indexes, renamed to the convention.
|
|
174
|
+
3. Register in `domain/table.ts`; shrink root `schema.ts` toward
|
|
175
|
+
`defineSchema(domainTables)`.
|
|
176
|
+
4. The kit's timestamps are `.optional()`, so existing rows validate without
|
|
177
|
+
backfill. Register `timestamps(triggers, '<table>')`; new writes maintain
|
|
178
|
+
`createdAt`/`updatedAt`. Backfill later if needed. Existing hand-rolled
|
|
179
|
+
timestamp fields (e.g. ISO strings) stay in the shape for now — migrate
|
|
180
|
+
the data separately; never couple data migration to restructuring.
|
|
181
|
+
5. Apply write discipline per table while you're here: `appendOnly` for
|
|
182
|
+
evidence tables (votes, history), `noDelete` + `archivedAt` for anything a
|
|
183
|
+
human might ask about later. Hard deletes are opt-in per table, not the
|
|
184
|
+
default.
|
|
185
|
+
|
|
186
|
+
### Step 3 — Move vocabularies to `constants.ts`
|
|
187
|
+
|
|
188
|
+
Every reusable finite vocabulary gets one owner: an exported tuple in the
|
|
189
|
+
owning entity's `constants.ts`. Schemas, validators, filters, transition
|
|
190
|
+
maps, and tests consume the tuple. Subsets and ordered presentations derive
|
|
191
|
+
from the canonical tuple, typed against its value type so an invalid literal
|
|
192
|
+
cannot compile. A file-local tuple is acceptable **only** for private
|
|
193
|
+
implementation mechanics that can never appear in another schema, contract,
|
|
194
|
+
persisted document, or UI state — and it is still `UPPER_SNAKE_CASE`.
|
|
195
|
+
|
|
196
|
+
### Step 4 — Split public functions into adapter + domain logic
|
|
197
|
+
|
|
198
|
+
For each public function at the root:
|
|
199
|
+
|
|
200
|
+
1. **Extract the domain logic.** Reads → `domain/<entity>/queries.ts`.
|
|
201
|
+
Writes → an operation in the entity's command registry with
|
|
202
|
+
classification and audit derivation (see `commands.md`) — deciding *what
|
|
203
|
+
gets audited and as what* is a product decision; make it explicitly, not
|
|
204
|
+
as an afterthought. Pure conditions the handler checks
|
|
205
|
+
(state-transition legality, permission-beyond-role predicates) →
|
|
206
|
+
`rules.ts`, so they're unit-testable without a ctx.
|
|
207
|
+
2. **Write the adapter** in `api/<entity>.ts` with `authQuery`/
|
|
208
|
+
`authMutation`/`role*`/`admin*`. Adapters do exactly four things: parse
|
|
209
|
+
args with the entity's boundary schemas, authorize via the constructor
|
|
210
|
+
choice, call the domain executor with `ctx.actor`, project through
|
|
211
|
+
`toPublicDto`. **Adapter budget: if it exceeds ~10 lines of glue or grows
|
|
212
|
+
a private helper, logic is leaking — move it into the domain.**
|
|
213
|
+
3. **Retire the old path.** Public names change to `api/<entity>:fn`; update
|
|
214
|
+
client call sites per entity, or keep a temporary re-export file at the
|
|
215
|
+
old path and delete it in the final step.
|
|
216
|
+
|
|
217
|
+
Anything "public-but-really-internal" (only called by your own scheduled
|
|
218
|
+
functions or other backend code) must stop being public: convert to `system*`
|
|
219
|
+
in the domain.
|
|
220
|
+
|
|
221
|
+
### Step 5 — Convert internal functions and the wiring files
|
|
222
|
+
|
|
223
|
+
- Replace raw `internalMutation`/`internalAction` with `systemMutation`/
|
|
224
|
+
`systemAction` from `functions.ts`, located in the owning domain's
|
|
225
|
+
`<purpose>_functions.ts`. Scheduled and callback writes now run under the
|
|
226
|
+
same trigger regime as user writes.
|
|
227
|
+
- `crons.ts` becomes declarations only, targeting `internal.domain.<entity>.
|
|
228
|
+
<purpose>_functions.*`.
|
|
229
|
+
- `http.ts` becomes routing only: verify the webhook signature, parse the
|
|
230
|
+
payload at the boundary, delegate to a domain action/function.
|
|
231
|
+
|
|
232
|
+
### Step 6 — Bound the reads
|
|
233
|
+
|
|
234
|
+
Replace `.collect()` and unbounded `.take(n)` in public paths with
|
|
235
|
+
`ctx.include(...).execute(limit)` (limit ≤ 100). Where a query can't be
|
|
236
|
+
served by an index, **add the index** — a scan is a schema problem, not a
|
|
237
|
+
query-code problem. Paginated surfaces cross the boundary with the convex
|
|
238
|
+
pagination validators bridged into zod (`convexToZod(paginationOptsValidator)`
|
|
239
|
+
and a `paginationResultValidator` built from the row validator), not with
|
|
240
|
+
hand-rolled cursor objects.
|
|
241
|
+
|
|
242
|
+
### Step 7 — Enforce everything with tests
|
|
243
|
+
|
|
244
|
+
Port the executable-architecture pattern. `__tests__/architecture.test.ts`
|
|
245
|
+
globs all production sources and asserts the Hard Prohibitions table above;
|
|
246
|
+
**each rule is proved against a synthetic violating fixture first** so a
|
|
247
|
+
broken checker cannot silently pass. `facades.test.ts` asserts each
|
|
248
|
+
`app.use(x)` appears exactly once and no file reaches a component's private
|
|
249
|
+
children. From this point the migration cannot regress — and neither can
|
|
250
|
+
future contributors or coding agents.
|
|
251
|
+
|
|
252
|
+
---
|
|
253
|
+
|
|
254
|
+
## 5. File conduct — how every source file is organized
|
|
255
|
+
|
|
256
|
+
From the origin project's code conduct. Apply to new code and files being
|
|
257
|
+
substantially changed; don't mechanically rewrite untouched files.
|
|
258
|
+
|
|
259
|
+
**File order** — readable from public purpose to private implementation:
|
|
260
|
+
|
|
261
|
+
1. Imports.
|
|
262
|
+
2. Exported types, schemas, constants, and public functions — the API.
|
|
263
|
+
3. File-local types and constants supporting the implementation.
|
|
264
|
+
4. Private helpers, as **named function declarations**, at the bottom.
|
|
265
|
+
|
|
266
|
+
```ts
|
|
267
|
+
import { parseInput } from './schema'
|
|
268
|
+
|
|
269
|
+
export async function createProjection(input: unknown) {
|
|
270
|
+
return buildProjection(parseInput(input))
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function buildProjection(input: ProjectionInput) {
|
|
274
|
+
// implementation
|
|
275
|
+
}
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
Never place private helper implementations above the exported API. Prefer
|
|
279
|
+
`function` declarations over helper arrow-constants so helpers can live at
|
|
280
|
+
the bottom.
|
|
281
|
+
|
|
282
|
+
**Ownership.** Put behavior beside the entity that owns it. Consumers (api
|
|
283
|
+
adapters, cron targets, workflow callbacks) stay thin: validate, authorize,
|
|
284
|
+
call the owning operation, translate the result. A consumer accumulating
|
|
285
|
+
entity rules or a pile of private helpers is logic asking to move into the
|
|
286
|
+
domain.
|
|
287
|
+
|
|
288
|
+
**No convenience re-exports.** Import a function from the file that owns it;
|
|
289
|
+
never re-export to shorten a path. Barrel files (`index.ts` re-exporting a
|
|
290
|
+
directory) are banned inside `convex/` — they blur ownership and create
|
|
291
|
+
import cycles.
|
|
292
|
+
|
|
293
|
+
**Components own their persistence.** Never mirror a mounted component's
|
|
294
|
+
internal state (audit events, workflow steps, job results) into application
|
|
295
|
+
tables unless the product domain independently requires a durable business
|
|
296
|
+
record. Log through the facade; don't build a receipts table for it.
|
|
297
|
+
|
|
298
|
+
**Comments state constraints, not narration.** The accepted pattern for
|
|
299
|
+
schema evolution is a one-line epoch comment on an `.optional()` field
|
|
300
|
+
explaining why it's optional. Comments that restate the next line are noise.
|
|
301
|
+
|
|
302
|
+
---
|
|
303
|
+
|
|
304
|
+
## 6. Documentation the migrated repo must carry
|
|
305
|
+
|
|
306
|
+
An opinionated codebase documents its opinions, or they die at the first
|
|
307
|
+
new contributor. Minimum set:
|
|
308
|
+
|
|
309
|
+
- **`CODE_CONDUCT.md`** (or equivalent) — the repo's own conventions: file
|
|
310
|
+
order, ownership layout, vocabulary rules, naming. This guide's §3 and §5
|
|
311
|
+
are the starting template; adapt, don't link.
|
|
312
|
+
- **`CONCEPTS.md`** — the domain glossary: every entity, state, and
|
|
313
|
+
vocabulary word with its business meaning. LLMs and new hires read this
|
|
314
|
+
first; keep entries one paragraph.
|
|
315
|
+
- **`CLAUDE.md` / `AGENTS.md`** — thin pointers: the stack, the three
|
|
316
|
+
commands that matter (test, typecheck, deploy), and links to
|
|
317
|
+
`CODE_CONDUCT.md` + `node_modules/cvx-kit/src/docs/llms.md`. Agent files
|
|
318
|
+
that duplicate the conduct doc drift; keep them as an index.
|
|
319
|
+
- **Architecture tests as documentation** — the test names in
|
|
320
|
+
`architecture.test.ts` are the enforceable summary of the conduct doc;
|
|
321
|
+
a rule that exists in prose but not in the suite is a request, not a rule.
|
|
322
|
+
|
|
323
|
+
---
|
|
324
|
+
|
|
325
|
+
## 7. Definition of done
|
|
326
|
+
|
|
327
|
+
The migration is complete when every box checks:
|
|
328
|
+
|
|
329
|
+
- [ ] `ls convex/*.ts` shows only configuration and facades (§1 table).
|
|
330
|
+
- [ ] Every public function lives in `api/` and uses `auth*`/`role*`/`admin*`.
|
|
331
|
+
- [ ] Every internal function uses `system*` and lives in its domain.
|
|
332
|
+
- [ ] `functions.ts` is the only file importing `_generated/server` builders.
|
|
333
|
+
- [ ] Every table is a `zodTable` with deliberately reviewed masks; indexes
|
|
334
|
+
named by convention; timestamps trigger registered.
|
|
335
|
+
- [ ] Every vocabulary is a single-owner tuple in a `constants.ts`.
|
|
336
|
+
- [ ] Every state change is a command operation with classification + audit.
|
|
337
|
+
- [ ] Every public read is bounded (`include` + limit, or bridged pagination).
|
|
338
|
+
- [ ] No entry in the Hard Prohibitions table greps positive.
|
|
339
|
+
- [ ] `architecture.test.ts` + `facades.test.ts` exist, each rule fixture-proved.
|
|
340
|
+
- [ ] `CODE_CONDUCT.md` and `CONCEPTS.md` exist; agent files point at them.
|
|
341
|
+
- [ ] Transitional re-exports deleted; clients call `api/<entity>:fn` paths.
|
|
342
|
+
|
|
343
|
+
## Migration order that works
|
|
344
|
+
|
|
345
|
+
1. Root files + kit mounting (step 1) — zero behavioral change.
|
|
346
|
+
2. The smallest, least-referenced entity end-to-end (steps 2–6) — shakes out
|
|
347
|
+
masks, role mapping, and conventions on a low-stakes surface.
|
|
348
|
+
3. Architecture tests (step 7) — lock the rules in **before** the bulk.
|
|
349
|
+
4. Remaining entities in dependency order, one PR each.
|
|
350
|
+
5. Delete transitional re-exports and remaining root function files; run the
|
|
351
|
+
definition-of-done checklist.
|
package/docs/tenancy.md
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# `cvx-kit/tenancy` — row-level security, with tenancy as an opt-in layer
|
|
2
|
+
|
|
3
|
+
Row-level security and tenancy are **separate, composable features** of the
|
|
4
|
+
same `security` config on `createAuthFunctions`:
|
|
5
|
+
|
|
6
|
+
- **`security.rules` alone** — role-level / module-level RLS with no tenancy
|
|
7
|
+
at all: gate writes by role, freeze evidence tables, ownership-gate reads.
|
|
8
|
+
`defaultPolicy` stays `'allow'`; unlisted tables behave normally.
|
|
9
|
+
- **`security.tenancy` alone** — pure tenant isolation from a table registry.
|
|
10
|
+
- **Both** — rules are AND-composed with tenant isolation.
|
|
11
|
+
|
|
12
|
+
A single-org app that only wants role-gated writes configures `rules` and
|
|
13
|
+
never touches tenancy. Nothing below is required unless you opt into it.
|
|
14
|
+
|
|
15
|
+
Multi-tenancy itself is a structural machine, not a per-handler
|
|
16
|
+
convention. The model: **tenant = the identity provider's organization id**,
|
|
17
|
+
stamped as a server-owned `tenant` string field on every tenant-scoped row,
|
|
18
|
+
always derived from the verified identity — never accepted as an argument.
|
|
19
|
+
Isolation is defense-in-depth; each layer covers a hole in the previous one:
|
|
20
|
+
|
|
21
|
+
1. **Schema** — `tenantTable` injects the field and strips it from every
|
|
22
|
+
write boundary.
|
|
23
|
+
2. **Row-level security** — deny-default rules generated mechanically from
|
|
24
|
+
one table registry, wrapped into the auth constructors.
|
|
25
|
+
3. **Triggers** — a post-image invariant forbids tenant reassignment (RLS
|
|
26
|
+
authorizes against the pre-image only).
|
|
27
|
+
4. **Reference guards** — client-supplied ids re-verify ownership without
|
|
28
|
+
disclosing existence.
|
|
29
|
+
|
|
30
|
+
Single-org apps skip all of this: every piece is opt-in, and
|
|
31
|
+
`createAuthFunctions` without a `security` config behaves exactly as before.
|
|
32
|
+
|
|
33
|
+
## The registry is the design
|
|
34
|
+
|
|
35
|
+
One list of table names drives everything. Adding a table to the registry is
|
|
36
|
+
the entire onboarding — there is no second per-table step to forget:
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
// convex/tenancy.ts (or alongside triggers.ts)
|
|
40
|
+
export const TENANT_TABLES = ['<entities>', '<others>'] as const
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
- `security.tenancy.tables: TENANT_TABLES` → RLS rules generated per table
|
|
44
|
+
- `tenantOwnership(triggers, ...TENANT_TABLES)` → reassignment guard per table
|
|
45
|
+
- each listed table is declared with `tenantTable(...)` in its module
|
|
46
|
+
|
|
47
|
+
Recommended architecture test: assert every `tenantTable` in the codebase
|
|
48
|
+
appears in `TENANT_TABLES`, and vice versa.
|
|
49
|
+
|
|
50
|
+
## Wiring
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
// domain/<module>/schema.ts — tenant injected at the schema boundary
|
|
54
|
+
export const <entities> = tenantTable('<entities>', (id) => ({
|
|
55
|
+
name: z.string(), ownerId: id('users'),
|
|
56
|
+
}), { publicFields: ['name'] })
|
|
57
|
+
// tenant: excluded from insertSchema/updateSchema/commandInput,
|
|
58
|
+
// in publicDto only if explicitly allowlisted
|
|
59
|
+
|
|
60
|
+
// domain/<module>/table.ts — every index tenant-prefixed
|
|
61
|
+
<entities>: <entities>.table.index('by_tenant', ['tenant'])
|
|
62
|
+
.index('by_tenant_owner', ['tenant', 'ownerId'])
|
|
63
|
+
|
|
64
|
+
// convex/triggers.ts
|
|
65
|
+
tenantOwnership(triggers, ...TENANT_TABLES)
|
|
66
|
+
|
|
67
|
+
// convex/functions.ts
|
|
68
|
+
createAuthFunctions<DataModel>({
|
|
69
|
+
...,
|
|
70
|
+
security: {
|
|
71
|
+
tenancy: {
|
|
72
|
+
tables: TENANT_TABLES,
|
|
73
|
+
// optional; defaults to bundle.org.organizationId
|
|
74
|
+
resolve: (bundle) => bundle.org.organizationId,
|
|
75
|
+
},
|
|
76
|
+
// role-level security, AND-composed with tenant isolation
|
|
77
|
+
rules: (bundle) => ({
|
|
78
|
+
'<entities>': { modify: async () => bundle.role === '<privileged>' },
|
|
79
|
+
}),
|
|
80
|
+
// defaults to 'deny' when tenancy is configured
|
|
81
|
+
defaultPolicy: 'deny',
|
|
82
|
+
},
|
|
83
|
+
})
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## What the constructors do with it
|
|
87
|
+
|
|
88
|
+
- Every authenticated ctx gains **`ctx.tenant`** (also on the frozen bundle) —
|
|
89
|
+
resolved via `security.tenancy.resolve`, defaulting to the organization id.
|
|
90
|
+
Actions re-derive it from the live-verified membership.
|
|
91
|
+
- **Queries** get a wrapped reader: rows failing `read` are invisible —
|
|
92
|
+
filtered from every `get`/`query`, even a full table scan.
|
|
93
|
+
- **Mutations** wrap triggers first, then the RLS writer — `insert`/`modify`
|
|
94
|
+
failing a rule throws. Order matters and is handled for you.
|
|
95
|
+
- **`system*` constructors stay unwrapped** — trusted internal paths reach
|
|
96
|
+
every table. This is deliberate: schedulers and callbacks receive the
|
|
97
|
+
server-derived tenant as data, never as authority.
|
|
98
|
+
- With tenancy configured, `defaultPolicy` defaults to **deny**: tables
|
|
99
|
+
outside the registry are unreachable from `auth*`/`role*`/`admin*`
|
|
100
|
+
functions. Global/platform tables are reached from `system*` paths, or by
|
|
101
|
+
explicitly granting rules for them.
|
|
102
|
+
|
|
103
|
+
Handlers stamp inserts from the ctx — the schema forbids clients supplying
|
|
104
|
+
it, RLS rejects a wrong value, and the trigger forbids changing it later:
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
await ctx.db.insert('<entities>', { ...args, tenant: ctx.tenant })
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Role-level security — with or without tenancy
|
|
111
|
+
|
|
112
|
+
`security.rules` receives the full auth bundle and returns convex-helpers
|
|
113
|
+
`Rules`. It works standalone (no tenancy configured) or AND-composed with
|
|
114
|
+
tenant isolation (`composeRules`) — a row must pass **every** rule set that
|
|
115
|
+
defines the operation. Use it for role-gated writes, ownership-gated reads,
|
|
116
|
+
or state-machine write locks:
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
// rules-only, single-org app: no tenancy anywhere
|
|
120
|
+
security: {
|
|
121
|
+
rules: (bundle) => ({
|
|
122
|
+
'<entities>': { modify: async () => bundle.role !== '<readonly-role>' },
|
|
123
|
+
}),
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
rules: (bundle) => ({
|
|
129
|
+
'<entities>': {
|
|
130
|
+
modify: async (_ctx, doc) =>
|
|
131
|
+
bundle.role === 'admin' || doc.ownerId === bundle.actor.userId,
|
|
132
|
+
},
|
|
133
|
+
'<evidence>': { modify: async () => false }, // frozen via RLS as well as trigger
|
|
134
|
+
})
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Client-supplied ids
|
|
138
|
+
|
|
139
|
+
RLS makes foreign rows invisible to reads, but any id a client sends should
|
|
140
|
+
still be re-verified — and without disclosing existence:
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
import { requireTenantReference } from 'cvx-kit/tenancy'
|
|
144
|
+
|
|
145
|
+
const row = await requireTenantReference(ctx.tenant, () => ctx.db.get(args.id))
|
|
146
|
+
// missing row and foreign row fail identically: REFERENCE_NOT_FOUND
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
`assertTenantOwned(tenant, doc)` is the internal-path variant
|
|
150
|
+
(CROSS_TENANT_REFERENCE, for rows you already loaded on trusted paths).
|
|
151
|
+
|
|
152
|
+
## Footguns
|
|
153
|
+
|
|
154
|
+
1. **Actions have no db** — an action passing work to internal functions must
|
|
155
|
+
pass `ctx.tenant` (server-derived) as data; internal functions must never
|
|
156
|
+
accept a caller-chosen tenant on a public path.
|
|
157
|
+
2. **Provider ids are global** (memberships, invitations in WorkOS-style
|
|
158
|
+
providers): org-scoped mutations against the provider must re-check the
|
|
159
|
+
id belongs to the active org, or you ship a cross-tenant IDOR.
|
|
160
|
+
3. **Tables outside the registry get none of this.** Keep the global-table
|
|
161
|
+
list short and named; every one is a per-handler-discipline zone.
|
|
162
|
+
4. **`t.run(...)` in convex-test bypasses RLS and triggers** — same rule as
|
|
163
|
+
always: seeding only.
|
|
164
|
+
5. **Return "not found", never "forbidden"** for cross-tenant references —
|
|
165
|
+
existence is also tenant data.
|
|
166
|
+
|
|
167
|
+
## Module maps
|
|
168
|
+
|
|
169
|
+
`createModule` (from `cvx-kit/zod-table`) merges per-module table maps into
|
|
170
|
+
the schema and rejects a table declared by two modules:
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
// convex/domain/table.ts
|
|
174
|
+
export const domainTables = createModule(
|
|
175
|
+
<moduleA>Tables, <moduleB>Tables, <moduleC>Tables,
|
|
176
|
+
)
|
|
177
|
+
// convex/schema.ts
|
|
178
|
+
export default defineSchema(domainTables)
|
|
179
|
+
```
|
package/docs/triggers.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# `cvx-kit/triggers` — one application-wide mutation interceptor registry
|
|
2
|
+
|
|
3
|
+
A single `Triggers` registry (from convex-helpers) is created once per app and
|
|
4
|
+
handed to `createAuthFunctions`. From then on, every `authMutation`,
|
|
5
|
+
`roleMutation`, and `systemMutation` runs its writes through
|
|
6
|
+
`triggers.wrapDB(ctx)` — trigger enforcement is **structural, not a
|
|
7
|
+
convention**. A handler cannot skip a trigger because it never sees an
|
|
8
|
+
unwrapped `ctx.db`.
|
|
9
|
+
|
|
10
|
+
## Setup
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
// convex/triggers.ts — the single registry
|
|
14
|
+
import { appendOnly, createTriggers, noDelete, timestamps } from 'cvx-kit/triggers'
|
|
15
|
+
import type { DataModel } from './_generated/dataModel'
|
|
16
|
+
|
|
17
|
+
export const triggers = createTriggers<DataModel>()
|
|
18
|
+
|
|
19
|
+
// Server-owned lifecycle timestamps (see zod-table.md)
|
|
20
|
+
timestamps(triggers, 'documents', 'projects', 'members')
|
|
21
|
+
|
|
22
|
+
// Evidence tables: inserts only — updates/deletes throw and roll back
|
|
23
|
+
appendOnly(triggers, 'votes', 'history')
|
|
24
|
+
|
|
25
|
+
// Soft-delete discipline: hard deletes throw
|
|
26
|
+
noDelete(triggers, 'documents')
|
|
27
|
+
|
|
28
|
+
// Domain triggers register on the same instance
|
|
29
|
+
triggers.register('documents', async (ctx, change) => {
|
|
30
|
+
if (change.operation === 'insert') { /* denormalize, validate, cascade */ }
|
|
31
|
+
})
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
// convex/functions.ts
|
|
36
|
+
createAuthFunctions<DataModel>({ ..., triggers })
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## The built-in helpers
|
|
40
|
+
|
|
41
|
+
- **`timestamps(triggers, ...tables)`** — maintains zod-table's opinionated
|
|
42
|
+
fields: sets `createdAt` + `updatedAt` on insert, moves `updatedAt` on every
|
|
43
|
+
update. `archivedAt` stays under application control. Contains its own
|
|
44
|
+
recursion guard (a write that already moved `updatedAt` — including the
|
|
45
|
+
trigger's own patch — is left alone). Register once per table.
|
|
46
|
+
- **`appendOnly(triggers, ...tables)`** — inserts pass; any update or delete
|
|
47
|
+
throws, which **rolls back the whole transaction**. The pattern for
|
|
48
|
+
votes/history/audit-style evidence tables.
|
|
49
|
+
- **`noDelete(triggers, ...tables)`** — forbids hard deletes only; updates
|
|
50
|
+
pass. Pair with `archivedAt` for soft deletion.
|
|
51
|
+
- **`tenantOwnership(triggers, ...tables)`** — the tenancy post-image
|
|
52
|
+
invariant: inserts must carry a non-empty `tenant`, updates may never
|
|
53
|
+
reassign it. Register for every table in the tenancy registry (row-level
|
|
54
|
+
security checks the pre-image only — this closes that hole). See
|
|
55
|
+
`tenancy.md`.
|
|
56
|
+
|
|
57
|
+
`Triggers`, `Change`, and `Trigger` are re-exported for custom registrations.
|
|
58
|
+
|
|
59
|
+
## Footguns
|
|
60
|
+
|
|
61
|
+
1. **Anything outside the kit constructors bypasses every trigger.** Raw
|
|
62
|
+
`mutation`/`internalMutation` from `_generated/server`, and `ctx.db` inside
|
|
63
|
+
`convex-test`'s `t.run(...)`, are unwrapped. Never build a mutation outside
|
|
64
|
+
your `functions.ts`, and expect test-side `t.run` writes to be trigger-free
|
|
65
|
+
(useful for seeding, dangerous for assertions about trigger behavior).
|
|
66
|
+
2. **Wrapper spread order matters** if you compose ctx manually:
|
|
67
|
+
`{ ...wrapDB(ctx), ...otherStuff }` — `wrapDB` returns a full ctx, so
|
|
68
|
+
spreading it *after* anything that carries `db` restores the raw database.
|
|
69
|
+
The kit's constructors already order this correctly
|
|
70
|
+
(`{ ...wrapDB(ctx), include, ...authBundle }`).
|
|
71
|
+
3. **A throwing trigger aborts the transaction.** That is the point for
|
|
72
|
+
`appendOnly`/`noDelete`, but remember it when writing custom triggers —
|
|
73
|
+
validation triggers should throw; best-effort denormalization should not.
|
|
74
|
+
4. **Triggers run per write, in registration order.** Keep them small; heavy
|
|
75
|
+
work belongs in a scheduled function, not in a trigger.
|