cvx-kit 0.1.13 → 0.1.15

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 (65) hide show
  1. package/AGENTS.md +47 -0
  2. package/CHANGELOG.md +33 -0
  3. package/README.md +142 -0
  4. package/dist/auth.d.mts +3 -1
  5. package/dist/auth.mjs +1 -1
  6. package/dist/auth.mjs.map +1 -1
  7. package/dist/index.d.mts +5 -2
  8. package/dist/index.mjs +1 -1
  9. package/dist/modules/effect/api-zod.d.mts +1 -1
  10. package/dist/oxlint/architecture.d.mts +2 -0
  11. package/dist/oxlint/architecture.mjs +1 -1
  12. package/dist/oxlint/architecture.mjs.map +1 -1
  13. package/dist/oxlint/ast.d.mts +10 -1
  14. package/dist/oxlint/ast.mjs +1 -1
  15. package/dist/oxlint/ast.mjs.map +1 -1
  16. package/dist/oxlint.d.mts +1 -1
  17. package/dist/oxlint.mjs +1 -1
  18. package/dist/oxlint.mjs.map +1 -1
  19. package/dist/querying/cursors.d.mts +50 -0
  20. package/dist/querying/cursors.mjs +2 -0
  21. package/dist/querying/cursors.mjs.map +1 -0
  22. package/dist/querying/execution.d.mts +30 -0
  23. package/dist/querying/execution.mjs +2 -0
  24. package/dist/querying/execution.mjs.map +1 -0
  25. package/dist/querying/filters.d.mts +136 -0
  26. package/dist/querying/filters.mjs +2 -0
  27. package/dist/querying/filters.mjs.map +1 -0
  28. package/dist/querying/planner.d.mts +50 -0
  29. package/dist/querying/planner.mjs +2 -0
  30. package/dist/querying/planner.mjs.map +1 -0
  31. package/dist/querying/relationships.d.mts +41 -0
  32. package/dist/querying/relationships.mjs +2 -0
  33. package/dist/querying/relationships.mjs.map +1 -0
  34. package/dist/querying/security.d.mts +107 -0
  35. package/dist/querying/security.mjs +2 -0
  36. package/dist/querying/security.mjs.map +1 -0
  37. package/dist/querying.d.mts +53 -0
  38. package/dist/querying.mjs +2 -0
  39. package/dist/querying.mjs.map +1 -0
  40. package/dist/relations-public.d.mts +2 -0
  41. package/dist/relations-public.mjs +1 -0
  42. package/dist/relations.d.mts +155 -0
  43. package/dist/relations.mjs +2 -0
  44. package/dist/relations.mjs.map +1 -0
  45. package/dist/zod-convex.d.mts +10 -0
  46. package/dist/zod-convex.mjs +2 -0
  47. package/dist/zod-convex.mjs.map +1 -0
  48. package/dist/zod-registration.d.mts +10 -0
  49. package/dist/zod-registration.mjs +2 -0
  50. package/dist/zod-registration.mjs.map +1 -0
  51. package/dist/zod-table.d.mts +63 -8
  52. package/dist/zod-table.mjs +1 -1
  53. package/dist/zod-table.mjs.map +1 -1
  54. package/docs/conventions.md +12 -0
  55. package/docs/llms.md +15 -5
  56. package/docs/oxlint.md +135 -25
  57. package/docs/querying.md +329 -0
  58. package/docs/zod-table.md +93 -13
  59. package/package.json +11 -1
  60. package/scripts/owned-process.mjs +140 -0
  61. package/scripts/owned-process.test.mjs +107 -0
  62. package/scripts/smoke-packed-querying.mjs +298 -0
  63. package/scripts/test-packed-effect.mjs +8 -1
  64. package/scripts/test-packed-oxlint.mjs +5 -1
  65. package/scripts/test-packed-querying.mjs +185 -0
@@ -0,0 +1,329 @@
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/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 | 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
- | `toPublicDto(row)` | projects **and re-parses** a row into the DTO | query handlers |
62
- | `insert(omit?)` / `update(omit?)` | schema with per-callsite field omission | specialized mutations |
63
- | `tools.insert` / `tools.update` / `tools.id` | ready-made LLM tool input schemas | agent tool definitions |
64
- | `tableName` | the literal table name | indexes, triggers registration |
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.13",
3
+ "version": "0.1.15",
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
+ }