cvx-kit 0.1.14 → 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.
- package/README.md +64 -45
- package/dist/querying/cursors.d.mts +5 -0
- package/dist/querying/cursors.mjs +1 -1
- package/dist/querying/cursors.mjs.map +1 -1
- package/dist/querying.d.mts +20 -6
- package/dist/querying.mjs +1 -1
- package/dist/querying.mjs.map +1 -1
- package/dist/relations.mjs +1 -1
- package/dist/relations.mjs.map +1 -1
- package/dist/zod-table.d.mts +59 -2
- package/dist/zod-table.mjs +1 -1
- package/dist/zod-table.mjs.map +1 -1
- package/package.json +10 -3
- package/AGENTS.md +0 -47
- package/CHANGELOG.md +0 -324
- package/ROADMAP.md +0 -126
- package/docs/agent-tools.md +0 -41
- package/docs/approvals.md +0 -201
- package/docs/architecture.md +0 -124
- package/docs/auth.md +0 -443
- package/docs/command-context.md +0 -150
- package/docs/commands-legacy.md +0 -513
- package/docs/commands.md +0 -173
- package/docs/conventions.md +0 -345
- package/docs/crud.md +0 -66
- package/docs/domain-contracts.md +0 -148
- package/docs/domain-operations.md +0 -615
- package/docs/effect.md +0 -403
- package/docs/llms.md +0 -324
- package/docs/maintainability.md +0 -124
- package/docs/migration.md +0 -351
- package/docs/oxlint.md +0 -254
- package/docs/querying.md +0 -293
- package/docs/tenancy.md +0 -176
- package/docs/triggers.md +0 -77
- package/docs/upgrading.md +0 -287
- package/docs/webhooks.md +0 -58
- package/docs/zod-table.md +0 -278
- package/scripts/convex-config-bridge.mjs +0 -44
- package/scripts/convex-smoke-env.mjs +0 -33
- package/scripts/extract-changelog.mjs +0 -29
- package/scripts/owned-process.mjs +0 -140
- package/scripts/owned-process.test.mjs +0 -107
- package/scripts/smoke-packed-components.mjs +0 -341
- package/scripts/smoke-packed-effect.mjs +0 -439
- package/scripts/smoke-packed-querying.mjs +0 -296
- package/scripts/test-packed-auth.mjs +0 -72
- package/scripts/test-packed-effect.mjs +0 -482
- package/scripts/test-packed-ids.mjs +0 -150
- package/scripts/test-packed-oxlint.mjs +0 -167
- package/scripts/test-packed-querying.mjs +0 -185
package/docs/querying.md
DELETED
|
@@ -1,293 +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
|
-
import { cursor } from './functions'
|
|
104
|
-
|
|
105
|
-
export const querying = createQueryEngine({ schema, relations, cursor })
|
|
106
|
-
```
|
|
107
|
-
|
|
108
|
-
The application's `functions.ts` facade configures `createAuthFunctions`,
|
|
109
|
-
including its membership resolution and effective read/tenancy policies. Call
|
|
110
|
-
`querying(ctx)` with a query context supplied by that facade's `authQuery`.
|
|
111
|
-
The engine consumes the private managed reader capability installed there and
|
|
112
|
-
rechecks composed authorization for parents, junctions, targets, and continuation
|
|
113
|
-
reads. The example declarations do not establish access policy on their own.
|
|
114
|
-
See [auth.md](./auth.md) and [tenancy.md](./tenancy.md) for policy setup.
|
|
115
|
-
|
|
116
|
-
Unknown contexts fail closed with `UNMANAGED_QUERY_CONTEXT`. Mutation and action
|
|
117
|
-
contexts are unsupported. A host with another auth system can centrally configure
|
|
118
|
-
`trustedAdapter`; it must attest the query reader, effective policy, and security
|
|
119
|
-
scope. Do not expose adapter selection to endpoint arguments.
|
|
120
|
-
|
|
121
|
-
`cursor` has `{ version, currentKeyId, keys }`. `keys` maps key IDs to server-only
|
|
122
|
-
64-byte `Uint8Array` secrets; `currentKeyId` names the key used for new tokens.
|
|
123
|
-
Load real secrets through server configuration, never from clients or a constant
|
|
124
|
-
fixture key. Keep prior keys during rotation to preserve existing cursors.
|
|
125
|
-
The default codec uses authenticated AES-SIV encryption; there is no plaintext
|
|
126
|
-
fallback. Identical bound payloads reveal equality.
|
|
127
|
-
|
|
128
|
-
## Define input and result contracts
|
|
129
|
-
|
|
130
|
-
```ts
|
|
131
|
-
// Inside convex/domain/tasks/contracts.ts
|
|
132
|
-
const input = relations.tables.tasks.paginationOpts({
|
|
133
|
-
department: true,
|
|
134
|
-
status: true,
|
|
135
|
-
priority: true,
|
|
136
|
-
referenceTask: { status: true },
|
|
137
|
-
labels: { name: true },
|
|
138
|
-
})
|
|
139
|
-
const result = relations.tables.tasks.pageDto({
|
|
140
|
-
referenceTask: true,
|
|
141
|
-
labels: true,
|
|
142
|
-
})
|
|
143
|
-
```
|
|
144
|
-
|
|
145
|
-
`paginationOpts(selection)` extends native Convex pagination options with optional
|
|
146
|
-
`filters` and global `order: 'asc' | 'desc'`. The selection permits client filters;
|
|
147
|
-
it does not change public output or grant access. The result selection must
|
|
148
|
-
match `.with(...)`. `.pageDto({})` returns a native page of the table's public
|
|
149
|
-
DTOs including branded `_id`. One relationships return a public DTO or `null`;
|
|
150
|
-
many relationships return their own bounded native pages. Missing and unreadable
|
|
151
|
-
targets have the same public result. Duplicate junction occurrences remain in
|
|
152
|
-
output, even when target reads are memoized.
|
|
153
|
-
|
|
154
|
-
For scalar-only input, use `tasks.paginationOpts({ status: true })`. Both forms
|
|
155
|
-
are Zod objects and support `.extend(...)` for endpoint-specific arguments.
|
|
156
|
-
Use the contracts through your domain Query registry and keep public auth
|
|
157
|
-
registration in `api/<entity>.ts`; see [domain-contracts.md](./domain-contracts.md).
|
|
158
|
-
|
|
159
|
-
## Query and filter
|
|
160
|
-
|
|
161
|
-
```ts
|
|
162
|
-
// Inside an authenticated domain Query handler
|
|
163
|
-
const department = args.filters?.department
|
|
164
|
-
const indexCondition = typeof department === 'string' ? department : undefined
|
|
165
|
-
const page = await querying(ctx)
|
|
166
|
-
.tasks.where({ status: 'open' })
|
|
167
|
-
.when(indexCondition, 'by_department_status')
|
|
168
|
-
.with({ referenceTask: true, labels: true })
|
|
169
|
-
.paginate(args)
|
|
170
|
-
```
|
|
171
|
-
|
|
172
|
-
`.where(spec)` adds server-owned constraints, combined with client filters by
|
|
173
|
-
AND. Use it for mandatory business constraints; effective auth policy still
|
|
174
|
-
controls access. `.with(selection)` chooses related output. A relation can be
|
|
175
|
-
used as an eligibility filter without also including it in output. A parent
|
|
176
|
-
relationship predicate does not filter the nested output page: it determines
|
|
177
|
-
whether the parent is eligible. Child-page filters are a separate query input.
|
|
178
|
-
|
|
179
|
-
Example client args for the contract above:
|
|
180
|
-
|
|
181
|
-
```ts
|
|
182
|
-
{
|
|
183
|
-
numItems: 25,
|
|
184
|
-
cursor: null,
|
|
185
|
-
order: 'desc',
|
|
186
|
-
filters: {
|
|
187
|
-
department: 'engineering',
|
|
188
|
-
priority: { gte: 2, lt: 5 },
|
|
189
|
-
labels: { some: { name: 'urgent' } },
|
|
190
|
-
not: { status: 'blocked' },
|
|
191
|
-
},
|
|
192
|
-
}
|
|
193
|
-
```
|
|
194
|
-
|
|
195
|
-
Scalar predicates accept direct equality or operator objects with `eq`, `in`,
|
|
196
|
-
and, for supported range types, `gt`, `gte`, `lt`, and `lte`. Optional fields
|
|
197
|
-
also support `exists`. `and` and `or` contain nonempty arrays of predicates;
|
|
198
|
-
`not` contains one predicate. Use `fields: { ... }` to address scalar fields
|
|
199
|
-
whose names collide with grammar keys such as `and`, `or`, `not`, or `fields`.
|
|
200
|
-
One relationships accept a nested predicate or `null`; many relationships use
|
|
201
|
-
`some` or `none` over authorized targets. Unknown fields and incompatible scalar
|
|
202
|
-
values/operators fail validation. Filter depth is at most four, the whole tree
|
|
203
|
-
has at most 256 nodes, and an `in` list contains at most 100 values.
|
|
204
|
-
|
|
205
|
-
## Conditional indexes and ordering
|
|
206
|
-
|
|
207
|
-
`.when(value, 'index_name')` selects the first present condition. `false`, `0`,
|
|
208
|
-
and `''` are present; `null` and `undefined` are absent. The condition chooses an
|
|
209
|
-
index but does not bind its fields: validated filters and `.where(...)` supply
|
|
210
|
-
the bindings. An incompatible explicit index fails instead of silently scanning.
|
|
211
|
-
Relationship traversal retains its mandatory foreign-key prefix.
|
|
212
|
-
|
|
213
|
-
Without an explicit choice, the planner selects the longest usable equality
|
|
214
|
-
prefix, with stable index-name ordering for ties and creation-time fallback.
|
|
215
|
-
OR branches contribute only guaranteed common bindings. `order: 'desc'` reverses
|
|
216
|
-
the selected native index order; it does not sort by an arbitrary field.
|
|
217
|
-
|
|
218
|
-
## Continue relationship pages
|
|
219
|
-
|
|
220
|
-
Return many relationships as pages, then use a separate authenticated endpoint
|
|
221
|
-
for their next page. Validate `parent` with `tasks.schema.shape._id` and use the
|
|
222
|
-
related table's pagination contract:
|
|
223
|
-
|
|
224
|
-
```ts
|
|
225
|
-
const input = relations.tables.labels
|
|
226
|
-
.paginationOpts({ name: true })
|
|
227
|
-
.extend({ parent: tasks.schema.shape._id })
|
|
228
|
-
const result = relations.tables.labels.pageDto({})
|
|
229
|
-
|
|
230
|
-
// Inside the child Query handler
|
|
231
|
-
const { parent, ...options } = args
|
|
232
|
-
return querying(ctx).tasks.relation(parent, 'labels').paginate(options)
|
|
233
|
-
```
|
|
234
|
-
|
|
235
|
-
The client sends the parent ID and the nested page's `continueCursor`, preserving
|
|
236
|
-
its original order and output selection. For the parent example above that means
|
|
237
|
-
`order: 'desc'`, selection `{}`, and no child filters on resume; adding a `name`
|
|
238
|
-
filter would change the binding and reject that cursor. A new child traversal
|
|
239
|
-
with `cursor: null` can establish its own filters. Typed
|
|
240
|
-
paths can traverse several declared edges to a many relationship. Each request
|
|
241
|
-
rechecks the parent path and current authorization; a child token cannot be
|
|
242
|
-
replayed under another parent.
|
|
243
|
-
|
|
244
|
-
Root, child, split, and end cursors bind filters, output selection, index, order,
|
|
245
|
-
ancestry, principal/tenant/security scope, and registry/policy/engine versions.
|
|
246
|
-
Changing page size alone remains valid. Treat tokens as opaque; preserve native
|
|
247
|
-
`isDone`, `continueCursor`, and optional page-status/split metadata. Permission
|
|
248
|
-
changes are rechecked on resume. Native `endCursor` supports bounded partitions.
|
|
249
|
-
|
|
250
|
-
## Central limits and operational behavior
|
|
251
|
-
|
|
252
|
-
`createQueryEngine` also accepts `maxPageSize`, `budgets`, `policyVersion`,
|
|
253
|
-
`engineVersion`, and `trustedAdapter`. These belong in central configuration,
|
|
254
|
-
not per-endpoint filter/project callbacks. Page sizes are positive integers up
|
|
255
|
-
to 100; `maxPageSize` can lower that ceiling. Shared request budgets cover read
|
|
256
|
-
operations, index ranges, raw candidates, document bytes, fan-out, traversal
|
|
257
|
-
depth, and memo entries, including managed policy reads. Native request read
|
|
258
|
-
limits may lower ceilings. Output relationship depth is at most four.
|
|
259
|
-
|
|
260
|
-
If eligibility cannot be established within the budget, execution throws
|
|
261
|
-
`MANAGED_READ_BUDGET`; it does not return a misleading exhausted page or conclude
|
|
262
|
-
that a negative relationship predicate matched. Policy errors propagate rather
|
|
263
|
-
than turning into eligible rows. Memoization is request-local and preserves
|
|
264
|
-
junction multiplicity. Relationships can require many database reads; one public
|
|
265
|
-
query does not imply one physical read. Document accounting is an application
|
|
266
|
-
bound, not a measurement of Convex storage/index/prefetch I/O.
|
|
267
|
-
|
|
268
|
-
Arbitrary async predicates, custom projections/sorts, mutation/action execution,
|
|
269
|
-
search/vector optimization, and global caching are outside this API. Index
|
|
270
|
-
selection reduces candidates where possible; filtering every supported scalar
|
|
271
|
-
field does not guarantee an efficient index for every filter combination.
|
|
272
|
-
|
|
273
|
-
## Migration and verification
|
|
274
|
-
|
|
275
|
-
Use `toPublicDto(row, true)` with `dto(true)` instead of manual ID spreads.
|
|
276
|
-
Replace copied pagination schemas with table/registry helpers. Move eligibility
|
|
277
|
-
predicates before bounded page filling, and replace unbounded relationship
|
|
278
|
-
collection with declared many pages and child continuation. See the diagnostics
|
|
279
|
-
and examples in [oxlint.md](./oxlint.md).
|
|
280
|
-
|
|
281
|
-
The [packed example](../../test/fixture-packed-querying/convex/) contains complete
|
|
282
|
-
schema, relation, engine, contract, Query-registry, auth-adapter, and child-endpoint
|
|
283
|
-
wiring. `bun run test:package:querying` exercises strict installed and minimum-peer
|
|
284
|
-
consumers, all consumer rules, and multi-entity/filter/order continuation tests.
|
|
285
|
-
The local native smoke runner exercises registered Convex validators and cursors;
|
|
286
|
-
these checks do not establish hosted production performance.
|
|
287
|
-
|
|
288
|
-
Run `bunx vp test run test/querying-relations-stress.test.ts --silent=false` to
|
|
289
|
-
report per-page p10, p50, and p99 latency for each filter/order/page-size case,
|
|
290
|
-
plus an aggregate for duplicate child continuations. Reports include sample
|
|
291
|
-
counts and use nearest-rank percentiles. A small sample's p99 is its maximum;
|
|
292
|
-
the measurements include `convex-test` and instrumentation overhead. Read
|
|
293
|
-
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.
|