cvx-kit 0.1.15 → 0.1.16

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.
Files changed (45) hide show
  1. package/README.md +59 -38
  2. package/dist/relations.mjs +1 -1
  3. package/dist/relations.mjs.map +1 -1
  4. package/dist/zod-table.d.mts +59 -2
  5. package/dist/zod-table.mjs +1 -1
  6. package/dist/zod-table.mjs.map +1 -1
  7. package/package.json +10 -3
  8. package/AGENTS.md +0 -47
  9. package/CHANGELOG.md +0 -337
  10. package/ROADMAP.md +0 -126
  11. package/docs/agent-tools.md +0 -41
  12. package/docs/approvals.md +0 -201
  13. package/docs/architecture.md +0 -124
  14. package/docs/auth.md +0 -443
  15. package/docs/command-context.md +0 -150
  16. package/docs/commands-legacy.md +0 -513
  17. package/docs/commands.md +0 -173
  18. package/docs/conventions.md +0 -345
  19. package/docs/crud.md +0 -66
  20. package/docs/domain-contracts.md +0 -148
  21. package/docs/domain-operations.md +0 -615
  22. package/docs/effect.md +0 -403
  23. package/docs/llms.md +0 -324
  24. package/docs/maintainability.md +0 -124
  25. package/docs/migration.md +0 -351
  26. package/docs/oxlint.md +0 -260
  27. package/docs/querying.md +0 -329
  28. package/docs/tenancy.md +0 -176
  29. package/docs/triggers.md +0 -77
  30. package/docs/upgrading.md +0 -287
  31. package/docs/webhooks.md +0 -58
  32. package/docs/zod-table.md +0 -278
  33. package/scripts/convex-config-bridge.mjs +0 -44
  34. package/scripts/convex-smoke-env.mjs +0 -33
  35. package/scripts/extract-changelog.mjs +0 -29
  36. package/scripts/owned-process.mjs +0 -140
  37. package/scripts/owned-process.test.mjs +0 -107
  38. package/scripts/smoke-packed-components.mjs +0 -341
  39. package/scripts/smoke-packed-effect.mjs +0 -439
  40. package/scripts/smoke-packed-querying.mjs +0 -298
  41. package/scripts/test-packed-auth.mjs +0 -72
  42. package/scripts/test-packed-effect.mjs +0 -482
  43. package/scripts/test-packed-ids.mjs +0 -150
  44. package/scripts/test-packed-oxlint.mjs +0 -167
  45. package/scripts/test-packed-querying.mjs +0 -185
package/docs/querying.md DELETED
@@ -1,329 +0,0 @@
1
- # `cvx-kit/querying` — typed, authorized stream pagination
2
-
3
- Declare relationships with `cvx-kit/relations`, configure one query engine, then
4
- paginate with a small declarative chain. The engine runs streams and relationship
5
- lookups in the caller's Convex query transaction. Filtering determines eligible
6
- parents before they fill a page. `include` and native `paginated(dto)` remain
7
- independent APIs.
8
-
9
- ## Declare tables and indexes
10
-
11
- Keep shapes in `domain/<module>/schema.ts` and native indexes in its `table.ts`.
12
- These examples put tasks, labels, and their junction in one owning module.
13
-
14
- ```ts
15
- // convex/domain/tasks/schema.ts
16
- import { z } from 'zod'
17
- import { zodTable } from 'cvx-kit/zod-table'
18
-
19
- export const tasks = zodTable(
20
- 'tasks',
21
- (id) => ({
22
- title: z.string(),
23
- department: z.string(),
24
- status: z.string(),
25
- priority: z.number(),
26
- reference: id('tasks').nullable().optional(),
27
- }),
28
- { publicFields: ['title', 'department', 'status', 'priority'] },
29
- )
30
-
31
- export const labels = zodTable(
32
- 'labels',
33
- () => ({
34
- name: z.string(),
35
- }),
36
- { publicFields: ['name'] },
37
- )
38
-
39
- export const edges = zodTable(
40
- 'edges',
41
- (id) => ({
42
- task: id('tasks'),
43
- label: id('labels'),
44
- }),
45
- { publicFields: [] },
46
- )
47
- ```
48
-
49
- ```ts
50
- // convex/domain/tasks/table.ts
51
- import { tasks, labels, edges } from './schema'
52
-
53
- export const tables = {
54
- tasks: tasks.table.index('by_department_status', ['department', 'status']),
55
- labels: labels.table,
56
- edges: edges.table.index('by_task', ['task']),
57
- }
58
- ```
59
-
60
- Assemble those table definitions in the application's root schema. Declare every
61
- index used by a relationship before compiling the relation registry.
62
-
63
- ## Declare relationships once
64
-
65
- ```ts
66
- // convex/relations.ts
67
- import { defineRelations } from 'cvx-kit/relations'
68
- import './domain/tasks/table'
69
- import { tasks, labels, edges } from './domain/tasks/schema'
70
-
71
- export const relations = defineRelations({ tasks, labels, edges }, (r) => ({
72
- tasks: {
73
- referenceTask: r.one.tasks({ from: r.tasks.reference, to: r.tasks._id }),
74
- labels: r.many.labels({
75
- from: r.tasks._id.through(r.edges.task),
76
- to: r.labels._id.through(r.edges.label),
77
- }),
78
- },
79
- }))
80
- ```
81
-
82
- A direct one edge uses `r.one.target({ from, to })`; a direct many edge uses
83
- `r.many.target({ from, to })`. For example, with a stored `labels.task` foreign
84
- key and a supporting `by_task` index on labels:
85
-
86
- ```ts
87
- r.many.labels({ from: r.tasks._id, to: r.labels.task })
88
- ```
89
-
90
- Relationship names distinguish multiple references to the same table. Self
91
- references and junctions are supported. Tables must be object-shaped `zodTable`
92
- or `tenantTable` boundaries; variant tables are outside this API. Declarations
93
- are validated against table ownership, field compatibility, and supporting
94
- indexes. Convex does not infer relationships; this registry supplies them.
95
-
96
- ## Configure the engine and authentication
97
-
98
- ```ts
99
- // convex/querying.ts
100
- import { createQueryEngine } from 'cvx-kit/querying'
101
- import schema from './schema'
102
- import { relations } from './relations'
103
-
104
- export const querying = createQueryEngine({ schema, relations })
105
- ```
106
-
107
- The application's `functions.ts` facade configures `createAuthFunctions`,
108
- including its membership resolution and effective read/tenancy policies. Call
109
- `querying(ctx)` with a query context supplied by that facade's `authQuery`.
110
- The engine consumes the private managed reader capability installed there and
111
- rechecks composed authorization for parents, junctions, targets, and continuation
112
- reads. The example declarations do not establish access policy on their own.
113
- See [auth.md](./auth.md) and [tenancy.md](./tenancy.md) for policy setup.
114
-
115
- Unknown contexts fail closed with `UNMANAGED_QUERY_CONTEXT`. Mutation and action
116
- contexts are unsupported. A host with another auth system can centrally configure
117
- `trustedAdapter`; it must attest the query reader, effective policy, and security
118
- scope. Do not expose adapter selection to endpoint arguments.
119
-
120
- Declare this engine once at module scope. It serves every registered table;
121
- `querying(ctx)` binds authorization and read accounting to the current request.
122
- Table fields, indexes, DTOs, and relationship metadata come from the schema and
123
- relation registry. No per-table engine declaration is needed.
124
-
125
- The engine loads cursor keys internally when pagination first runs. Set
126
- `CVX_QUERY_CURSOR_KEYS_JSON` to a JSON object mapping key IDs to 128 hexadecimal
127
- characters (64 secret bytes), and `CVX_QUERY_CURSOR_CURRENT_KEY_ID` to the ID
128
- used for new tokens. These are server environment variables, shared by all
129
- tables. Generate real secrets outside query execution and configure them through
130
- Convex environment settings; never put them in client arguments, source, or logs.
131
- Keep prior keys during rotation to preserve existing cursors. Changing environment
132
- configuration requires the engine to reload with the new deployment configuration.
133
-
134
- Engine declaration and auth registration do not read these variables. Missing or
135
- invalid configuration fails pagination before database reads. The codec's format
136
- version is internal. It uses authenticated AES-SIV encryption with no plaintext
137
- fallback; identical bound payloads reveal equality.
138
-
139
- ## Define input and result contracts
140
-
141
- ```ts
142
- // Inside convex/domain/tasks/contracts.ts
143
- const input = relations.tables.tasks.paginationOpts({
144
- department: true,
145
- status: true,
146
- priority: true,
147
- referenceTask: { status: true },
148
- labels: { name: true },
149
- })
150
- const result = relations.tables.tasks.pageDto({
151
- referenceTask: true,
152
- labels: true,
153
- })
154
- ```
155
-
156
- `paginationOpts(selection)` extends native Convex pagination options with optional
157
- `filters` and global `order: 'asc' | 'desc'`. The selection permits client filters;
158
- it does not change public output or grant access. The result selection must
159
- match `.with(...)`. `.pageDto({})` returns a native page of the table's public
160
- DTOs including branded `_id`. One relationships return a public DTO or `null`;
161
- many relationships return their own bounded native pages. Missing and unreadable
162
- targets have the same public result. Duplicate junction occurrences remain in
163
- output, even when target reads are memoized.
164
-
165
- For scalar-only input, use `tasks.paginationOpts({ status: true })`. Both forms
166
- are Zod objects and support `.extend(...)` for endpoint-specific arguments.
167
- Use the contracts through your domain Query registry and keep public auth
168
- registration in `api/<entity>.ts`; see [domain-contracts.md](./domain-contracts.md).
169
-
170
- ## Query and filter
171
-
172
- ```ts
173
- // Inside an authenticated domain Query handler
174
- const department = args.filters?.department
175
- const indexCondition = typeof department === 'string' ? department : undefined
176
- const page = await querying(ctx)
177
- .tasks.where({ status: 'open' })
178
- .when(indexCondition, 'by_department_status')
179
- .with({ referenceTask: true, labels: true })
180
- .paginate(args)
181
- ```
182
-
183
- `.where(spec)` adds server-owned constraints, combined with client filters by
184
- AND. Use it for mandatory business constraints; effective auth policy still
185
- controls access. `.with(selection)` chooses related output. A relation can be
186
- used as an eligibility filter without also including it in output. A parent
187
- relationship predicate does not filter the nested output page: it determines
188
- whether the parent is eligible. Child-page filters are a separate query input.
189
-
190
- Example client args for the contract above:
191
-
192
- ```ts
193
- {
194
- numItems: 25,
195
- cursor: null,
196
- order: 'desc',
197
- filters: {
198
- department: 'engineering',
199
- priority: { gte: 2, lt: 5 },
200
- labels: { some: { name: 'urgent' } },
201
- not: { status: 'blocked' },
202
- },
203
- }
204
- ```
205
-
206
- Scalar predicates accept direct equality or operator objects with `eq`, `in`,
207
- and, for supported range types, `gt`, `gte`, `lt`, and `lte`. Optional fields
208
- also support `exists`. `and` and `or` contain nonempty arrays of predicates;
209
- `not` contains one predicate. Use `fields: { ... }` to address scalar fields
210
- whose names collide with grammar keys such as `and`, `or`, `not`, or `fields`.
211
- One relationships accept a nested predicate or `null`; many relationships use
212
- `some` or `none` over authorized targets. Unknown fields and incompatible scalar
213
- values/operators fail validation. Filter depth is at most four, the whole tree
214
- has at most 256 nodes, and an `in` list contains at most 100 values.
215
-
216
- ## Conditional indexes and ordering
217
-
218
- `.when(value, 'index_name')` selects the first present condition. `false`, `0`,
219
- and `''` are present; `null` and `undefined` are absent. The condition chooses an
220
- index but does not bind its fields: validated filters and `.where(...)` supply
221
- the bindings. An incompatible explicit index fails instead of silently scanning.
222
- Relationship traversal retains its mandatory foreign-key prefix.
223
-
224
- Without an explicit choice, the planner selects the longest usable equality
225
- prefix, with stable index-name ordering for ties and creation-time fallback.
226
- OR branches contribute only guaranteed common bindings. `order: 'desc'` reverses
227
- the selected native index order; it does not sort by an arbitrary field.
228
-
229
- ## Continue relationship pages
230
-
231
- Return many relationships as pages, then use a separate authenticated endpoint
232
- for their next page. Validate `parent` with `tasks.schema.shape._id` and use the
233
- related table's pagination contract:
234
-
235
- ```ts
236
- const input = relations.tables.labels
237
- .paginationOpts({ name: true })
238
- .extend({ parent: tasks.schema.shape._id })
239
- const result = relations.tables.labels.pageDto({})
240
-
241
- // Inside the child Query handler
242
- const { parent, ...options } = args
243
- return querying(ctx).tasks.relation(parent, 'labels').paginate(options)
244
- ```
245
-
246
- The client sends the parent ID and the nested page's `continueCursor`, preserving
247
- its original order and output selection. For the parent example above that means
248
- `order: 'desc'`, selection `{}`, and no child filters on resume; adding a `name`
249
- filter would change the binding and reject that cursor. A new child traversal
250
- with `cursor: null` can establish its own filters. Typed
251
- paths can traverse several declared edges to a many relationship. Each request
252
- rechecks the parent path and current authorization; a child token cannot be
253
- replayed under another parent.
254
-
255
- Root, child, split, and end cursors bind filters, output selection, index, order,
256
- ancestry, principal/tenant/security scope, and registry/policy/engine versions.
257
- Changing page size alone remains valid. Treat tokens as opaque; preserve native
258
- `isDone`, `continueCursor`, and optional page-status/split metadata. Permission
259
- changes are rechecked on resume. Native `endCursor` supports bounded partitions.
260
-
261
- ## Central limits and operational behavior
262
-
263
- The engine supplies default limits and cursor compatibility versions. Normal
264
- applications need only `{ schema, relations }`. Optional central overrides belong
265
- in the second argument:
266
-
267
- ```ts
268
- export const querying = createQueryEngine(
269
- { schema, relations },
270
- {
271
- maxPageSize: 50,
272
- },
273
- )
274
- ```
275
-
276
- | Option | Purpose | Default setup |
277
- | ---------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------- |
278
- | `budgets` | Lower shared read, byte, fan-out, depth, and memo ceilings | Engine defaults |
279
- | `maxPageSize` | Lower the 100-item page ceiling | 100 |
280
- | `policyVersion` | Invalidate old cursors after a policy semantic change | Internal default; current authorization is always rechecked |
281
- | `engineVersion` | Override the engine compatibility identity | Internal default |
282
- | `trustedAdapter` | Connect another auth system's query reader and policies | Managed auth capability from `cvx-kit/auth` |
283
- | `cursor` | Supply an explicit key ring, for custom secret management or migration | Server environment loader |
284
-
285
- These are application-wide overrides, never endpoint arguments. Existing
286
- one-argument declarations with these fields still work for compatibility; move
287
- those fields to the second argument when migrating. To preserve pre-migration
288
- cursors, keep the existing explicit cursor version and key ring until those
289
- cursors have expired.
290
-
291
- Page sizes are positive integers up to 100. Shared request budgets cover read
292
- operations, index ranges, raw candidates, document bytes, fan-out, traversal
293
- depth, and memo entries, including managed policy reads. Native request read
294
- limits may lower ceilings. Output relationship depth is at most four.
295
-
296
- If eligibility cannot be established within the budget, execution throws
297
- `MANAGED_READ_BUDGET`; it does not return a misleading exhausted page or conclude
298
- that a negative relationship predicate matched. Policy errors propagate rather
299
- than turning into eligible rows. Memoization is request-local and preserves
300
- junction multiplicity. Relationships can require many database reads; one public
301
- query does not imply one physical read. Document accounting is an application
302
- bound, not a measurement of Convex storage/index/prefetch I/O.
303
-
304
- Arbitrary async predicates, custom projections/sorts, mutation/action execution,
305
- search/vector optimization, and global caching are outside this API. Index
306
- selection reduces candidates where possible; filtering every supported scalar
307
- field does not guarantee an efficient index for every filter combination.
308
-
309
- ## Migration and verification
310
-
311
- Use `toPublicDto(row, true)` with `dto(true)` instead of manual ID spreads.
312
- Replace copied pagination schemas with table/registry helpers. Move eligibility
313
- predicates before bounded page filling, and replace unbounded relationship
314
- collection with declared many pages and child continuation. See the diagnostics
315
- and examples in [oxlint.md](./oxlint.md).
316
-
317
- The [packed example](../../test/fixture-packed-querying/convex/) contains complete
318
- schema, relation, engine, contract, Query-registry, auth-adapter, and child-endpoint
319
- wiring. `bun run test:package:querying` exercises strict installed and minimum-peer
320
- consumers, all consumer rules, and multi-entity/filter/order continuation tests.
321
- The local native smoke runner exercises registered Convex validators and cursors;
322
- these checks do not establish hosted production performance.
323
-
324
- Run `bunx vp test run test/querying-relations-stress.test.ts --silent=false` to
325
- report per-page p10, p50, and p99 latency for each filter/order/page-size case,
326
- plus an aggregate for duplicate child continuations. Reports include sample
327
- counts and use nearest-rank percentiles. A small sample's p99 is its maximum;
328
- the measurements include `convex-test` and instrumentation overhead. Read
329
- counters and ordered-result assertions remain separate from latency measurements.
package/docs/tenancy.md DELETED
@@ -1,176 +0,0 @@
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) => bundle.role === 'admin' || doc.ownerId === bundle.actor.userId,
131
- },
132
- '<evidence>': { modify: async () => false }, // frozen via RLS as well as trigger
133
- })
134
- ```
135
-
136
- ## Client-supplied ids
137
-
138
- RLS makes foreign rows invisible to reads, but any id a client sends should
139
- still be re-verified — and without disclosing existence:
140
-
141
- ```ts
142
- import { requireTenantReference } from 'cvx-kit/tenancy'
143
-
144
- const row = await requireTenantReference(ctx.tenant, () => ctx.db.get(args.id))
145
- // missing row and foreign row fail identically: REFERENCE_NOT_FOUND
146
- ```
147
-
148
- `assertTenantOwned(tenant, doc)` is the internal-path variant
149
- (CROSS_TENANT_REFERENCE, for rows you already loaded on trusted paths).
150
-
151
- ## Footguns
152
-
153
- 1. **Actions have no db** — an action passing work to internal functions must
154
- pass `ctx.tenant` (server-derived) as data; internal functions must never
155
- accept a caller-chosen tenant on a public path.
156
- 2. **Provider ids are global** (memberships, invitations in WorkOS-style
157
- providers): org-scoped mutations against the provider must re-check the
158
- id belongs to the active org, or you ship a cross-tenant IDOR.
159
- 3. **Tables outside the registry get none of this.** Keep the global-table
160
- list short and named; every one is a per-handler-discipline zone.
161
- 4. **`t.run(...)` in convex-test bypasses RLS and triggers** — same rule as
162
- always: seeding only.
163
- 5. **Return "not found", never "forbidden"** for cross-tenant references —
164
- existence is also tenant data.
165
-
166
- ## Module maps
167
-
168
- `createModule` (from `cvx-kit/zod-table`) merges per-module table maps into
169
- the schema and rejects a table declared by two modules:
170
-
171
- ```ts
172
- // convex/domain/table.ts
173
- export const domainTables = createModule(<moduleA>Tables, <moduleB>Tables, <moduleC>Tables)
174
- // convex/schema.ts
175
- export default defineSchema(domainTables)
176
- ```
package/docs/triggers.md DELETED
@@ -1,77 +0,0 @@
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') {
31
- /* denormalize, validate, cascade */
32
- }
33
- })
34
- ```
35
-
36
- ```ts
37
- // convex/functions.ts
38
- createAuthFunctions<DataModel>({ ..., triggers })
39
- ```
40
-
41
- ## The built-in helpers
42
-
43
- - **`timestamps(triggers, ...tables)`** — maintains zod-table's opinionated
44
- fields: sets `createdAt` + `updatedAt` on insert, moves `updatedAt` on every
45
- update. `archivedAt` stays under application control. Contains its own
46
- recursion guard (a write that already moved `updatedAt` — including the
47
- trigger's own patch — is left alone). Register once per table.
48
- - **`appendOnly(triggers, ...tables)`** — inserts pass; any update or delete
49
- throws, which **rolls back the whole transaction**. The pattern for
50
- votes/history/audit-style evidence tables.
51
- - **`noDelete(triggers, ...tables)`** — forbids hard deletes only; updates
52
- pass. Pair with `archivedAt` for soft deletion.
53
- - **`tenantOwnership(triggers, ...tables)`** — the tenancy post-image
54
- invariant: inserts must carry a non-empty `tenant`, updates may never
55
- reassign it. Register for every table in the tenancy registry (row-level
56
- security checks the pre-image only — this closes that hole). See
57
- `tenancy.md`.
58
-
59
- `Triggers`, `Change`, and `Trigger` are re-exported for custom registrations.
60
-
61
- ## Footguns
62
-
63
- 1. **Anything outside the kit constructors bypasses every trigger.** Raw
64
- `mutation`/`internalMutation` from `_generated/server`, and `ctx.db` inside
65
- `convex-test`'s `t.run(...)`, are unwrapped. Never build a mutation outside
66
- your `functions.ts`, and expect test-side `t.run` writes to be trigger-free
67
- (useful for seeding, dangerous for assertions about trigger behavior).
68
- 2. **Wrapper spread order matters** if you compose ctx manually:
69
- `{ ...wrapDB(ctx), ...otherStuff }` — `wrapDB` returns a full ctx, so
70
- spreading it _after_ anything that carries `db` restores the raw database.
71
- The kit's constructors already order this correctly
72
- (`{ ...wrapDB(ctx), include, ...authBundle }`).
73
- 3. **A throwing trigger aborts the transaction.** That is the point for
74
- `appendOnly`/`noDelete`, but remember it when writing custom triggers —
75
- validation triggers should throw; best-effort denormalization should not.
76
- 4. **Triggers run per write, in registration order.** Keep them small; heavy
77
- work belongs in a scheduled function, not in a trigger.