cvx-kit 0.1.2 → 0.1.3

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/docs/commands.md CHANGED
@@ -51,9 +51,13 @@ operation** — auditing is type-enforced, not opt-in.
51
51
  import { z } from 'zod'
52
52
  import { Command } from '../../foundation'
53
53
  import { documents } from './schema'
54
+ import { zid } from 'cvx-kit/zod-table'
55
+ import type { MutationCtx } from '../../_generated/server'
56
+
57
+ const typed = Command.withContext<MutationCtx>()
54
58
 
55
59
  const operations = {
56
- 'documents.rename': Command.operation({
60
+ 'documents.rename': typed.operation({
57
61
  command: documents.commandInput.extend({ id: zid('documents') }),
58
62
  result: z.object({ ok: z.literal(true) }).strict(),
59
63
  classification: 'business',
@@ -89,14 +93,20 @@ The public API layer then wraps the executor in an `authMutation` and passes
89
93
  Foundation's injected `checkPermission(ctx, { permission, operation })`
90
94
  runs; throw to deny. Declaring a permission with no injected checker
91
95
  **fails closed** (`COMMAND_PERMISSION_NOT_CONFIGURED`).
92
- 4. **Default guard** — the registry-wide guard passed as the Command's second
96
+ 4. **Prepare**, when configured: obtain an invocation-local replay decision
97
+ or completion closure. Replay validates its durable result, then goes
98
+ directly to observation, skipping middleware, both guards, handler and audit.
99
+ 5. **Middleware and guards** — registry middleware wraps operation middleware,
100
+ which wraps both guards and the handler. The default guard is the
101
+ registry-wide guard passed as the Command's second
93
102
  argument (`new Command(operations, { guard })`), if any.
94
- 5. **Operation guard** — the operation's own `guard(ctx, command)`:
103
+ Then the operation's own `guard(ctx, command)` checks
95
104
  preconditions like state-machine legality, ownership beyond roles, or
96
105
  invariants over the parsed command. Throw to deny — nothing has run yet,
97
106
  so a denial is always clean.
98
- 6. **Run the handler**; its return value is parsed through the `result` schema
99
- — outputs are validated too.
107
+ 6. **Run the handler and unwind middleware**; parse the final chain output
108
+ through the `result` schema exactly once. Middleware sees schema inputs;
109
+ audit, completion, and the executor receive validated schema outputs.
100
110
  7. **Audit**: call the operation's `audit({ command, result }, ctx)`. If it
101
111
  returns non-null, the entry (plus the operation's `classification`) is
102
112
  written through the injected `writeAudit` — **in the same transaction** as
@@ -106,7 +116,9 @@ The public API layer then wraps the executor in an `authMutation` and passes
106
116
  allowlist throws (`COMMAND_AGGREGATE_NOT_DECLARED`) — the audit
107
117
  vocabulary is enforced, not advisory. The per-operation catalog is
108
118
  introspectable as `commands.aggregates` for tests.
109
- 8. **Emit the observation**: `{ operation, classification, outcome,
119
+ 8. **Complete**: await the closure returned by `prepare`, including when audit
120
+ returns null. A failure propagates inside the observation boundary.
121
+ 9. **Emit the observation**: `{ operation, classification, outcome,
110
122
  errorCode?, durationMs }` — completed, denied, or failed per
111
123
  `classifyError`.
112
124
 
@@ -150,11 +162,11 @@ a handler in disguise.
150
162
 
151
163
  ### Middleware — composable, next()-based
152
164
 
153
- For wrap-around concerns (timing, tracing, context enrichment, replay
154
- wrappers) that two disconnected callbacks can't express, both kernels take
165
+ For wrap-around concerns (timing, tracing, context enrichment) that two
166
+ disconnected callbacks can't express, both kernels take
155
167
  Express/TanStack-style middleware. The chain runs **inside** the pipeline's
156
168
  invariants: after the permission check, around [guards → handler], before the
157
- result-schema re-parse, aggregate allowlist, and audit — so middleware can
169
+ result-schema parse, aggregate allowlist, and audit — so middleware can
158
170
  never skip authorization, return an invalid result, or desynchronize audit
159
171
  from effects.
160
172
 
@@ -188,16 +200,119 @@ guard → operation guard → handler. `next()` returns the downstream result;
188
200
  middleware, guards, and the handler. Skipping `next()` short-circuits
189
201
  (guards and handler never run — the returned value still must satisfy the
190
202
  result schema); calling it twice throws `COMMAND_MIDDLEWARE_NEXT_REUSED`.
191
- Whatever leaves the chain is re-parsed through the operation's strict result
203
+ Whatever leaves the chain is parsed through the operation's strict result
192
204
  schema — a middleware cannot fabricate an invalid result.
193
205
 
206
+ ### Schema-inferred callbacks and context extensions (0.1.3)
207
+
208
+ `Command.withContext<Ctx>()` binds the host context for operation definitions.
209
+ Its `operation()` infers parsed command fields, validated audit result fields,
210
+ and the declared aggregate vocabulary. Operation middleware is contextual:
211
+ write inline callbacks without `Command.middleware`, whose legacy low-level
212
+ command and result boundary intentionally remains unknown.
213
+
214
+ ```ts
215
+ const typed = Command.withContext<Ctx>()
216
+ const operations = {
217
+ rename: typed.operation({
218
+ command: z.object({ title: z.string().transform(value => value.length) }),
219
+ result: z.object({ ok: z.boolean() }),
220
+ classification: 'business',
221
+ aggregates: ['document'],
222
+ guard: (ctx, command) => requireTitleLength(ctx, command.title), // number
223
+ middleware: [async ({ command, next }) => {
224
+ const result = await next() // { ok: boolean }, before validation
225
+ logLength(command.title)
226
+ return result
227
+ }],
228
+ audit: ({ command, result }, ctx) => ({
229
+ operation: 'rename', actorId: ctx.actorId,
230
+ aggregate: { type: 'document', id: String(command.title) },
231
+ metadata: { ok: result.ok },
232
+ }),
233
+ }),
234
+ }
235
+ const middleware = typed.registryMiddleware(operations, async input => {
236
+ if (input.operation === 'rename') {
237
+ input.command.title.toFixed() // discriminator retains schema correlation
238
+ }
239
+ return input.next()
240
+ })
241
+ const commands = new Command<Ctx, typeof operations>(operations, {
242
+ middleware: [middleware],
243
+ })
244
+ ```
245
+
246
+ Bind registry middleware to the same registry it describes. A heterogeneous
247
+ registry callback returns a union; narrowing `input.operation` preserves
248
+ the matching `input.command` and `input.next()` result type.
249
+ Reusing it with a different operation definition throws
250
+ `COMMAND_MIDDLEWARE_REGISTRY_MISMATCH` before the typed callback runs.
251
+
252
+ To propagate a required middleware extension to guards and handlers, use
253
+ `Command.withContext<Ctx, { traceId: string }>()`. Its operations require a
254
+ nonempty middleware tuple; the first layer must pass the declared extension
255
+ to `next({ context: { traceId } })` whenever it runs the downstream chain.
256
+ Later layers, guards and handlers see `Ctx & { traceId: string }`.
257
+ Audit and prepare receive the original context. The original context object
258
+ is not mutated. Short circuits still undergo final result validation.
259
+
260
+ ### Transactional completion and replay (0.1.3)
261
+
262
+ Use `prepare(ctx, parsedCommand)` for host-owned idempotency. It runs after
263
+ permission and returns either `{ kind: 'replay', result: durableValue }` or
264
+ `{ kind: 'execute', complete: async validatedResult => { ... } }`.
265
+ The completion closure captures per-invocation state, including a fingerprint
266
+ derived from the parsed command and authenticated actor. Do not cache that
267
+ state in a shared context or module variable.
268
+
269
+ ```ts
270
+ prepare: async (ctx, command) => {
271
+ const key = scopedKey(ctx.actorId, command.idempotencyKey)
272
+ const fingerprint = fingerprintOf(command)
273
+ const receipt = await loadReceipt(ctx, key)
274
+ if (receipt) {
275
+ if (receipt.fingerprint !== fingerprint) throw new Error('KEY_REUSED')
276
+ return { kind: 'replay', result: receipt.result }
277
+ }
278
+ return {
279
+ kind: 'execute',
280
+ complete: async result => {
281
+ await saveReceipt(ctx, { key, fingerprint, result })
282
+ },
283
+ }
284
+ },
285
+ ```
286
+
287
+ Storage, actor/tenant scoping, fingerprint comparison, retention, and conflict
288
+ policy remain host responsibilities. Put authorization required on replay in
289
+ the injected permission checker or prepare lookup; both guards are skipped
290
+ on replay. A permission slug without a checker still fails closed. Ordinary
291
+ middleware short-circuiting is not this replay path and still audits.
292
+
293
+ Replays are parsed through `result` by default. If that schema transforms its
294
+ input, store the completion callback's validated output and supply
295
+ `replayResult` with an output validator (for example `z.number().int()` when
296
+ `result` transforms a string into a number). This prevents reapplying an input
297
+ transform to an already transformed durable result. An invalid replay fails
298
+ inside observation; successful replay does not invoke completion again.
299
+
300
+ Completion is awaited after result validation, audit resolution, aggregate
301
+ validation, and audit writing, even on audit-null paths. Errors from prepare,
302
+ replay validation, audit, or completion propagate and are passed to the host's
303
+ error classifier. Observation success means this command finished; the
304
+ enclosing mutation has not necessarily committed yet.
305
+
194
306
  The Query kernel takes the same shape: `new Query({ defaults, middleware,
195
307
  execute })` plus per-executor `middleware: [...]`, with `Query.middleware`
196
308
  as the typing helper. Kernel middleware runs before executor middleware,
197
309
  inside the host's injected `execute` policy.
198
310
 
199
- If any step throws, the Convex transaction rolls back — handler writes and
200
- audit entry together. Audit and effects can never disagree.
311
+ For atomic rollback, let failures escape the enclosing Convex mutation.
312
+ Catching and swallowing a failure inside that mutation can commit earlier
313
+ business, audit, and completion writes. Rethrow after effects, or use the
314
+ effect-aware `executeResultBoundary` below. External I/O in actions is not
315
+ rolled back; these transaction guarantees concern Convex mutation writes.
201
316
 
202
317
  ### Dynamic dispatch
203
318
 
@@ -269,9 +384,9 @@ knowing. Metadata defaults are set at construction and merged per-executor.
269
384
  else destructures from it.
270
385
  2. Never deep-import the component's `modules/*` or `result.ts` paths from app
271
386
  code — the `client.ts` facade is the contract.
272
- 3. The audit callback receives the ctx untyped (`never`); wiring a query ctx
273
- into an audited command fails at runtime, not compile time. Commands run in
274
- mutations.
387
+ 3. Use `Command.withContext<MutationCtx>()` for typed callbacks and construct
388
+ the Command with a compatible host context. Legacy `Command.operation`
389
+ keeps its low-level callback signature. Commands with writes run in mutations.
275
390
  4. Name operations `domain.verb` and error codes `UPPER_SNAKE` or your
276
391
  telemetry silently disappears (see regexes above).
277
392
  5. Keep operation registries frozen (`as const`) in the domain's
package/docs/upgrading.md CHANGED
@@ -1,5 +1,47 @@
1
1
  # Upgrading to 0.1.0 — the new way of things
2
2
 
3
+ ## 0.1.3
4
+
5
+ Install `convex-helpers` as a direct peer, using one compatible instance:
6
+
7
+ ```sh
8
+ bun add cvx-kit@0.1.3 convex-helpers@0.1.124
9
+ ```
10
+
11
+ The peer range is `>=0.1.123 <0.2.0`. Remove overrides that force a
12
+ separate helper copy and regenerate the installation without discarding
13
+ unrelated dependency choices. This release is validated with Convex 1.45.0,
14
+ Zod 4.5.4, and convex-test 0.0.56.
15
+
16
+ Existing `Command.operation` and `Command.middleware` remain available.
17
+ For callback inference, use `Command.withContext<MutationCtx>().operation`;
18
+ see `commands.md` for typed middleware and transactional completion.
19
+
20
+ Result transforms now run once, after middleware. A handler and middleware
21
+ return the result schema's **input** type; audit, completion, and the executor
22
+ receive its **output**. Middleware reading `await next()` sees the unparsed
23
+ handler result. Remove workarounds that compensate for double transforms.
24
+ When storing transformed outputs for replay, supply an output validator as
25
+ `replayResult`; otherwise replay uses the ordinary result schema.
26
+ Executor callers supply the command schema's input type; guards, prepare,
27
+ middleware, and handlers receive its parsed output. For example, a string
28
+ transformed to a number is passed as a string to the executor and observed
29
+ as a number by its callbacks.
30
+
31
+ Test registration no longer needs a registrar adapter or assertion:
32
+
33
+ ```ts
34
+ import { convexTest } from 'convex-test'
35
+ import { registerFoundation, registerApprovals } from 'cvx-kit/test'
36
+ import schema from './schema'
37
+
38
+ const t = convexTest(schema, import.meta.glob('./**/*.ts'))
39
+ registerFoundation(t)
40
+ registerApprovals(t)
41
+ ```
42
+
43
+ Both helpers accept an optional custom component path as their second argument.
44
+
3
45
  ## Approvals packaging repair (0.1.x patch)
4
46
 
5
47
  `cvx-kit@0.1.0` published component schemas as `schema.mjs`, which Convex does
package/docs/zod-table.md CHANGED
@@ -27,6 +27,28 @@ export const documents = zodTable(
27
27
 
28
28
  ## What you get back
29
29
 
30
+ ### Shared ID validation (0.1.3)
31
+
32
+ Install one compatible `convex-helpers` peer (`>=0.1.123 <0.2.0`) alongside
33
+ cvx-kit. ID metadata lives in a helper-instance registry: copying schemas
34
+ between separately installed helper instances can silently turn an ID into
35
+ `v.any()`. An exact nested dependency previously caused that failure even
36
+ when both helper versions were compatible. Fresh npm and Bun consumer tests
37
+ now exercise storage, command, DTO, and nested ID conversion with a different
38
+ compatible host version.
39
+
40
+ Prefer the matching kit exports when deriving validators from kit schemas:
41
+
42
+ ```ts
43
+ import { zid, zodToConvex, convexToZod } from 'cvx-kit/zod-table'
44
+ ```
45
+
46
+ Do not force conflicting nested helper copies with overrides or reuse schema
47
+ objects across separate bundles. The upstream converter cannot recognize an
48
+ unregistered foreign ID and does not throw; peer installation prevents the
49
+ supported package graph from creating that ambiguity. Verify critical ID
50
+ validators retain `kind === 'id'` and the expected `tableName` after installation.
51
+
30
52
  | Property | What it is | Where you use it |
31
53
  |---|---|---|
32
54
  | `table` | `defineTable(...)` from the storage shape | `convex/schema.ts` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cvx-kit",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
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
  "type": "module",
6
6
  "license": "MIT",
@@ -92,6 +92,7 @@
92
92
  "typecheck": "tsc --noEmit",
93
93
  "test": "vp test run --exclude test/package-contents.test.mjs",
94
94
  "test:package": "bun run build && vp test run test/package-contents.test.mjs",
95
+ "test:package:ids": "bun run build && node scripts/test-packed-ids.mjs",
95
96
  "test:package:smoke:bun": "bun scripts/smoke-packed-components.mjs bun",
96
97
  "test:package:smoke:npm": "node scripts/smoke-packed-components.mjs npm",
97
98
  "docs:sync": "rm -f docs/*.md && cp src/docs/*.md docs/",
@@ -101,22 +102,23 @@
101
102
  "release:minor": "npm version minor && git push --follow-tags"
102
103
  },
103
104
  "dependencies": {
104
- "@convex-dev/workflow": "0.4.4",
105
- "convex-audit-log": "^0.2.0",
106
- "convex-helpers": "0.1.122"
105
+ "@convex-dev/workflow": "0.4.6",
106
+ "convex-audit-log": "^0.2.0"
107
107
  },
108
108
  "peerDependencies": {
109
- "convex": "^1.43.0",
110
- "zod": "^4.0.0"
109
+ "convex-helpers": ">=0.1.123 <0.2.0",
110
+ "convex": "^1.45.0",
111
+ "zod": "^4.5.4"
111
112
  },
112
113
  "devDependencies": {
114
+ "convex-helpers": "0.1.124",
113
115
  "@edge-runtime/vm": "^5.0.0",
114
- "convex": "1.43.0",
115
- "convex-test": "^0.0.55",
116
- "publint": "^0.3.23",
116
+ "convex": "1.45.0",
117
+ "convex-test": "^0.0.56",
118
+ "publint": "^0.3.24",
117
119
  "typescript": "7.0.2",
118
- "vite-plus": "0.2.8",
119
- "zod": "4.4.3"
120
+ "vite-plus": "0.3.0",
121
+ "zod": "4.5.4"
120
122
  },
121
123
  "keywords": [
122
124
  "convex",
@@ -11,7 +11,7 @@ if (!version) {
11
11
  }
12
12
  const changelog = readFileSync(new URL('../CHANGELOG.md', import.meta.url), 'utf8')
13
13
  const pattern = new RegExp(
14
- String.raw`^## \[${version.replace(/\./g, String.raw`\.`)}\][^\n]*\n([\s\S]*?)(?=^## \[|\Z)`,
14
+ String.raw`^## \[${version.replace(/\./g, String.raw`\.`)}\][^\n]*\n([\s\S]*?)(?=^## \[|(?![\s\S]))`,
15
15
  'm',
16
16
  )
17
17
  const match = changelog.match(pattern)
@@ -18,9 +18,10 @@ if (installer !== 'bun' && installer !== 'npm') {
18
18
  const root = join(import.meta.dirname, '..')
19
19
  const temporaryRoot = mkdtempSync(join(tmpdir(), 'cvx-kit-smoke-'))
20
20
  const fixture = join(temporaryRoot, 'fixture')
21
- const packageVersion = JSON.parse(
21
+ const packageManifest = JSON.parse(
22
22
  readFileSync(join(root, 'package.json'), 'utf8'),
23
- ).version
23
+ )
24
+ const packageVersion = packageManifest.version
24
25
  const tarball = join(temporaryRoot, `cvx-kit-${packageVersion}.tgz`)
25
26
 
26
27
  function run(command, args, cwd = fixture) {
@@ -72,11 +73,12 @@ try {
72
73
  type: 'module',
73
74
  dependencies: {
74
75
  'cvx-kit': `file:${tarball}`,
75
- convex: '1.43.0',
76
- zod: '4.4.3',
76
+ convex: packageManifest.devDependencies.convex,
77
+ zod: packageManifest.devDependencies.zod,
78
+ 'convex-helpers': packageManifest.devDependencies['convex-helpers'],
77
79
  },
78
80
  devDependencies: {
79
- 'vite-plus': '0.2.8',
81
+ 'vite-plus': packageManifest.devDependencies['vite-plus'],
80
82
  },
81
83
  },
82
84
  null,
@@ -327,27 +329,26 @@ export const status = query({
327
329
  )
328
330
  }
329
331
 
330
- let decision
331
- let lastError
332
- for (let attempt = 0; attempt < 10; attempt += 1) {
333
- try {
334
- decision = JSON.parse(
335
- runConvex('run', 'smoke:decide', JSON.stringify({ runId })),
336
- )
337
- break
338
- } catch (error) {
339
- lastError = error
340
- const currentStatus = JSON.parse(
341
- runConvex('run', 'smoke:status', JSON.stringify({ runId })),
342
- )
343
- if (currentStatus.state === 'approved') {
344
- decision = currentStatus
345
- break
346
- }
347
- await new Promise((resolve) => setTimeout(resolve, 500))
332
+ // start schedules the workflow; a successful start is not decision readiness.
333
+ let readyStatus
334
+ const readyDeadline = Date.now() + 60_000
335
+ while (Date.now() < readyDeadline) {
336
+ readyStatus = JSON.parse(
337
+ runConvex('run', 'smoke:status', JSON.stringify({ runId })),
338
+ )
339
+ if (readyStatus?.run.state !== 'pending' ||
340
+ ['failed', 'canceled', 'completed'].includes(readyStatus?.execution?.type)) {
341
+ throw new Error(`Approval ended before decision readiness: ${JSON.stringify(readyStatus)}`)
348
342
  }
343
+ if (readyStatus.run.currentStepKey === 'releaseDecision') break
344
+ await new Promise((resolve) => setTimeout(resolve, 500))
349
345
  }
350
- if (!decision) throw lastError
346
+ if (readyStatus?.run.currentStepKey !== 'releaseDecision') {
347
+ throw new Error(`Approval did not become decision-ready: ${JSON.stringify(readyStatus)}`)
348
+ }
349
+ const decision = JSON.parse(
350
+ runConvex('run', 'smoke:decide', JSON.stringify({ runId })),
351
+ )
351
352
  if (decision.state !== 'approved') {
352
353
  throw new Error(`Unexpected decision response: ${JSON.stringify(decision)}`)
353
354
  }
@@ -0,0 +1,157 @@
1
+ import { execFileSync } from 'node:child_process'
2
+ import {
3
+ mkdtempSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ rmSync,
7
+ writeFileSync,
8
+ } from 'node:fs'
9
+ import { tmpdir } from 'node:os'
10
+ import { join } from 'node:path'
11
+
12
+ const root = join(import.meta.dirname, '..')
13
+ const temporary = mkdtempSync(join(tmpdir(), 'cvx-kit-ids-'))
14
+ const manifest = JSON.parse(
15
+ readFileSync(join(root, 'package.json'), 'utf8'),
16
+ )
17
+ function run(command, args, cwd) {
18
+ return execFileSync(command, args, {
19
+ cwd,
20
+ encoding: 'utf8',
21
+ stdio: 'pipe',
22
+ })
23
+ }
24
+ try {
25
+ run(
26
+ 'bun',
27
+ ['pm', 'pack', '--destination', temporary, '--ignore-scripts'],
28
+ root,
29
+ )
30
+ for (const installer of ['npm', 'bun']) {
31
+ const fixture = join(temporary, installer)
32
+ mkdirSync(fixture)
33
+ writeFileSync(
34
+ join(fixture, 'package.json'),
35
+ JSON.stringify({
36
+ private: true,
37
+ type: 'module',
38
+ dependencies: {
39
+ 'cvx-kit': `file:${join(temporary, `cvx-kit-${manifest.version}.tgz`)}`,
40
+ 'convex-helpers': '0.1.123',
41
+ convex: manifest.devDependencies.convex,
42
+ zod: manifest.devDependencies.zod,
43
+ },
44
+ devDependencies: {
45
+ 'convex-test': manifest.devDependencies['convex-test'],
46
+ 'vite-plus': manifest.devDependencies['vite-plus'],
47
+ '@edge-runtime/vm': manifest.devDependencies['@edge-runtime/vm'],
48
+ },
49
+ }),
50
+ )
51
+ run(installer, ['install', '--ignore-scripts'], fixture)
52
+ writeFileSync(
53
+ join(fixture, 'probe.mjs'),
54
+ `
55
+ import { strict as assert } from 'node:assert'
56
+ import { tenantTable, zodToConvex as kitConverter, zid as kitId } from 'cvx-kit/zod-table'
57
+ import { zid, zodToConvex } from 'convex-helpers/server/zod4'
58
+ import { z } from 'zod'
59
+ const table = tenantTable('children', id => ({
60
+ parentId: id('parents'), nested: z.object({ parentId: id('parents') }),
61
+ }), { commandFields: ['parentId', 'nested'], publicFields: ['parentId', 'nested'] })
62
+ for (const validator of [
63
+ zodToConvex(zid('parents')), table.table.validator.fields.parentId,
64
+ zodToConvex(table.storage).fields.parentId,
65
+ zodToConvex(table.commandInput).fields.parentId,
66
+ zodToConvex(table.publicDto).fields.parentId,
67
+ zodToConvex(table.storage).fields.nested.fields.parentId,
68
+ kitConverter(kitId('parents')),
69
+ kitConverter(table.commandInput).fields.nested.fields.parentId,
70
+ kitConverter(table.publicDto).fields.nested.fields.parentId,
71
+ ]) {
72
+ assert.equal(validator.kind, 'id', 'exported schema lost ID validation')
73
+ assert.equal(validator.tableName, 'parents')
74
+ }
75
+ `,
76
+ )
77
+ run('node', ['probe.mjs'], fixture)
78
+ for (const testFile of [
79
+ 'command.test.ts',
80
+ 'command-lifecycle.test.ts',
81
+ ]) {
82
+ const source = readFileSync(join(root, 'test', testFile), 'utf8')
83
+ .replace(
84
+ '../src/components/foundation/client',
85
+ 'cvx-kit/components/foundation',
86
+ )
87
+ .replace(
88
+ "const modules = import.meta.glob('./fixture/**/*.ts')",
89
+ "const modules = { './_generated/server.ts': async () => ({}) }",
90
+ )
91
+ writeFileSync(join(fixture, testFile), source)
92
+ }
93
+ writeFileSync(
94
+ join(fixture, 'command-types.ts'),
95
+ readFileSync(join(root, 'test/command-types.ts'), 'utf8').replace(
96
+ '../src/components/foundation/client',
97
+ 'cvx-kit/components/foundation',
98
+ ),
99
+ )
100
+ writeFileSync(
101
+ join(fixture, 'registration.test.ts'),
102
+ `
103
+ import { convexTest } from 'convex-test'
104
+ import { defineSchema, defineTable } from 'convex/server'
105
+ import { v } from 'convex/values'
106
+ import { registerFoundation, registerApprovals } from 'cvx-kit/test'
107
+ import { it } from 'vite-plus/test'
108
+ it('registers concrete and generic backends from the packed package', () => {
109
+ const modules = { './_generated/server.ts': async () => ({}) }
110
+ const concrete = convexTest(defineSchema({ items: defineTable({ name: v.string() }) }), modules)
111
+ const generic = convexTest(undefined, modules)
112
+ for (const backend of [concrete, generic]) {
113
+ registerFoundation(backend)
114
+ registerApprovals(backend)
115
+ registerFoundation(backend, 'customFoundation')
116
+ registerApprovals(backend, 'customApprovals')
117
+ }
118
+ })
119
+ `,
120
+ )
121
+ run(
122
+ join(root, 'node_modules/.bin/tsc'),
123
+ [
124
+ '--noEmit',
125
+ '--strict',
126
+ '--skipLibCheck',
127
+ '--target',
128
+ 'es2025',
129
+ '--module',
130
+ 'esnext',
131
+ '--moduleResolution',
132
+ 'bundler',
133
+ '--types',
134
+ 'vite/client',
135
+ 'command-types.ts',
136
+ 'registration.test.ts',
137
+ ],
138
+ fixture,
139
+ )
140
+ run(
141
+ join(fixture, 'node_modules/.bin/vp'),
142
+ [
143
+ 'test',
144
+ 'run',
145
+ 'registration.test.ts',
146
+ 'command.test.ts',
147
+ 'command-lifecycle.test.ts',
148
+ ],
149
+ fixture,
150
+ )
151
+ console.log(
152
+ `${installer}: packed IDs, strict declarations, registration, and command lifecycle pass with host convex-helpers 0.1.123`,
153
+ )
154
+ }
155
+ } finally {
156
+ rmSync(temporary, { recursive: true, force: true })
157
+ }
package/src/test.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  * registers the COMPILED component modules from dist, so dist is the only
7
7
  * code the package ships.
8
8
  */
9
+ import type { GenericSchema, SchemaDefinition } from 'convex/server'
9
10
  import approvalsSchema from '../dist/components/approvals/schema.js'
10
11
  import foundationSchema from '../dist/components/foundation/schema.js'
11
12
 
@@ -19,7 +20,7 @@ const approvalsModules = import.meta.glob(
19
20
  type RegistersComponents = {
20
21
  registerComponent: (
21
22
  name: string,
22
- schema: unknown,
23
+ schema: SchemaDefinition<GenericSchema, boolean>,
23
24
  modules: Record<string, () => Promise<unknown>>,
24
25
  ) => void
25
26
  }