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.
@@ -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.
@@ -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
+ ```
@@ -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.