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.
Files changed (65) hide show
  1. package/AGENTS.md +47 -0
  2. package/CHANGELOG.md +20 -0
  3. package/README.md +144 -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 +45 -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 +39 -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 +129 -25
  57. package/docs/querying.md +293 -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 +296 -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,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 | 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.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
+ }