cvx-kit 0.1.6 → 0.1.8
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/CHANGELOG.md +23 -0
- package/README.md +63 -38
- package/dist/agent-tools.d.mts +2 -1
- package/dist/agent-tools.mjs +1 -1
- package/dist/agent-tools.mjs.map +1 -1
- package/dist/components/approvals/audit.mjs +1 -1
- package/dist/components/foundation/client.d.mts +16 -7
- package/dist/components/foundation/client.mjs +1 -1
- package/dist/components/foundation/client.mjs.map +1 -1
- package/dist/components/foundation/modules/command/command.d.mts +50 -10
- package/dist/components/foundation/modules/command/command.mjs +1 -1
- package/dist/components/foundation/modules/command/command.mjs.map +1 -1
- package/dist/components/foundation/modules/command/lifecycle.d.mts +81 -0
- package/dist/components/foundation/modules/command/lifecycle.mjs +2 -0
- package/dist/components/foundation/modules/command/lifecycle.mjs.map +1 -0
- package/dist/components/foundation/modules/observability/observability.d.mts +8 -0
- package/dist/components/foundation/modules/observability/observability.mjs +1 -1
- package/dist/components/foundation/modules/observability/observability.mjs.map +1 -1
- package/dist/components/foundation/operation.d.mts +6 -6
- package/dist/components/foundation/operation.mjs.map +1 -1
- package/dist/contracts.d.mts +4 -0
- package/dist/contracts.mjs +1 -0
- package/dist/crud.d.mts +2 -2
- package/dist/crud.mjs.map +1 -1
- package/dist/effect.d.mts +12 -0
- package/dist/effect.mjs +1 -0
- package/dist/errors.d.mts +2 -1
- package/dist/errors.mjs +1 -1
- package/dist/errors.mjs.map +1 -1
- package/dist/idempotency.d.mts +2 -0
- package/dist/idempotency.mjs +1 -0
- package/dist/modules/contracts/contract.d.mts +70 -0
- package/dist/modules/contracts/contract.mjs +2 -0
- package/dist/modules/contracts/contract.mjs.map +1 -0
- package/dist/modules/contracts/convex.d.mts +22 -0
- package/dist/modules/contracts/convex.mjs +2 -0
- package/dist/modules/contracts/convex.mjs.map +1 -0
- package/dist/modules/contracts/errors.d.mts +44 -0
- package/dist/modules/contracts/errors.mjs +2 -0
- package/dist/modules/contracts/errors.mjs.map +1 -0
- package/dist/modules/contracts/exposure.d.mts +105 -0
- package/dist/modules/contracts/exposure.mjs +2 -0
- package/dist/modules/contracts/exposure.mjs.map +1 -0
- package/dist/modules/contracts/idempotency.d.mts +87 -0
- package/dist/modules/contracts/idempotency.mjs +2 -0
- package/dist/modules/contracts/idempotency.mjs.map +1 -0
- package/dist/modules/contracts/standard.d.mts +16 -0
- package/dist/modules/contracts/standard.mjs +2 -0
- package/dist/modules/contracts/standard.mjs.map +1 -0
- package/dist/modules/contracts/workflow.d.mts +167 -0
- package/dist/modules/contracts/workflow.mjs +2 -0
- package/dist/modules/contracts/workflow.mjs.map +1 -0
- package/dist/modules/effect/agent-tools.d.mts +2 -0
- package/dist/modules/effect/agent-tools.mjs +1 -0
- package/dist/modules/effect/api-runtime.d.mts +38 -0
- package/dist/modules/effect/api-runtime.mjs +2 -0
- package/dist/modules/effect/api-runtime.mjs.map +1 -0
- package/dist/modules/effect/api-zod.d.mts +35 -0
- package/dist/modules/effect/api-zod.mjs +2 -0
- package/dist/modules/effect/api-zod.mjs.map +1 -0
- package/dist/modules/effect/api.d.mts +21 -0
- package/dist/modules/effect/api.mjs +2 -0
- package/dist/modules/effect/api.mjs.map +1 -0
- package/dist/modules/effect/client.d.mts +11 -0
- package/dist/modules/effect/client.mjs +1 -0
- package/dist/modules/effect/command.d.mts +63 -0
- package/dist/modules/effect/command.mjs +2 -0
- package/dist/modules/effect/command.mjs.map +1 -0
- package/dist/modules/effect/crud.d.mts +785 -0
- package/dist/modules/effect/crud.mjs +2 -0
- package/dist/modules/effect/crud.mjs.map +1 -0
- package/dist/modules/effect/errors.d.mts +8 -0
- package/dist/modules/effect/errors.mjs +2 -0
- package/dist/modules/effect/errors.mjs.map +1 -0
- package/dist/modules/effect/foundation.d.mts +21 -0
- package/dist/modules/effect/foundation.mjs +2 -0
- package/dist/modules/effect/foundation.mjs.map +1 -0
- package/dist/modules/effect/idempotency.d.mts +31 -0
- package/dist/modules/effect/idempotency.mjs +2 -0
- package/dist/modules/effect/idempotency.mjs.map +1 -0
- package/dist/modules/effect/normalize.d.mts +10 -0
- package/dist/modules/effect/normalize.mjs +2 -0
- package/dist/modules/effect/normalize.mjs.map +1 -0
- package/dist/modules/effect/operation.d.mts +141 -0
- package/dist/modules/effect/operation.mjs +2 -0
- package/dist/modules/effect/operation.mjs.map +1 -0
- package/dist/modules/effect/query.d.mts +69 -0
- package/dist/modules/effect/query.mjs +2 -0
- package/dist/modules/effect/query.mjs.map +1 -0
- package/dist/modules/effect/schema.d.mts +33 -0
- package/dist/modules/effect/schema.mjs +2 -0
- package/dist/modules/effect/schema.mjs.map +1 -0
- package/dist/modules/effect/workflow.d.mts +77 -0
- package/dist/modules/effect/workflow.mjs +2 -0
- package/dist/modules/effect/workflow.mjs.map +1 -0
- package/dist/node_modules/@standard-schema/spec/dist/index.d.mts +79 -0
- package/dist/workflow.d.mts +2 -0
- package/dist/workflow.mjs +1 -0
- package/docs/architecture.md +3 -3
- package/docs/auth.md +71 -0
- package/docs/command-context.md +150 -0
- package/docs/commands-legacy.md +511 -0
- package/docs/commands.md +143 -363
- package/docs/conventions.md +15 -5
- package/docs/crud.md +4 -0
- package/docs/domain-operations.md +615 -0
- package/docs/effect.md +395 -0
- package/docs/llms.md +69 -45
- package/docs/migration.md +1 -1
- package/docs/upgrading.md +9 -0
- package/package.json +33 -1
- package/scripts/convex-smoke-env.mjs +33 -0
- package/scripts/smoke-packed-components.mjs +9 -2
- package/scripts/smoke-packed-effect.mjs +439 -0
- package/scripts/test-packed-effect.mjs +460 -0
- package/scripts/test-packed-ids.mjs +1 -0
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process'
|
|
2
|
+
import {
|
|
3
|
+
cpSync,
|
|
4
|
+
mkdtempSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
realpathSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from 'node:fs'
|
|
11
|
+
import { tmpdir } from 'node:os'
|
|
12
|
+
import { basename, join, resolve } from 'node:path'
|
|
13
|
+
|
|
14
|
+
const root = resolve(import.meta.dirname, '..')
|
|
15
|
+
const manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
|
|
16
|
+
const temporary = mkdtempSync(join(tmpdir(), 'cvx-kit-effect-'))
|
|
17
|
+
const effectVersion = '4.0.1'
|
|
18
|
+
const helpersVersion = '0.1.123'
|
|
19
|
+
const nodeTypesVersion = '26.6.1'
|
|
20
|
+
const domainGuidePath = join(root, 'src/docs/domain-operations.md')
|
|
21
|
+
const domainGuideImports = new Set([
|
|
22
|
+
'./fixture-effect/convex/_generated/api',
|
|
23
|
+
'./fixture-effect/convex/_generated/server',
|
|
24
|
+
'./fixture-effect/convex/idempotency',
|
|
25
|
+
'./fixture-effect/convex/integratedRename',
|
|
26
|
+
'./fixture-effect/convex/_generated/dataModel',
|
|
27
|
+
])
|
|
28
|
+
const extraTypeFixtures = [
|
|
29
|
+
join(root, 'README.md'),
|
|
30
|
+
join(root, 'src/docs/effect.md'),
|
|
31
|
+
join(root, 'src/docs/commands.md'),
|
|
32
|
+
join(root, 'src/docs/command-context.md'),
|
|
33
|
+
join(root, 'src/docs/commands-legacy.md'),
|
|
34
|
+
join(root, 'src/docs/auth.md'),
|
|
35
|
+
domainGuidePath,
|
|
36
|
+
...process.argv.slice(2).map((path) => resolve(path)),
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
function run(command, args, cwd) {
|
|
40
|
+
try {
|
|
41
|
+
return execFileSync(command, args, { cwd, encoding: 'utf8', stdio: 'pipe' })
|
|
42
|
+
} catch (error) {
|
|
43
|
+
// Install output can contain registry credentials. Report compiler/runtime diagnostics only.
|
|
44
|
+
if (!args.includes('install')) {
|
|
45
|
+
process.stderr.write(error.stdout ?? '')
|
|
46
|
+
process.stderr.write(error.stderr ?? '')
|
|
47
|
+
}
|
|
48
|
+
throw new Error(`${command} ${args.join(' ')} failed in ${basename(cwd)}`, {
|
|
49
|
+
cause: error.code,
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function copyPublicTypeFixture(fixture, filename, publicImport) {
|
|
55
|
+
const source = readFileSync(join(root, 'test', filename), 'utf8')
|
|
56
|
+
.replaceAll(publicImport, 'cvx-kit/effect')
|
|
57
|
+
.replaceAll('../src/errors', 'cvx-kit/errors')
|
|
58
|
+
.replaceAll('../src/contracts', 'cvx-kit/contracts')
|
|
59
|
+
.replaceAll('../src/idempotency', 'cvx-kit/idempotency')
|
|
60
|
+
.replaceAll('../src/modules/contracts/errors', 'cvx-kit/errors')
|
|
61
|
+
.replaceAll('../src/modules/contracts/idempotency', 'cvx-kit/idempotency')
|
|
62
|
+
.replaceAll('../src/modules/contracts/workflow', 'cvx-kit/workflow')
|
|
63
|
+
.replaceAll('../src/modules/contracts/exposure', 'cvx-kit/agent-tools')
|
|
64
|
+
.replaceAll('../src/modules/effect/workflow', 'cvx-kit/effect')
|
|
65
|
+
.replaceAll('../src/modules/effect/idempotency', 'cvx-kit/effect')
|
|
66
|
+
.replaceAll('../src/modules/effect/foundation', 'cvx-kit/effect')
|
|
67
|
+
.replaceAll('../src/modules/effect/schema', 'cvx-kit/effect')
|
|
68
|
+
.replaceAll('../src/modules/effect/operation', 'cvx-kit/effect')
|
|
69
|
+
.replaceAll('../src/modules/effect/crud', 'cvx-kit/effect')
|
|
70
|
+
.replaceAll('../src/modules/effect/errors', 'cvx-kit/effect')
|
|
71
|
+
.replaceAll('../src/components/foundation/client', 'cvx-kit/components/foundation')
|
|
72
|
+
.replaceAll('../src/crud', 'cvx-kit/crud')
|
|
73
|
+
.replaceAll('../src/zod-table', 'cvx-kit/zod-table')
|
|
74
|
+
if (/from ['"]\.\.\/src\//.test(source))
|
|
75
|
+
throw new Error(`${filename} still uses private source imports`)
|
|
76
|
+
writeFileSync(join(fixture, filename), source)
|
|
77
|
+
return filename
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Compile the published guide itself so prose edits cannot leave stale companion fixtures passing.
|
|
81
|
+
function publishedTypeSources(path, contents) {
|
|
82
|
+
if (!path.endsWith('.md')) return [contents]
|
|
83
|
+
const marked = [
|
|
84
|
+
...contents.matchAll(/<!-- packed-effect-example -->\s*```ts\s*\n([\s\S]*?)^```/gm),
|
|
85
|
+
]
|
|
86
|
+
if (marked.length) return marked.map((match) => match[1])
|
|
87
|
+
return [[...contents.matchAll(/^```ts\s*\n([\s\S]*?)^```/gm)].map((match) => match[1]).join('\n')]
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function copyExtraTypeFixtures(fixture, paths) {
|
|
91
|
+
return paths.flatMap((path, index) => {
|
|
92
|
+
const contents = readFileSync(path, 'utf8')
|
|
93
|
+
const sources = publishedTypeSources(path, contents)
|
|
94
|
+
const isDomainGuide = path === domainGuidePath
|
|
95
|
+
if (isDomainGuide) {
|
|
96
|
+
const companion = readFileSync(join(root, 'test/domain-operation-guide-types.ts'), 'utf8')
|
|
97
|
+
const blocks = [...contents.matchAll(/^```ts\s*\n([\s\S]*?)^```/gm)]
|
|
98
|
+
if (blocks.length !== 1 || sources.length !== 1 || sources[0].trim() !== companion.trim())
|
|
99
|
+
throw new Error(
|
|
100
|
+
'Domain operation guide must contain its complete, current companion fixture',
|
|
101
|
+
)
|
|
102
|
+
cpSync(join(root, 'test/fixture-effect/convex'), join(fixture, 'fixture-effect/convex'), {
|
|
103
|
+
recursive: true,
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
return sources.map((source, snippet) => {
|
|
107
|
+
if (!source.trim())
|
|
108
|
+
throw new Error(`Documentation fixture ${basename(path)} has no TypeScript`)
|
|
109
|
+
const imports = [...source.matchAll(/from ['"]([^'"]+)['"]/g)].map((match) => match[1])
|
|
110
|
+
if (
|
|
111
|
+
imports.some(
|
|
112
|
+
(specifier) =>
|
|
113
|
+
/^cvx-kit\/(?:src|dist)\//.test(specifier) ||
|
|
114
|
+
(/^\.\.?\//.test(specifier) && !(isDomainGuide && domainGuideImports.has(specifier))),
|
|
115
|
+
)
|
|
116
|
+
) {
|
|
117
|
+
throw new Error(`Documentation fixture ${basename(path)} must use public package imports`)
|
|
118
|
+
}
|
|
119
|
+
const filename = isDomainGuide
|
|
120
|
+
? `domain-operation-guide-${snippet}.ts`
|
|
121
|
+
: `doc-example-${index}-${snippet}.ts`
|
|
122
|
+
writeFileSync(join(fixture, filename), source)
|
|
123
|
+
return filename
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const legacyRuntime = `
|
|
129
|
+
import { strict as assert } from 'node:assert'
|
|
130
|
+
import { createRequire } from 'node:module'
|
|
131
|
+
import { Foundation } from 'cvx-kit/components/foundation'
|
|
132
|
+
import { Foundation as RootFoundation } from 'cvx-kit'
|
|
133
|
+
import { createAuthFunctions, defaultRoleMap } from 'cvx-kit/auth'
|
|
134
|
+
import { createCrudCommands } from 'cvx-kit/crud'
|
|
135
|
+
import { zodTable } from 'cvx-kit/zod-table'
|
|
136
|
+
import { decodeContract, standardContract, zodContract } from 'cvx-kit/contracts'
|
|
137
|
+
import { captureIdempotencyInvocation, transactionalIdempotency, canonicalConvexBytes } from 'cvx-kit/idempotency'
|
|
138
|
+
import { defineErrorContract } from 'cvx-kit/errors'
|
|
139
|
+
import { workflowTransition } from 'cvx-kit/workflow'
|
|
140
|
+
import { selectOperation, bindOperationExecutor, createOperationTools, operationToolDialect } from 'cvx-kit/agent-tools'
|
|
141
|
+
import { actionGeneric, queryGeneric, mutationGeneric, internalActionGeneric, internalQueryGeneric, internalMutationGeneric } from 'convex/server'
|
|
142
|
+
import { z } from 'zod'
|
|
143
|
+
const require = createRequire(import.meta.url)
|
|
144
|
+
if (process.argv.includes('--without-effect')) {
|
|
145
|
+
assert.throws(() => require.resolve('effect'), { code: 'MODULE_NOT_FOUND' }, 'optional peer was unexpectedly auto-installed')
|
|
146
|
+
}
|
|
147
|
+
assert.equal(RootFoundation, Foundation)
|
|
148
|
+
assert.equal(typeof workflowTransition, 'function')
|
|
149
|
+
const toolOwner = Symbol('packed-tools')
|
|
150
|
+
const toolSelection = selectOperation(toolOwner, 'query', 'measure', { input: z.object({ title: z.string() }), result: z.number() })
|
|
151
|
+
const toolExecutor = bindOperationExecutor(toolSelection, { owner: toolOwner, key: 'measure', execute: async raw => raw.title.length })
|
|
152
|
+
const operationTools = createOperationTools({ measure: { operation: toolSelection, executor: toolExecutor, description: 'Measure a title', converter: { dialect: operationToolDialect, convert: () => ({ type: 'object', properties: { title: { type: 'string' } }, required: ['title'], additionalProperties: false }) } } })
|
|
153
|
+
assert.deepEqual(await operationTools.measure.invoke({ title: 'packed' }), { _tag: 'Success', value: 6 })
|
|
154
|
+
let normalizations = 0
|
|
155
|
+
const normalized = zodContract(z.string().transform(async value => { normalizations++; return value.trim() }))
|
|
156
|
+
assert.deepEqual(await decodeContract(normalized, ' packed '), { value: 'packed' })
|
|
157
|
+
assert.equal(normalizations, 1)
|
|
158
|
+
const receiverSchema = { '~standard': {
|
|
159
|
+
version: 1, vendor: 'packed-receiver',
|
|
160
|
+
validate(value) { return this.vendor === 'packed-receiver' ? { value } : { issues: [{ message: 'lost receiver' }] } },
|
|
161
|
+
} }
|
|
162
|
+
assert.deepEqual(await decodeContract(receiverSchema, 'packed'), { value: 'packed' })
|
|
163
|
+
assert.deepEqual(await decodeContract(standardContract(receiverSchema), 'packed'), { value: 'packed' })
|
|
164
|
+
const errors = defineErrorContract({ DENIED: { message: 'Denied', details: { key: z.string() } } })
|
|
165
|
+
const envelope = errors.project(errors.create('DENIED', { key: 'packed' }))
|
|
166
|
+
assert.deepEqual(errors.decode(JSON.parse(JSON.stringify(envelope))), envelope)
|
|
167
|
+
const audits = []
|
|
168
|
+
const { Command } = new Foundation({ functions: { status: null } }, {
|
|
169
|
+
observability: { enabled: false, classifyError: () => ({ outcome: 'failed', errorCode: 'FAILED' }), writeAudit: (_ctx, entry) => audits.push(entry) },
|
|
170
|
+
})
|
|
171
|
+
const operations = { measure: Command.operation({
|
|
172
|
+
command: z.object({ title: z.string().transform(value => value.length) }), result: z.number(),
|
|
173
|
+
classification: 'business', audit: () => null,
|
|
174
|
+
}) }
|
|
175
|
+
const execute = new Command(operations).exec({ operation: 'measure', handler: async (ctx, input) => ctx.offset + input.title })
|
|
176
|
+
const pending = execute({ offset: 2 }, { title: 'abc' })
|
|
177
|
+
assert.ok(pending instanceof Promise)
|
|
178
|
+
assert.equal(await pending, 5)
|
|
179
|
+
assert.equal(defaultRoleMap('member'), 'writer')
|
|
180
|
+
const auth = createAuthFunctions({
|
|
181
|
+
query: queryGeneric, mutation: mutationGeneric, action: actionGeneric,
|
|
182
|
+
internalQuery: internalQueryGeneric, internalMutation: internalMutationGeneric, internalAction: internalActionGeneric,
|
|
183
|
+
getAuthUser: async () => ({ id: 'actor' }), mapRole: defaultRoleMap, adminRoles: ['admin'],
|
|
184
|
+
})
|
|
185
|
+
const authenticated = auth.authQuery({ args: {}, returns: z.string(), handler: ctx => ctx.actor.userId + ':' + ctx.tenant })
|
|
186
|
+
assert.equal(await authenticated._handler({ auth: { getUserIdentity: async () => ({ subject: 'actor', org_id: 'org', role: 'member' }) }, db: {} }, {}), 'actor:org')
|
|
187
|
+
const table = zodTable('notes', () => ({ text: z.string() }), { commandFields: ['text'] })
|
|
188
|
+
const crud = createCrudCommands({ Command, table, aggregateType: 'note', actor: () => 'actor' })
|
|
189
|
+
const changes = []
|
|
190
|
+
const ctx = { db: { insert: async (name, row) => { changes.push([name, row]); return 'note-id' }, patch: async (id, row) => changes.push([id, row]) } }
|
|
191
|
+
assert.deepEqual(await crud.executeCreate(ctx, { text: 'packed' }), { id: 'note-id' })
|
|
192
|
+
assert.deepEqual(await crud.executeUpdate(ctx, { id: 'note-id', data: { text: 'updated' } }), { ok: true })
|
|
193
|
+
assert.deepEqual(changes, [['notes', { text: 'packed' }], ['note-id', { text: 'updated' }]])
|
|
194
|
+
assert.equal(audits.length, 2)
|
|
195
|
+
const bounds = { maxDepth: 8, maxNodes: 100, maxBytes: 4096, maxArrayLength: 20, maxObjectFields: 20 }
|
|
196
|
+
assert.equal(canonicalConvexBytes({ b: { d: 2, c: 1 }, a: 0 }, bounds), canonicalConvexBytes({ a: 0, b: { c: 1, d: 2 } }, bounds))
|
|
197
|
+
const invocation = captureIdempotencyInvocation({ binding: { kind: 'mutation', atomicity: 'same-mutation' }, identity: { operation: 'packed', scope: 'host', key: 'one' }, versions: { operation: '1', contract: '1', binding: '1', fingerprintPolicy: '1' }, rawInput: { title: 'raw' }, canonical: { bounds } })
|
|
198
|
+
const ledger = []
|
|
199
|
+
let authorizations = 0
|
|
200
|
+
const idempotency = transactionalIdempotency({
|
|
201
|
+
final: { replayResult: zodContract(z.object({ version: z.literal(1), value: z.string() }).transform(wire => wire.value)), encode: value => ({ version: 1, value }) },
|
|
202
|
+
authorize: () => { authorizations++ }, lookup: () => ledger, fingerprint: bytes => bytes,
|
|
203
|
+
claim: (_identity, receipt) => { ledger.push(receipt); return 0 }, complete: (index, receipt) => { ledger[index] = receipt },
|
|
204
|
+
})
|
|
205
|
+
const preparation = await idempotency.prepare(invocation)
|
|
206
|
+
assert.equal(preparation.kind, 'execute')
|
|
207
|
+
await preparation.complete('final!')
|
|
208
|
+
const replay = await idempotency.prepare(invocation)
|
|
209
|
+
assert.equal(replay.kind, 'replay')
|
|
210
|
+
assert.deepEqual(await decodeContract(idempotency.replayResult, replay.result), { value: 'final!' })
|
|
211
|
+
assert.equal(authorizations, 2)
|
|
212
|
+
assert.equal(ledger.length, 1)
|
|
213
|
+
`
|
|
214
|
+
|
|
215
|
+
const legacyTypes = `
|
|
216
|
+
import { Foundation } from 'cvx-kit'
|
|
217
|
+
import { createAuthFunctions, defaultRoleMap } from 'cvx-kit/auth'
|
|
218
|
+
import { createCrudCommands } from 'cvx-kit/crud'
|
|
219
|
+
import { zodTable } from 'cvx-kit/zod-table'
|
|
220
|
+
import { actionGeneric, queryGeneric, mutationGeneric, internalActionGeneric, internalQueryGeneric, internalMutationGeneric, type GenericMutationCtx, type GenericDataModel } from 'convex/server'
|
|
221
|
+
import { z } from 'zod'
|
|
222
|
+
const { Command } = new Foundation({ functions: { status: null } }, {
|
|
223
|
+
observability: { enabled: false, classifyError: () => ({ outcome: 'failed', errorCode: 'FAILED' }) },
|
|
224
|
+
})
|
|
225
|
+
const operation = Command.withContext<{ offset: number }>().operation({
|
|
226
|
+
command: z.object({ title: z.string().transform(value => value.length) }), result: z.number(), classification: 'business', audit: () => null,
|
|
227
|
+
})
|
|
228
|
+
const commands = new Command<{ offset: number }, { measure: typeof operation }>({ measure: operation })
|
|
229
|
+
const execute = commands.exec({ operation: 'measure', handler: async (ctx, input) => ctx.offset + input.title })
|
|
230
|
+
const result: Promise<number> = execute({ offset: 1 }, { title: 'raw' })
|
|
231
|
+
void result
|
|
232
|
+
const auth = createAuthFunctions({ query: queryGeneric, mutation: mutationGeneric, action: actionGeneric,
|
|
233
|
+
internalQuery: internalQueryGeneric, internalMutation: internalMutationGeneric, internalAction: internalActionGeneric,
|
|
234
|
+
getAuthUser: async () => ({ id: 'actor' }), mapRole: defaultRoleMap, adminRoles: ['admin'],
|
|
235
|
+
})
|
|
236
|
+
const authenticated = auth.authQuery({ args: {}, returns: z.string(), handler: ctx => ctx.actor.userId })
|
|
237
|
+
void authenticated
|
|
238
|
+
const table = zodTable('notes', () => ({ text: z.string() }), { commandFields: ['text'] })
|
|
239
|
+
const crud = createCrudCommands<GenericMutationCtx<GenericDataModel>, typeof table>({ Command, table, aggregateType: 'note', actor: () => 'actor' })
|
|
240
|
+
declare const ctx: GenericMutationCtx<GenericDataModel>
|
|
241
|
+
void crud.executeCreate(ctx, { text: 'packed' })
|
|
242
|
+
`
|
|
243
|
+
|
|
244
|
+
const domainTypes = `
|
|
245
|
+
import { Context, Effect } from 'effect'
|
|
246
|
+
import { createEffectFoundation } from 'cvx-kit/effect'
|
|
247
|
+
import { z } from 'zod'
|
|
248
|
+
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false
|
|
249
|
+
type Assert<T extends true> = T
|
|
250
|
+
class Reader extends Context.Service<Reader, { count: number }>()('PackedReader') {}
|
|
251
|
+
class Failure { readonly _tag = 'PackedFailure' }
|
|
252
|
+
const foundation = createEffectFoundation({ observability: { enabled: false, classifyError: () => ({ outcome: 'failed', errorCode: 'FAILED' }) }, writeAudit: () => undefined })
|
|
253
|
+
const queries = foundation.Query({ context: (host: { actor: string }) => host, operations: ({ query }) => ({
|
|
254
|
+
read: query({ input: z.string().transform(value => value.length), result: z.number().transform(String),
|
|
255
|
+
handler: (input, ctx) => Effect.gen(function* () { ctx.actor.toUpperCase(); if (input < 1) return yield* Effect.fail(new Failure()); return input + (yield* Reader).count }),
|
|
256
|
+
}),
|
|
257
|
+
recovered: query({ input: z.number(), result: z.number(), handler: input => Effect.gen(function* () { if (input < 0) return yield* Effect.fail(new Failure()); return input + (yield* Reader).count }),
|
|
258
|
+
middleware: ({ next }) => next().pipe(Effect.provideService(Reader, { count: 1 }), Effect.catchTag('PackedFailure', () => Effect.succeed(0))),
|
|
259
|
+
}),
|
|
260
|
+
ordinary: query({ input: z.number(), result: z.number(), handler: input => input }),
|
|
261
|
+
promise: query({ input: z.number(), result: z.number(), handler: async input => input }),
|
|
262
|
+
named: query({ input: z.number(), result: z.number(), handler: Effect.fn('packed.named')(function* (input: number) { return yield* Effect.succeed(input) }) }),
|
|
263
|
+
}) })
|
|
264
|
+
const read = queries.exec('read', 'raw', { actor: 'trusted' })
|
|
265
|
+
const recovered = queries.exec('recovered', 1, { actor: 'trusted' })
|
|
266
|
+
const checks: [Assert<Equal<Effect.Success<typeof read>, string>>, Assert<Equal<Effect.Error<typeof read>, Failure>>, Assert<Equal<Effect.Services<typeof read>, Reader>>, Assert<Equal<Effect.Error<typeof recovered>, never>>, Assert<Equal<Effect.Services<typeof recovered>, never>>] = [true, true, true, true, true]
|
|
267
|
+
void checks
|
|
268
|
+
// @ts-expect-error an unprovided domain requirement cannot reach the runner
|
|
269
|
+
void Effect.runPromise(read)
|
|
270
|
+
// @ts-expect-error transport input is the raw schema input
|
|
271
|
+
queries.exec('read', 1, { actor: 'trusted' })
|
|
272
|
+
for (const kind of ['ordinary', 'promise', 'named'] as const) { const execution = queries.exec(kind, 1, { actor: 'trusted' }); const checks: [Assert<Equal<Effect.Error<typeof execution>, never>>, Assert<Equal<Effect.Services<typeof execution>, never>>] = [true, true]; void checks }
|
|
273
|
+
`
|
|
274
|
+
|
|
275
|
+
const effectRuntime = `
|
|
276
|
+
import { strict as assert } from 'node:assert'
|
|
277
|
+
import { createRequire } from 'node:module'
|
|
278
|
+
import { realpathSync } from 'node:fs'
|
|
279
|
+
import { Context, Effect } from 'effect'
|
|
280
|
+
import { createEffectFoundation, createEffectCrud, effectApiBuilder, effectZodApiBuilder, createOperationTools } from 'cvx-kit/effect'
|
|
281
|
+
import { createOperationTools as neutralOperationTools } from 'cvx-kit/agent-tools'
|
|
282
|
+
import { zodTable } from 'cvx-kit/zod-table'
|
|
283
|
+
import { queryGeneric, internalMutationGeneric, actionGeneric } from 'convex/server'
|
|
284
|
+
import { zCustomMutation } from 'convex-helpers/server/zod4'
|
|
285
|
+
import { v } from 'convex/values'
|
|
286
|
+
import { z } from 'zod'
|
|
287
|
+
const require = createRequire(import.meta.url)
|
|
288
|
+
const packageRequire = createRequire(import.meta.resolve('cvx-kit/effect'))
|
|
289
|
+
assert.equal(createOperationTools, neutralOperationTools)
|
|
290
|
+
assert.equal(realpathSync(require.resolve('effect')), realpathSync(packageRequire.resolve('effect')), 'package resolved a second Effect identity')
|
|
291
|
+
class Request extends Context.Service()('PackedRequest') {}
|
|
292
|
+
const events = []
|
|
293
|
+
const foundation = createEffectFoundation({ observability: { enabled: false, classifyError: () => ({ outcome: 'failed', errorCode: 'FAILED' }) }, writeAudit: () => events.push('audit') })
|
|
294
|
+
const queries = foundation.Query({ context: host => host, operations: ({ query }) => ({
|
|
295
|
+
read: query({ input: z.string().transform(value => value.length), result: z.number(), handler: input => Effect.gen(function* () { events.push('read'); return input + (yield* Request).offset }) }),
|
|
296
|
+
}) })
|
|
297
|
+
const commands = foundation.Command({ context: host => host, operations: ({ command }) => ({
|
|
298
|
+
save: command({ input: z.string(), result: z.number(), classification: 'business', audit: () => ({ operation: 'save', actorId: 'actor', aggregate: { type: 'note', id: 'note-id' } }), handler: (input, host) => Effect.gen(function* () { events.push('save'); return yield* queries.exec('read', input, host) }) }),
|
|
299
|
+
}) })
|
|
300
|
+
const execution = commands.exec('save', 'abc', {})
|
|
301
|
+
assert.ok(Effect.isEffect(execution), 'public facade must return the host Effect value')
|
|
302
|
+
assert.deepEqual(events, [], 'domain composition must stay lazy')
|
|
303
|
+
const adapted = effectApiBuilder(queryGeneric, { services: () => { events.push('provide'); return Context.make(Request, { offset: 2 }) } })({ args: {}, returns: v.number(), handler: () => execution })
|
|
304
|
+
assert.equal(await adapted._handler({}, {}), 5)
|
|
305
|
+
assert.deepEqual(events, ['provide', 'save', 'read', 'audit'], 'one adapter run must execute nested composition once')
|
|
306
|
+
const plain = effectApiBuilder(queryGeneric, { services: () => Context.empty() })
|
|
307
|
+
for (const handler of [() => 3, () => Promise.resolve(3), () => Effect.succeed(3), Effect.fn('packed.fn')(function* () { return yield* Effect.succeed(3) })]) {
|
|
308
|
+
assert.equal(await plain({ handler })._handler({}, {}), 3)
|
|
309
|
+
}
|
|
310
|
+
const customization = []
|
|
311
|
+
const custom = effectZodApiBuilder(zCustomMutation(internalMutationGeneric, { args: { session: v.string() }, input: (_ctx, args) => { customization.push('custom'); return { ctx: { actor: args.session }, args: { made: 4 } } } }), {
|
|
312
|
+
services: ctx => { customization.push('provide:' + ctx.actor); return Context.make(Request, { offset: 2 }) },
|
|
313
|
+
})({ args: { amount: z.string().transform(value => value.length) }, returns: z.number().transform(String), skipConvexValidation: true,
|
|
314
|
+
handler: (ctx, args) => Effect.gen(function* () { customization.push('handle:' + ctx.actor); return args.amount + args.made + (yield* Request).offset }),
|
|
315
|
+
})
|
|
316
|
+
assert.equal(await custom._handler({}, { session: 'trusted', amount: 'abc' }), '9')
|
|
317
|
+
assert.deepEqual(customization, ['custom', 'provide:trusted', 'handle:trusted'])
|
|
318
|
+
assert.ok(custom.isInternal && custom.isMutation)
|
|
319
|
+
assert.ok(effectApiBuilder(actionGeneric, { services: () => Context.empty() })(() => 1).isAction)
|
|
320
|
+
for (const fail of [false, true]) {
|
|
321
|
+
const lifecycle = []
|
|
322
|
+
const adapter = effectApiBuilder(queryGeneric, { services: () => Effect.acquireRelease(Effect.sync(() => { lifecycle.push('acquire'); return Context.empty() }), () => Effect.promise(async () => { await Promise.resolve(); lifecycle.push('release') })) })
|
|
323
|
+
const registered = adapter({ handler: () => Effect.gen(function* () { lifecycle.push('handle'); if (fail) return yield* Effect.fail('packed-failure'); return 1 }) })
|
|
324
|
+
if (fail) await assert.rejects(registered._handler({}, {}), error => error === 'packed-failure')
|
|
325
|
+
else assert.equal(await registered._handler({}, {}), 1)
|
|
326
|
+
assert.deepEqual(lifecycle, ['acquire', 'handle', 'release'])
|
|
327
|
+
}
|
|
328
|
+
await assert.rejects(plain({ handler: () => Effect.die(new Error('packed-defect')) })._handler({}, {}), /packed-defect/)
|
|
329
|
+
await assert.rejects(plain({ handler: () => Effect.interrupt })._handler({}, {}))
|
|
330
|
+
const crudAudits = []
|
|
331
|
+
const crudFoundation = createEffectFoundation({ observability: { enabled: false, classifyError: () => ({ outcome: 'failed', errorCode: 'FAILED' }) }, writeAudit: (_host, entry) => crudAudits.push(entry) })
|
|
332
|
+
const crudTable = zodTable('packedNotes', () => ({ title: z.string(), secret: z.string() }), { commandFields: ['title'], serverFields: ['secret'], publicFields: ['title'] })
|
|
333
|
+
let crudRow
|
|
334
|
+
const crudHost = { actor: 'packed-actor', db: { insert: async (_table, values) => { crudRow = values; return 'packed-id' }, patch: async (_id, values) => { crudRow = { ...crudRow, ...values } } } }
|
|
335
|
+
const crud = createEffectCrud({ foundation: crudFoundation, table: crudTable, context: host => host, aggregateType: 'note', actor: host => host.actor, checkId: (_host, id) => id === 'packed-id', enrich: () => ({ secret: 'private' }), read: { maxPageSize: 2, project: row => ({ title: row.title }), get: () => crudRow ?? null, list: () => ({ page: crudRow ? [crudRow] : [], isDone: true, continueCursor: '' }) } })
|
|
336
|
+
const pendingCreate = crud.commands.exec('packedNotes.create', { title: 'packed-crud' }, crudHost)
|
|
337
|
+
assert.equal(crudRow, undefined, 'CRUD remains lazy before the API runner')
|
|
338
|
+
assert.deepEqual(await Effect.runPromise(pendingCreate), { id: 'packed-id' })
|
|
339
|
+
assert.deepEqual(await Effect.runPromise(crud.queries.exec('packedNotes.get', { id: 'packed-id' }, crudHost)), { title: 'packed-crud' })
|
|
340
|
+
assert.deepEqual(await Effect.runPromise(crud.commands.exec('packedNotes.update', { id: 'packed-id', data: {} }, crudHost)), { ok: true })
|
|
341
|
+
assert.equal(crudAudits.length, 2)
|
|
342
|
+
assert.deepEqual(crudRow, { title: 'packed-crud', secret: 'private' })
|
|
343
|
+
assert.deepEqual(await Effect.runPromise(crud.commands.exec('packedNotes.update', { id: 'packed-id', data: { title: 'packed-updated' } }, crudHost)), { ok: true })
|
|
344
|
+
assert.deepEqual(crudRow, { title: 'packed-updated', secret: 'private' })
|
|
345
|
+
assert.deepEqual(await Effect.runPromise(crud.queries.exec('packedNotes.get', { id: 'packed-id' }, crudHost)), { title: 'packed-updated' })
|
|
346
|
+
assert.equal(crudAudits.length, 3)
|
|
347
|
+
assert.deepEqual(crudAudits.map(entry => entry.operation), ['packedNotes.create', 'packedNotes.update', 'packedNotes.update'])
|
|
348
|
+
for (const [registry, operation, input] of [
|
|
349
|
+
[crud.queries, 'packedNotes.get', { id: 'other-table-id' }],
|
|
350
|
+
[crud.commands, 'packedNotes.update', { id: 'other-table-id', data: { title: 'wrong-table' } }],
|
|
351
|
+
[crud.commands, 'packedNotes.archive', { id: 'other-table-id' }],
|
|
352
|
+
]) {
|
|
353
|
+
await assert.rejects(Effect.runPromise(registry.exec(operation, input, crudHost)), error => error.code === 'CRUD_INVALID_ID')
|
|
354
|
+
assert.deepEqual(crudRow, { title: 'packed-updated', secret: 'private' })
|
|
355
|
+
assert.equal(crudAudits.length, 3)
|
|
356
|
+
}
|
|
357
|
+
`
|
|
358
|
+
|
|
359
|
+
try {
|
|
360
|
+
run('bun', ['pm', 'pack', '--destination', temporary, '--ignore-scripts'], root)
|
|
361
|
+
for (const installer of ['npm', 'bun']) {
|
|
362
|
+
for (const withEffect of [false, true]) {
|
|
363
|
+
const name = `${installer}-${withEffect ? 'effect' : 'legacy'}`
|
|
364
|
+
const fixture = join(temporary, name)
|
|
365
|
+
mkdirSync(fixture)
|
|
366
|
+
const dependencies = {
|
|
367
|
+
'cvx-kit': `file:${join(temporary, `cvx-kit-${manifest.version}.tgz`)}`,
|
|
368
|
+
convex: manifest.devDependencies.convex,
|
|
369
|
+
'convex-helpers': helpersVersion,
|
|
370
|
+
'@convex-dev/workflow': manifest.dependencies['@convex-dev/workflow'],
|
|
371
|
+
zod: manifest.devDependencies.zod,
|
|
372
|
+
}
|
|
373
|
+
if (withEffect) dependencies.effect = effectVersion
|
|
374
|
+
const devDependencies = { typescript: manifest.devDependencies.typescript }
|
|
375
|
+
if (withEffect) devDependencies['@types/node'] = nodeTypesVersion
|
|
376
|
+
writeFileSync(
|
|
377
|
+
join(fixture, 'package.json'),
|
|
378
|
+
JSON.stringify({
|
|
379
|
+
private: true,
|
|
380
|
+
type: 'module',
|
|
381
|
+
dependencies,
|
|
382
|
+
devDependencies,
|
|
383
|
+
}),
|
|
384
|
+
)
|
|
385
|
+
run(installer, ['install', '--ignore-scripts'], fixture)
|
|
386
|
+
writeFileSync(join(fixture, 'legacy.mjs'), legacyRuntime)
|
|
387
|
+
writeFileSync(join(fixture, 'legacy-types.ts'), legacyTypes)
|
|
388
|
+
const typeFiles = [
|
|
389
|
+
'legacy-types.ts',
|
|
390
|
+
copyPublicTypeFixture(fixture, 'operation-neutral-types.ts', '../src/effect'),
|
|
391
|
+
copyPublicTypeFixture(fixture, 'operation-agent-tools-types.ts', '../src/effect'),
|
|
392
|
+
]
|
|
393
|
+
const guideTypeFiles = []
|
|
394
|
+
if (withEffect) {
|
|
395
|
+
for (const filename of [
|
|
396
|
+
'effect-foundation-types.ts',
|
|
397
|
+
'effect-api-types.ts',
|
|
398
|
+
'effect-api-zod-types.ts',
|
|
399
|
+
'effect-registry-review-types.ts',
|
|
400
|
+
'operation-contract-types.ts',
|
|
401
|
+
'effect-schema-types.ts',
|
|
402
|
+
'effect-crud-types.ts',
|
|
403
|
+
'idempotency-types.ts',
|
|
404
|
+
'operation-workflow-types.ts',
|
|
405
|
+
'error-contract-types.ts',
|
|
406
|
+
]) {
|
|
407
|
+
typeFiles.push(copyPublicTypeFixture(fixture, filename, '../src/effect'))
|
|
408
|
+
}
|
|
409
|
+
writeFileSync(join(fixture, 'domain-types.ts'), domainTypes)
|
|
410
|
+
writeFileSync(join(fixture, 'effect.mjs'), effectRuntime)
|
|
411
|
+
typeFiles.push('domain-types.ts')
|
|
412
|
+
for (const filename of copyExtraTypeFixtures(fixture, extraTypeFixtures)) {
|
|
413
|
+
if (filename.startsWith('domain-operation-guide-')) guideTypeFiles.push(filename)
|
|
414
|
+
else typeFiles.push(filename)
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
writeFileSync(
|
|
418
|
+
join(fixture, 'tsconfig.json'),
|
|
419
|
+
JSON.stringify({
|
|
420
|
+
compilerOptions: {
|
|
421
|
+
noEmit: true,
|
|
422
|
+
strict: true,
|
|
423
|
+
skipLibCheck: false,
|
|
424
|
+
target: 'ES2025',
|
|
425
|
+
lib: ['ES2025', 'DOM', 'ESNext.Disposable'],
|
|
426
|
+
module: 'ESNext',
|
|
427
|
+
moduleResolution: 'Bundler',
|
|
428
|
+
types: [],
|
|
429
|
+
},
|
|
430
|
+
files: typeFiles,
|
|
431
|
+
}),
|
|
432
|
+
)
|
|
433
|
+
const compiler = realpathSync(join(fixture, 'node_modules/typescript/bin/tsc'))
|
|
434
|
+
run('node', [compiler, '-p', 'tsconfig.json'], fixture)
|
|
435
|
+
if (withEffect) {
|
|
436
|
+
writeFileSync(
|
|
437
|
+
join(fixture, 'tsconfig.domain.json'),
|
|
438
|
+
JSON.stringify({
|
|
439
|
+
extends: './tsconfig.json',
|
|
440
|
+
compilerOptions: { types: ['node'] },
|
|
441
|
+
files: guideTypeFiles,
|
|
442
|
+
}),
|
|
443
|
+
)
|
|
444
|
+
run('node', [compiler, '-p', 'tsconfig.domain.json'], fixture)
|
|
445
|
+
console.log(`${name}: complete domain guide and actual native references pass separately`)
|
|
446
|
+
}
|
|
447
|
+
console.log(
|
|
448
|
+
`${name}: strict public declaration/type fixtures pass (TypeScript ${manifest.devDependencies.typescript}, helpers ${helpersVersion})`,
|
|
449
|
+
)
|
|
450
|
+
const runtime = installer === 'bun' ? 'bun' : 'node'
|
|
451
|
+
run(runtime, ['legacy.mjs', ...(withEffect ? [] : ['--without-effect'])], fixture)
|
|
452
|
+
if (withEffect) run(runtime, ['effect.mjs'], fixture)
|
|
453
|
+
console.log(
|
|
454
|
+
`${name}: public consumer runtime pass (Convex ${manifest.devDependencies.convex}, Zod ${manifest.devDependencies.zod}${withEffect ? `, Effect ${effectVersion}` : ', Effect absent'})`,
|
|
455
|
+
)
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
} finally {
|
|
459
|
+
rmSync(temporary, { recursive: true, force: true })
|
|
460
|
+
}
|
|
@@ -66,6 +66,7 @@ for (const validator of [
|
|
|
66
66
|
for (const testFile of ['command.test.ts', 'command-lifecycle.test.ts']) {
|
|
67
67
|
const source = readFileSync(join(root, 'test', testFile), 'utf8')
|
|
68
68
|
.replace('../src/components/foundation/client', 'cvx-kit/components/foundation')
|
|
69
|
+
.replaceAll('../src/contracts', 'cvx-kit/contracts')
|
|
69
70
|
.replace(
|
|
70
71
|
"const modules = import.meta.glob('./fixture/**/*.ts')",
|
|
71
72
|
"const modules = { './_generated/server.ts': async () => ({}) }",
|