cvx-kit 0.1.13 → 0.1.14
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/AGENTS.md +47 -0
- package/CHANGELOG.md +20 -0
- package/README.md +144 -0
- package/dist/auth.d.mts +3 -1
- package/dist/auth.mjs +1 -1
- package/dist/auth.mjs.map +1 -1
- package/dist/index.d.mts +5 -2
- package/dist/index.mjs +1 -1
- package/dist/modules/effect/api-zod.d.mts +1 -1
- package/dist/oxlint/architecture.d.mts +2 -0
- package/dist/oxlint/architecture.mjs +1 -1
- package/dist/oxlint/architecture.mjs.map +1 -1
- package/dist/oxlint/ast.d.mts +10 -1
- package/dist/oxlint/ast.mjs +1 -1
- package/dist/oxlint/ast.mjs.map +1 -1
- package/dist/oxlint.d.mts +1 -1
- package/dist/oxlint.mjs +1 -1
- package/dist/oxlint.mjs.map +1 -1
- package/dist/querying/cursors.d.mts +45 -0
- package/dist/querying/cursors.mjs +2 -0
- package/dist/querying/cursors.mjs.map +1 -0
- package/dist/querying/execution.d.mts +30 -0
- package/dist/querying/execution.mjs +2 -0
- package/dist/querying/execution.mjs.map +1 -0
- package/dist/querying/filters.d.mts +136 -0
- package/dist/querying/filters.mjs +2 -0
- package/dist/querying/filters.mjs.map +1 -0
- package/dist/querying/planner.d.mts +50 -0
- package/dist/querying/planner.mjs +2 -0
- package/dist/querying/planner.mjs.map +1 -0
- package/dist/querying/relationships.d.mts +41 -0
- package/dist/querying/relationships.mjs +2 -0
- package/dist/querying/relationships.mjs.map +1 -0
- package/dist/querying/security.d.mts +107 -0
- package/dist/querying/security.mjs +2 -0
- package/dist/querying/security.mjs.map +1 -0
- package/dist/querying.d.mts +39 -0
- package/dist/querying.mjs +2 -0
- package/dist/querying.mjs.map +1 -0
- package/dist/relations-public.d.mts +2 -0
- package/dist/relations-public.mjs +1 -0
- package/dist/relations.d.mts +155 -0
- package/dist/relations.mjs +2 -0
- package/dist/relations.mjs.map +1 -0
- package/dist/zod-convex.d.mts +10 -0
- package/dist/zod-convex.mjs +2 -0
- package/dist/zod-convex.mjs.map +1 -0
- package/dist/zod-registration.d.mts +10 -0
- package/dist/zod-registration.mjs +2 -0
- package/dist/zod-registration.mjs.map +1 -0
- package/dist/zod-table.d.mts +63 -8
- package/dist/zod-table.mjs +1 -1
- package/dist/zod-table.mjs.map +1 -1
- package/docs/conventions.md +12 -0
- package/docs/llms.md +15 -5
- package/docs/oxlint.md +129 -25
- package/docs/querying.md +293 -0
- package/docs/zod-table.md +93 -13
- package/package.json +11 -1
- package/scripts/owned-process.mjs +140 -0
- package/scripts/owned-process.test.mjs +107 -0
- package/scripts/smoke-packed-querying.mjs +296 -0
- package/scripts/test-packed-effect.mjs +8 -1
- package/scripts/test-packed-oxlint.mjs +5 -1
- package/scripts/test-packed-querying.mjs +185 -0
package/docs/querying.md
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
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/zod-table.md
CHANGED
|
@@ -49,19 +49,21 @@ unregistered foreign ID and does not throw; peer installation prevents the
|
|
|
49
49
|
supported package graph from creating that ambiguity. Verify critical ID
|
|
50
50
|
validators retain `kind === 'id'` and the expected `tableName` after installation.
|
|
51
51
|
|
|
52
|
-
| Property | What it is
|
|
53
|
-
| -------------------------------------------- |
|
|
54
|
-
| `table` | `defineTable(...)` from the storage shape
|
|
55
|
-
| `storage` | zod object of the raw row (no `_id`/`_creationTime`)
|
|
56
|
-
| `schema` | `storage` + `_id` + `_creationTime`
|
|
57
|
-
| `insertSchema` | storage minus timestamps minus `serverFields`
|
|
58
|
-
| `updateSchema` | `insertSchema.partial()`
|
|
59
|
-
| `commandInput` | strict pick of `commandFields`
|
|
60
|
-
| `publicDto` | strict pick of `publicFields`
|
|
61
|
-
| `
|
|
62
|
-
| `
|
|
63
|
-
| `
|
|
64
|
-
| `
|
|
52
|
+
| Property | What it is | Where you use it |
|
|
53
|
+
| -------------------------------------------- | ------------------------------------------------------ | ------------------------------ |
|
|
54
|
+
| `table` | `defineTable(...)` from the storage shape | `convex/schema.ts` |
|
|
55
|
+
| `storage` | zod object of the raw row (no `_id`/`_creationTime`) | validating rows |
|
|
56
|
+
| `schema` | `storage` + `_id` + `_creationTime` | full-document validation |
|
|
57
|
+
| `insertSchema` | storage minus timestamps minus `serverFields` | mutation args |
|
|
58
|
+
| `updateSchema` | `insertSchema.partial()` | patch mutation args |
|
|
59
|
+
| `commandInput` | strict pick of `commandFields` | command protocol inputs |
|
|
60
|
+
| `publicDto` | strict pick of `publicFields` | query return validators |
|
|
61
|
+
| `dto(options?)` | public return schema with optional ID and extensions | query return validators |
|
|
62
|
+
| `toPublicDto(row)` | projects **and re-parses** a row into the DTO | query handlers |
|
|
63
|
+
| `paginationOpts(selection)` | native pagination options, selected filters, and order | query input validators |
|
|
64
|
+
| `insert(omit?)` / `update(omit?)` | schema with per-callsite field omission | specialized mutations |
|
|
65
|
+
| `tools.insert` / `tools.update` / `tools.id` | ready-made LLM tool input schemas | agent tool definitions |
|
|
66
|
+
| `tableName` | the literal table name | indexes, triggers registration |
|
|
65
67
|
|
|
66
68
|
## Redaction is runtime, not just types
|
|
67
69
|
|
|
@@ -82,6 +84,84 @@ export const get = authQuery({
|
|
|
82
84
|
})
|
|
83
85
|
```
|
|
84
86
|
|
|
87
|
+
## Public DTO parameters
|
|
88
|
+
|
|
89
|
+
`publicDto` is the strict Zod schema selected by `publicFields`; it takes no
|
|
90
|
+
parameters. Use `dto(...)` to derive a return schema and `toPublicDto(row, ...)`
|
|
91
|
+
to project a row with the same options. These methods are also available on
|
|
92
|
+
`tenantTable` boundaries.
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
documents.toPublicDto(row) // publicFields only
|
|
96
|
+
documents.toPublicDto(row, false) // same default projection
|
|
97
|
+
documents.toPublicDto(row, true) // publicFields plus _id
|
|
98
|
+
documents.toPublicDto(row, { _id: true }) // explicit equivalent
|
|
99
|
+
|
|
100
|
+
const withoutId = documents.dto() // same boundary as publicDto
|
|
101
|
+
const withId = documents.dto(true) // branded Id<'documents'>
|
|
102
|
+
const explicit = documents.dto({ _id: true })
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Including `_id` requires a row containing an ID and validates it through the
|
|
106
|
+
table's ID schema. It does not expose `_creationTime`, tenant metadata, or other
|
|
107
|
+
storage fields. Do not spread an ID onto a parsed DTO manually; use the matching
|
|
108
|
+
`dto(true)` return validator.
|
|
109
|
+
|
|
110
|
+
Computed fields need both a schema and explicitly supplied values:
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
const extend = { url: z.url() }
|
|
114
|
+
const returnSchema = documents.dto({ _id: true, extend })
|
|
115
|
+
|
|
116
|
+
const output = documents.toPublicDto(row, {
|
|
117
|
+
_id: true,
|
|
118
|
+
extend,
|
|
119
|
+
values: { url: `https://example.test/documents/${row._id}` },
|
|
120
|
+
})
|
|
121
|
+
returnSchema.parse(output)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`extend` is a map of field names to Zod validators, not an `extendFields` wrapper.
|
|
125
|
+
`values` belongs only to the projector; `dto(...)` describes the result. Extension
|
|
126
|
+
values are parsed as a strict object: missing required fields, unknown values,
|
|
127
|
+
and invalid values fail. Values are never copied implicitly from storage.
|
|
128
|
+
|
|
129
|
+
Extensions cannot replace public fields or use `_id`, `_creationTime`, `tenant`,
|
|
130
|
+
`createdAt`, `updatedAt`, `archivedAt`, or `__proto__`. Their schemas must validate
|
|
131
|
+
without changing values: transforms, pipes, coercion, preprocess, defaults,
|
|
132
|
+
catches, and overwrites are rejected, including inside nested schemas.
|
|
133
|
+
Refinements must not mutate their input. Existing public-field schemas retain
|
|
134
|
+
their existing parsing behavior.
|
|
135
|
+
|
|
136
|
+
## Pagination options
|
|
137
|
+
|
|
138
|
+
Select filterable scalar fields independently of the public DTO mask:
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
const input = documents.paginationOpts({ title: true, ownerId: true })
|
|
142
|
+
const args = input.parse({
|
|
143
|
+
numItems: 25,
|
|
144
|
+
cursor: null,
|
|
145
|
+
order: 'desc',
|
|
146
|
+
filters: { title: 'Proposal' },
|
|
147
|
+
})
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
The helper extends Convex's native pagination options with optional `filters`
|
|
151
|
+
and `order: 'asc' | 'desc'`. It returns a Zod object that can be used directly as
|
|
152
|
+
query args or extended with endpoint-specific fields. `numItems` must be a
|
|
153
|
+
positive integer at most 100; engine configuration may lower that ceiling.
|
|
154
|
+
Unknown filter fields fail. Stored scalar fields, including explicitly selected
|
|
155
|
+
server-owned fields and IDs, can be filterable even when absent from the DTO.
|
|
156
|
+
Arrays, objects, and other unsupported non-scalar fields cannot be selected.
|
|
157
|
+
This schema creates no authorization policy by itself.
|
|
158
|
+
|
|
159
|
+
For relationship filters and paginated public output, use
|
|
160
|
+
`relations.tables.documents.paginationOpts(selection)` and `.pageDto(selection)`.
|
|
161
|
+
See [querying.md](./querying.md) for declarations, conditional indexes, filters,
|
|
162
|
+
authorization, and child-page continuation. Existing native pagination can keep
|
|
163
|
+
using `paginated(dto)`; the querying engine is independent of `include`.
|
|
164
|
+
|
|
85
165
|
## Opinionated timestamps
|
|
86
166
|
|
|
87
167
|
Every `zodTable` bakes in three optional, **server-owned** fields:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cvx-kit",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.14",
|
|
4
4
|
"description": "Reusable Convex application kit: foundation kernels, approvals component, zod table boundaries, WorkOS-backed auth function constructors, and audited command protocol.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"component",
|
|
@@ -30,6 +30,14 @@
|
|
|
30
30
|
"types": "./dist/zod-table.d.mts",
|
|
31
31
|
"import": "./dist/zod-table.mjs"
|
|
32
32
|
},
|
|
33
|
+
"./relations": {
|
|
34
|
+
"types": "./dist/relations-public.d.mts",
|
|
35
|
+
"import": "./dist/relations-public.mjs"
|
|
36
|
+
},
|
|
37
|
+
"./querying": {
|
|
38
|
+
"types": "./dist/querying.d.mts",
|
|
39
|
+
"import": "./dist/querying.mjs"
|
|
40
|
+
},
|
|
33
41
|
"./effect": {
|
|
34
42
|
"types": "./dist/effect.d.mts",
|
|
35
43
|
"import": "./dist/effect.mjs"
|
|
@@ -134,6 +142,7 @@
|
|
|
134
142
|
"test:package:oxlint": "bun run build && node scripts/test-packed-oxlint.mjs",
|
|
135
143
|
"test:package:effect": "bun run build && node scripts/test-packed-effect.mjs",
|
|
136
144
|
"test:package:auth": "bun run build && node scripts/test-packed-auth.mjs",
|
|
145
|
+
"test:package:querying": "bun run build && node scripts/test-packed-querying.mjs",
|
|
137
146
|
"test:package:smoke:effect": "bun run build && node scripts/smoke-packed-effect.mjs",
|
|
138
147
|
"test:package:smoke:bun": "bun scripts/smoke-packed-components.mjs bun",
|
|
139
148
|
"test:package:smoke:npm": "node scripts/smoke-packed-components.mjs npm",
|
|
@@ -145,6 +154,7 @@
|
|
|
145
154
|
},
|
|
146
155
|
"dependencies": {
|
|
147
156
|
"@convex-dev/workflow": "0.4.10",
|
|
157
|
+
"@noble/ciphers": "2.4.0",
|
|
148
158
|
"@oxlint/plugins": "1.87.0",
|
|
149
159
|
"@standard-schema/spec": "^1.1.0",
|
|
150
160
|
"convex-audit-log": "^0.2.0",
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { setTimeout as delay } from 'node:timers/promises'
|
|
3
|
+
|
|
4
|
+
export function processGroupAlive(pid) {
|
|
5
|
+
try {
|
|
6
|
+
process.kill(-pid, 0)
|
|
7
|
+
return true
|
|
8
|
+
} catch (error) {
|
|
9
|
+
if (error.code === 'ESRCH') return false
|
|
10
|
+
throw error
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
const owned = new Set()
|
|
14
|
+
export async function cleanupOwnedProcess(pid, graceMs = 1000) {
|
|
15
|
+
if (!owned.has(pid)) throw new Error('Process group is not owned by this runner')
|
|
16
|
+
const signals = [],
|
|
17
|
+
errors = []
|
|
18
|
+
for (const signal of ['SIGINT', 'SIGTERM', 'SIGKILL']) {
|
|
19
|
+
if (!processGroupAlive(pid)) break
|
|
20
|
+
try {
|
|
21
|
+
process.kill(-pid, signal)
|
|
22
|
+
signals.push(signal)
|
|
23
|
+
} catch (error) {
|
|
24
|
+
if (error.code !== 'ESRCH') errors.push({ signal, code: error.code })
|
|
25
|
+
}
|
|
26
|
+
const deadline = performance.now() + graceMs
|
|
27
|
+
while (processGroupAlive(pid) && performance.now() < deadline) await delay(20)
|
|
28
|
+
}
|
|
29
|
+
return { pid, groupId: pid, signals, errors, alive: processGroupAlive(pid) }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function runOwnedProcess({
|
|
33
|
+
command,
|
|
34
|
+
args,
|
|
35
|
+
cwd,
|
|
36
|
+
env,
|
|
37
|
+
timeoutMs = 180000,
|
|
38
|
+
graceMs = 1000,
|
|
39
|
+
onSpawn = () => {},
|
|
40
|
+
}) {
|
|
41
|
+
const started = performance.now()
|
|
42
|
+
const child = spawn(command, args, { cwd, env, detached: true, stdio: ['pipe', 'pipe', 'pipe'] })
|
|
43
|
+
let stdout = '',
|
|
44
|
+
stderr = '',
|
|
45
|
+
timedOut = false,
|
|
46
|
+
spawnError,
|
|
47
|
+
timeoutCleanup
|
|
48
|
+
child.stdout.on('data', (bytes) => {
|
|
49
|
+
stdout += bytes
|
|
50
|
+
})
|
|
51
|
+
child.stderr.on('data', (bytes) => {
|
|
52
|
+
stderr += bytes
|
|
53
|
+
})
|
|
54
|
+
const closed = new Promise((resolve) => {
|
|
55
|
+
child.once('error', (error) => {
|
|
56
|
+
spawnError = { code: error.code, message: error.message }
|
|
57
|
+
resolve({ code: null, signal: null })
|
|
58
|
+
})
|
|
59
|
+
child.once('close', (code, signal) => resolve({ code, signal }))
|
|
60
|
+
})
|
|
61
|
+
if (child.pid) {
|
|
62
|
+
owned.add(child.pid)
|
|
63
|
+
onSpawn(child.pid)
|
|
64
|
+
}
|
|
65
|
+
child.stdin.end()
|
|
66
|
+
let finishTimeout
|
|
67
|
+
const timeoutFinished = new Promise((resolve) => {
|
|
68
|
+
finishTimeout = resolve
|
|
69
|
+
})
|
|
70
|
+
const timer = setTimeout(() => {
|
|
71
|
+
timedOut = true
|
|
72
|
+
timeoutCleanup = child.pid
|
|
73
|
+
? cleanupOwnedProcess(child.pid, graceMs).catch((error) => ({
|
|
74
|
+
pid: child.pid,
|
|
75
|
+
alive: true,
|
|
76
|
+
error: { code: error.code, message: error.message },
|
|
77
|
+
}))
|
|
78
|
+
: Promise.resolve(null)
|
|
79
|
+
timeoutCleanup.then(() => finishTimeout({ code: child.exitCode, signal: child.signalCode }))
|
|
80
|
+
}, timeoutMs)
|
|
81
|
+
let exit, cleanup
|
|
82
|
+
try {
|
|
83
|
+
exit = await Promise.race([closed, timeoutFinished])
|
|
84
|
+
if (timeoutCleanup) await timeoutCleanup
|
|
85
|
+
} finally {
|
|
86
|
+
clearTimeout(timer)
|
|
87
|
+
if (child.pid) {
|
|
88
|
+
try {
|
|
89
|
+
cleanup = await cleanupOwnedProcess(child.pid, graceMs)
|
|
90
|
+
} catch (error) {
|
|
91
|
+
cleanup = {
|
|
92
|
+
pid: child.pid,
|
|
93
|
+
alive: true,
|
|
94
|
+
error: { code: error.code, message: error.message },
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (timedOut) {
|
|
100
|
+
child.stdout.destroy()
|
|
101
|
+
child.stderr.destroy()
|
|
102
|
+
child.unref()
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
...exit,
|
|
106
|
+
spawnError,
|
|
107
|
+
stdout,
|
|
108
|
+
stderr,
|
|
109
|
+
timedOut,
|
|
110
|
+
pid: child.pid,
|
|
111
|
+
milliseconds: performance.now() - started,
|
|
112
|
+
timeoutCleanup: await timeoutCleanup,
|
|
113
|
+
cleanup,
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function collectOwnedCleanup({ pids, ports, checkPort, failure, graceMs = 1000 }) {
|
|
118
|
+
const groups = []
|
|
119
|
+
for (const pid of pids) {
|
|
120
|
+
try {
|
|
121
|
+
groups.push(await cleanupOwnedProcess(pid, graceMs))
|
|
122
|
+
} catch (error) {
|
|
123
|
+
groups.push({ pid, alive: true, error: { code: error.code, message: error.message } })
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const portResults = []
|
|
127
|
+
for (const port of ports) {
|
|
128
|
+
try {
|
|
129
|
+
portResults.push({ port, closed: (await checkPort(port)) === port })
|
|
130
|
+
} catch (error) {
|
|
131
|
+
portResults.push({ port, closed: false, error: { code: error.code, message: error.message } })
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
pids: groups.map(({ pid, alive }) => ({ pid, alive })),
|
|
136
|
+
failure: failure ? { name: failure.name, message: failure.message } : null,
|
|
137
|
+
groups,
|
|
138
|
+
ports: portResults,
|
|
139
|
+
}
|
|
140
|
+
}
|