cvx-kit 0.1.15 → 0.1.16
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/README.md +59 -38
- package/dist/relations.mjs +1 -1
- package/dist/relations.mjs.map +1 -1
- package/dist/zod-table.d.mts +59 -2
- package/dist/zod-table.mjs +1 -1
- package/dist/zod-table.mjs.map +1 -1
- package/package.json +10 -3
- package/AGENTS.md +0 -47
- package/CHANGELOG.md +0 -337
- package/ROADMAP.md +0 -126
- package/docs/agent-tools.md +0 -41
- package/docs/approvals.md +0 -201
- package/docs/architecture.md +0 -124
- package/docs/auth.md +0 -443
- package/docs/command-context.md +0 -150
- package/docs/commands-legacy.md +0 -513
- package/docs/commands.md +0 -173
- package/docs/conventions.md +0 -345
- package/docs/crud.md +0 -66
- package/docs/domain-contracts.md +0 -148
- package/docs/domain-operations.md +0 -615
- package/docs/effect.md +0 -403
- package/docs/llms.md +0 -324
- package/docs/maintainability.md +0 -124
- package/docs/migration.md +0 -351
- package/docs/oxlint.md +0 -260
- package/docs/querying.md +0 -329
- package/docs/tenancy.md +0 -176
- package/docs/triggers.md +0 -77
- package/docs/upgrading.md +0 -287
- package/docs/webhooks.md +0 -58
- package/docs/zod-table.md +0 -278
- package/scripts/convex-config-bridge.mjs +0 -44
- package/scripts/convex-smoke-env.mjs +0 -33
- package/scripts/extract-changelog.mjs +0 -29
- package/scripts/owned-process.mjs +0 -140
- package/scripts/owned-process.test.mjs +0 -107
- package/scripts/smoke-packed-components.mjs +0 -341
- package/scripts/smoke-packed-effect.mjs +0 -439
- package/scripts/smoke-packed-querying.mjs +0 -298
- package/scripts/test-packed-auth.mjs +0 -72
- package/scripts/test-packed-effect.mjs +0 -482
- package/scripts/test-packed-ids.mjs +0 -150
- package/scripts/test-packed-oxlint.mjs +0 -167
- package/scripts/test-packed-querying.mjs +0 -185
|
@@ -1,615 +0,0 @@
|
|
|
1
|
-
# Domain operations on Convex
|
|
2
|
-
|
|
3
|
-
Declare operations beside their handlers, inject the application's repositories, and register the native functions at the application boundary. The domain registry owns decoding and the command lifecycle. Convex owns function authorization, transaction rollback, durable workflow journals, and scheduling.
|
|
4
|
-
|
|
5
|
-
The complete TypeScript module below is mirrored in `test/domain-operation-guide-types.ts` for strict compilation. All TypeScript in this guide belongs to that one module; there are no standalone handler fragments. It uses public `cvx-kit/*` imports, real Convex builders, an injected document repository, and generated references from the native acceptance fixture. Its document schema and generic builders let the example compile independently; an application imports its own `mutation`, `query`, and internal builders from `./_generated/server`. The integrated rename portion imports the exact registry from `test/fixture-effect/convex/integratedRename.ts` and binds its real generated references. The document create/receipt example remains separate.
|
|
6
|
-
|
|
7
|
-
The examples use the recommended Effect command API even for ordinary handlers. The older `Foundation.Command` facade is deprecated and retained for existing consumers without Effect; use [the migration guide](./commands.md) when moving those domains. Neutral contracts, errors, idempotency, workflow, and tool facades remain available without Effect. Import `cvx-kit/effect` only when the application supplies its Effect peer.
|
|
8
|
-
|
|
9
|
-
## Declare a cohesive domain
|
|
10
|
-
|
|
11
|
-
Use `createEffectFoundation` from `cvx-kit/effect` to bind the application's audit and permission capabilities. Both ordinary and Effect handlers use the same operation factory. Define `create` and `rename` in the command registry and `get` in the query registry. Query declarations cannot accept command-only preparation, audit, or receipt hooks.
|
|
12
|
-
|
|
13
|
-
The compile fixture defines `Host` with the current secured writer, tenant, actor, current role, repository, and a trusted idempotency invocation. It supplies `foundation.writeAudit` with that same writer. The ordinary `ordinaryCommands.create` handler accesses the repository through its host. The `commands.create` alternative uses the same input, result, receipt preparation, and audit lifecycle.
|
|
14
|
-
|
|
15
|
-
The `Effect.fn` version resolves `Documents` only when the operation executes.
|
|
16
|
-
|
|
17
|
-
`Effect.gen` works in the same declaration: `commands.rename` resolves the injected repository and changes the title. `queries.get` requires only `DocumentReader` and fails with the declared `MISSING` error when no document exists.
|
|
18
|
-
|
|
19
|
-
Command declarations also provide `classification` and `audit`. The audit callback receives `{ command, result }` and the domain host; use `result.id`, the trusted `host.actor`, and an explicit operation name. The injected audit writer persists the entry in the current mutation transaction. A nested generated CRUD command can own the audit instead; avoid writing two execution audits for one command.
|
|
20
|
-
|
|
21
|
-
`commands.exec('create', input, host)` returns a lazy Effect. Its success, declared failures, and required services are inferred from the selected operation's decoders and callbacks. The `serverCreate` function composes it server side and provides `Documents` at the invocation boundary. At the native API boundary, `securedMutation` provides that service from the authenticated context instead.
|
|
22
|
-
|
|
23
|
-
Do not call `Effect.runPromise` from the middle of a domain operation to discharge its dependencies. Keep error and service channels visible until the native API adapter provides them.
|
|
24
|
-
|
|
25
|
-
## Register the native API
|
|
26
|
-
|
|
27
|
-
`effectApiBuilder` accepts Convex validators; `effectZodApiBuilder` accepts Zod definitions. Pass the application's secured builder, such as `auth.authMutation`, so its trusted context is constructed before the service provider runs. The compile fixture uses `createAuthFunctions` from `cvx-kit/auth`, tenant security for documents/receipts/audits, and owner-only write rules. The repository receives the resulting secured writer.
|
|
28
|
-
|
|
29
|
-
The host takes tenant, actor, and writer from the secured context and captures the raw title before the domain decoder runs. The fixture also registers `get` through `auth.authQuery`, injecting a separate `DocumentReader` capability that exposes only repository reads.
|
|
30
|
-
|
|
31
|
-
Each invocation gets its own provider and scope. If service initialization or scoped cleanup fails, the native mutation must reject so its business, audit, and receipt writes roll back. Ordinary throws/rejections and defects remain failures; they are not silently chosen as one declared domain error.
|
|
32
|
-
|
|
33
|
-
The fixture has a negative example: an API provider returning `Context.empty()` cannot register a handler requiring `Documents`. The `invokeNativeCreate` function demonstrates native consumption with `ctx.runMutation(api.idempotency.save, { key, payload })` using the real registered fixture mutation. When installing the local document module in an application, generate its own references with Convex codegen before calling its `create` and `get` exports. The native function reference carries its args/result and kind; it does not transport the server Effect's `E` or `R` types.
|
|
34
|
-
|
|
35
|
-
`zid('documents')` brands strings in Zod parsing. A registered native `v.id('documents')` validator establishes the real Convex ID/table boundary. The host must enforce native IDs when raw tool or network strings reach a registered function; a brand alone does not prove a valid document ID.
|
|
36
|
-
|
|
37
|
-
## Choose each contract explicitly
|
|
38
|
-
|
|
39
|
-
Import `zodContract`, `standardContract`, and `convexContract` from `cvx-kit/contracts` for neutral codecs. Ordinary operation definitions can also use Zod schemas directly and may decode asynchronously. Standard Schema supplies validation, not a portable schema AST or encoder; do not derive native validators from a black-box contract. Import `effectContract`, `effectSchema`, or `effectStandardSchema` from `cvx-kit/effect` when decoding itself requires Effects or services.
|
|
40
|
-
|
|
41
|
-
Keep the raw input, decoded domain result, retained final result, and public wire projection distinct. A title-trimming input decoder belongs inside the operation. A fresh-result transform must not run again on receipt replay or native workflow journal replay. Use a separate `replayResult` schema for the retained final form and a public wire schema/projector when exposing only selected fields.
|
|
42
|
-
|
|
43
|
-
Generated CRUD uses `createEffectCrud` and `tenantTable` with explicit command/server/public masks. The fixture's `documentCrud` binds the same foundation, writer, actor, and indexed repository. The read configuration supplies `get` and indexed pagination with `maxPageSize: 20`; its public projection returns only the title. Generated writes retain native trigger/RLS behavior through the supplied secured writer. Application configuration determines which rules and triggers exist.
|
|
44
|
-
|
|
45
|
-
## Project declared failures safely
|
|
46
|
-
|
|
47
|
-
The `get` handler fails with `errors.create('MISSING', { id })`. Pass the contract to the Effect API builder. The native boundary emits a versioned JSON-safe `ConvexError` payload only for a valid singular declared failure. Safe details must be intentionally chosen; do not include service state, credentials, internal causes, or stack traces.
|
|
48
|
-
|
|
49
|
-
On consumption, call `errors.decode(error.data)` after recognizing a native `ConvexError`. The result is a checked `DeclaredFailure` union or `UnknownFailure`; an arbitrary payload does not become a domain error through a cast. Defects, interruptions, composite causes, malformed details, and undeclared errors follow the unknown path. Never parse workflow journal error strings to recover typed failures.
|
|
50
|
-
|
|
51
|
-
## Commit receipts with business writes
|
|
52
|
-
|
|
53
|
-
Import `captureIdempotencyInvocation` and `transactionalIdempotency` from `cvx-kit/idempotency`, or use `effectTransactionalIdempotency` for Effect callbacks. The fixture's adapter closes over the secured mutation writer and implements authorization, indexed lookup, claim, and completion. Receipt completion, document creation, and command audit share one transaction.
|
|
54
|
-
|
|
55
|
-
The `create` native handler captures identity before decoding the raw request. The fingerprint is derived from the raw title; trimming is performed only by the operation decoder.
|
|
56
|
-
|
|
57
|
-
Supply `prepare: host => receipts(host.db, host.tenant, host.actor, host.role).prepare(host.invocation)` and `replayResult: document` in the command. The retained final result is validated separately on replay. A compatible retry returns it without another business write, result transform, or execution audit. Authorization still runs before receipt replay: the adapter checks the current owner role and the trusted tenant/actor scope before returning a retained result. Stale versions, conflicting fingerprints, malformed results, pending rows, and duplicate identities fail explicitly.
|
|
58
|
-
|
|
59
|
-
The host owns authorization scope, compatible deployment policy, fingerprint equivalence, retention, and expiration. Deleting a receipt removes dedupe protection; do not promise indefinite exactly-once execution. A caller key alone is not authority. Replay skips execution guards and middleware; required permission checks, context resolution, and receipt authorization must still authorize the retained result. External actions cannot share this mutation transaction; use provider dedupe or provider-specific reconciliation when a response is lost.
|
|
60
|
-
|
|
61
|
-
## Run native workflow steps
|
|
62
|
-
|
|
63
|
-
Import `workflowBinding`, `workflowExecution`, and `runWorkflowStep` from `cvx-kit/workflow`. Pair the operation's explicit wire schemas and version metadata with an actual generated query, mutation, or action reference. The rename fixture binds `internal.integratedRename.applyRename`, the same registered audited mutation reached by the direct API and selected tool. A wrong kind is a compile error. The separate create example demonstrates receipt protection; rename does not add a receipt.
|
|
64
|
-
|
|
65
|
-
The `nativeWorkflowStep` function calls that registered reference through `stepBinding`. `startNativeRename` invokes the secured `api.integratedRename.start`, which issues the execution descriptor and starts the configured `WorkflowManager` component. Its native workflow waits for a host event, then executes this binding through an actual component step. Callers supply only the note ID and raw title. The execution capability, actor and tenant come from the authenticated host.
|
|
66
|
-
|
|
67
|
-
The host-issued execution descriptor contains `version`, operation/contract/binding versions, stable business `run`, opaque trusted `capability`, logical `occurrence`, and plain `args`. Reconstruct current authority and invocation services inside each newly executed native step. Replayed completed steps remain historical journal results. Status access also requires current authority.
|
|
68
|
-
|
|
69
|
-
Receipt identity uses stable business run plus logical occurrence within authority scope. A loop's second occurrence needs a different identity. Delivery attempt counts and host callback generations are telemetry/fencing data and must not change that business identity.
|
|
70
|
-
|
|
71
|
-
`runWorkflowStep` returns the raw wire result inferred from the generated reference (`ContractInput<Result>`); it does not apply the binding result transform on journal replay. `terminalWorkflowAttempt` validates a successful result against its result contract but returns the original raw wire value in `{ version: 1, kind: "succeeded", value }`. For a known terminal failure, its `terminal` callback must select safe details from the rejected native error. The failure contract decodes that projection, and the helper checks the decoded value is plain Convex data before returning `{ version: 1, kind: "failed", error }`. The failure is decoded and sanitized; success remains raw wire. Unknown or retryable errors must return `undefined` from the classifier so the original error escapes.
|
|
72
|
-
|
|
73
|
-
A typed-terminal mutation must reject its inner native mutation before an outer binding catches and projects the safe terminal outcome. Use `terminalWorkflowAttempt` with the outer mutation path only after the host has verified sub-mutation rollback and atomic journaling on its pinned component/backend. The action fallback requires evidence from `receiptWorkflowMutation` and a registered inner mutation that commits its U5 receipt with business/audit writes. Its outer action can fail after that commit but before journaling; such native failure does not establish rollback.
|
|
74
|
-
|
|
75
|
-
Action step retry defaults to disabled in `runWorkflowStep`, including when manager defaults enable retries. Opt in deliberately through native retry options. Declared terminal envelopes should not retry; transient actions may exhaust their opted-in attempts. Unknown provider outcomes require provider dedupe or reconciliation before retry.
|
|
76
|
-
|
|
77
|
-
Use `workflowStatusBindings` with host-owned transactional generation guards for callback, reconciliation, cancellation, and restart. A successful native journal value containing a terminal domain failure maps to failed host status. Failed callbacks leave native terminal status available for explicit authorized reconciliation. A committed receipt can be reported separately while native workflow status remains failed. Cancellation does not undo committed steps or running external requests; late completion cannot replace canceled host status or a newer generation.
|
|
78
|
-
|
|
79
|
-
The pinned native `WorkflowManager.restart` retains the original completion callback context. Incrementing the host generation does not update that context: the restarted completion callback still carries the old generation and is correctly ignored. The host must explicitly reconcile native status and retained outcomes using the new generation, or use a separately registered new run with fresh callback context. `workflowStatusBindings` does not automatically rebind native callback generations. Preserve the stable business receipt identity independently of callback fencing.
|
|
80
|
-
|
|
81
|
-
The native acceptance driver exercises paused-run recovery across a compatible redeployment and rejects changed recorded step arguments through the real journal. Listed compatibility metadata is an explicit host decision, not automatic compatibility discovery.
|
|
82
|
-
|
|
83
|
-
The actual rename declaration lives in `test/fixture-effect/convex/integratedRename.ts`. Its `commands.rename` resolves the injected `Notes` repository and produces only `{ title }`. The registered `applyRename` adapter supplies that repository per invocation, resolves the host-issued capability, rechecks current permission, and reconstructs the tenant/owner secured writer with native triggers. Permission, rename and actor audit execute inside the same native mutation. The asynchronous title decoder runs once there; neither the tool converter nor workflow binding normalizes it again. `rejectMissingRenameService` imports this exact registry and verifies that an API provider returning `Context.empty()` cannot close its required `Notes` service.
|
|
84
|
-
|
|
85
|
-
The native driver independently reads business, audit and trigger state after direct API, selected tool and workflow calls. It also pauses a real component workflow, revokes authority, and verifies the newly executed step leaves those writes unchanged. Completed journal results remain historical; current checks apply when the native mutation executes again.
|
|
86
|
-
|
|
87
|
-
## Select operation tools explicitly
|
|
88
|
-
|
|
89
|
-
Use `registry.expose(owner, key)` and `bindOperationExecutor` from `cvx-kit/agent-tools` to select one operation and bind its executor. `createOperationTools` exposes only the supplied entries. `renameDocumentTool` consumes `selectedRename`, exported by the actual injected rename registry, and invokes `api.integratedRename.rename` through `invokeNativeRename`. That secured API issues a host capability and calls `internal.integratedRename.applyRename`, exactly the mutation bound by the workflow step. There is one rename declaration and one audited native execution boundary across all three surfaces.
|
|
90
|
-
|
|
91
|
-
The separate `createDocumentTool` function binds the selected create operation to its real generated native function through `invokeNativeCreate`. The host supplies the idempotency key outside the tool arguments. Construct a tool binding for each logical create request. Reuse that request's key for compatible retries; give distinct requests distinct keys. Reusing one binding for independent writes can replay the earlier result or reject conflicting input.
|
|
92
|
-
|
|
93
|
-
Tool arguments carry only operation input. Tenant, actor, permissions, credentials, and service authority come from the trusted host and its secured reference. The owner and key pair identify the selected operation; they are correlation tags, not authorization credentials. The trusted host must bind an executor to the correct secured native reference. The adapter cannot authorize an arbitrary host callback or repair its public projection.
|
|
94
|
-
|
|
95
|
-
Supply a converter with `dialect: operationToolDialect` and a JSON schema representing the raw wire input. The fixture's title schema is:
|
|
96
|
-
|
|
97
|
-
```json
|
|
98
|
-
{
|
|
99
|
-
"type": "object",
|
|
100
|
-
"properties": { "title": { "type": "string" } },
|
|
101
|
-
"required": ["title"],
|
|
102
|
-
"additionalProperties": false
|
|
103
|
-
}
|
|
104
|
-
```
|
|
105
|
-
|
|
106
|
-
Do not turn the selected operation's title transforms into an unrelated tool-side decoder. The same registered operation remains responsible for decoding, authority, result validation, receipt completion, and audit. Tool invocation returns `Success`, a validated declared failure when a contract is supplied, or `UnknownFailure`; mutation failures must cross the native transaction boundary before becoming failure data.
|
|
107
|
-
|
|
108
|
-
No agent framework, model invocation, React state, or automatic registry exposure is required by these server bindings. The application supplies its agent framework adapter and explicitly chooses which secured references an agent may execute.
|
|
109
|
-
|
|
110
|
-
## Compile the complete example
|
|
111
|
-
|
|
112
|
-
The module uses public package imports and local generated application references. Preserve this relative layout in a consumer fixture:
|
|
113
|
-
|
|
114
|
-
```text
|
|
115
|
-
consumer/
|
|
116
|
-
domain-operation-guide-types.ts
|
|
117
|
-
fixture-effect/convex/
|
|
118
|
-
_generated/{api.d.ts,api.js,server.d.ts,server.js,dataModel.d.ts}
|
|
119
|
-
actions.ts, crud.ts, domain.ts, functions.ts, http.ts
|
|
120
|
-
idempotency.ts, operationTools.ts, workflowNative.ts, workflowProof.ts
|
|
121
|
-
schema.ts
|
|
122
|
-
```
|
|
123
|
-
|
|
124
|
-
Copy the actual native fixture modules and generated files together: `api.d.ts` imports every registered module, and `server.d.ts` uses the generated model derived from `schema.ts`. Do not replace generated references with `any`, fabricated `FunctionReference` casts, or handwritten API stubs. Mount the workflow component through the fixture's `convex.config.ts` when generating or running the native app.
|
|
125
|
-
|
|
126
|
-
The checked versions are Effect `4.0.1`, Convex `1.45.0`, convex-helpers `0.1.124`, Zod `4.5.4`, and `@convex-dev/workflow` `0.4.6`; compilation also needs TypeScript (this checkout uses `7.0.2`) and `@types/node` (here `26.6.1`) because the registered action fixture uses `process`. Install the packed `cvx-kit` package and its required peers. Use ESM with strict TypeScript, bundler module resolution, ES2025 and DOM libraries, `types: ["node"]`, and no emit. An installed consumer must resolve `cvx-kit/*` through package exports without source path aliases.
|
|
127
|
-
|
|
128
|
-
The module includes compile-only negative assertions for a missing service provider and incompatible native function kind, arguments, and result. Preserve the `@ts-expect-error` assertions in the compilation fixture. They are not application registrations to execute. Typechecking proves public API compatibility; it does not establish native rollback, callback delivery, provider dedupe, or runtime acceptance.
|
|
129
|
-
|
|
130
|
-
<details>
|
|
131
|
-
<summary>Complete server module and compile-only assertions</summary>
|
|
132
|
-
|
|
133
|
-
```ts
|
|
134
|
-
import { Context, Effect } from 'effect'
|
|
135
|
-
import { z } from 'zod'
|
|
136
|
-
import { ConvexError, v, type Value } from 'convex/values'
|
|
137
|
-
import {
|
|
138
|
-
defineSchema,
|
|
139
|
-
defineTable,
|
|
140
|
-
mutationGeneric,
|
|
141
|
-
queryGeneric,
|
|
142
|
-
actionGeneric,
|
|
143
|
-
internalMutationGeneric,
|
|
144
|
-
internalQueryGeneric,
|
|
145
|
-
internalActionGeneric,
|
|
146
|
-
type DataModelFromSchemaDefinition,
|
|
147
|
-
type MutationBuilder,
|
|
148
|
-
type QueryBuilder,
|
|
149
|
-
type ActionBuilder,
|
|
150
|
-
type GenericDatabaseWriter,
|
|
151
|
-
type GenericDatabaseReader,
|
|
152
|
-
} from 'convex/server'
|
|
153
|
-
import { createAuthFunctions } from 'cvx-kit/auth'
|
|
154
|
-
import {
|
|
155
|
-
createEffectFoundation,
|
|
156
|
-
createEffectCrud,
|
|
157
|
-
effectApiBuilder,
|
|
158
|
-
effectZodApiBuilder,
|
|
159
|
-
} from 'cvx-kit/effect'
|
|
160
|
-
import { defineErrorContract } from 'cvx-kit/errors'
|
|
161
|
-
import { zid, tenantTable } from 'cvx-kit/zod-table'
|
|
162
|
-
import { zodContract, standardContract, convexContract } from 'cvx-kit/contracts'
|
|
163
|
-
import {
|
|
164
|
-
captureIdempotencyInvocation,
|
|
165
|
-
transactionalIdempotency,
|
|
166
|
-
type IdempotencyInvocation,
|
|
167
|
-
} from 'cvx-kit/idempotency'
|
|
168
|
-
import { workflowBinding, runWorkflowStep, type WorkflowExecution } from 'cvx-kit/workflow'
|
|
169
|
-
import {
|
|
170
|
-
bindOperationExecutor,
|
|
171
|
-
createOperationTools,
|
|
172
|
-
operationToolDialect,
|
|
173
|
-
} from 'cvx-kit/agent-tools'
|
|
174
|
-
import type { WorkflowCtx } from '@convex-dev/workflow'
|
|
175
|
-
import { internal, api } from './fixture-effect/convex/_generated/api'
|
|
176
|
-
import type { ActionCtx } from './fixture-effect/convex/_generated/server'
|
|
177
|
-
import { nativeIdempotencyOperation } from './fixture-effect/convex/idempotency'
|
|
178
|
-
import {
|
|
179
|
-
commands as integratedRenameCommands,
|
|
180
|
-
selectedRename,
|
|
181
|
-
type Host as IntegratedRenameHost,
|
|
182
|
-
} from './fixture-effect/convex/integratedRename'
|
|
183
|
-
import { internalMutation as fixtureInternalMutation } from './fixture-effect/convex/_generated/server'
|
|
184
|
-
import type { Id as FixtureId } from './fixture-effect/convex/_generated/dataModel'
|
|
185
|
-
|
|
186
|
-
// Standalone type fixture: these are real native builders specialized to this schema.
|
|
187
|
-
// An application imports its equivalent generated builders from ./_generated/server.
|
|
188
|
-
const schema = defineSchema({
|
|
189
|
-
documents: defineTable({ tenant: v.string(), title: v.string() }).index('by_tenant', ['tenant']),
|
|
190
|
-
audits: defineTable({ tenant: v.string(), actor: v.string(), operation: v.string() }),
|
|
191
|
-
receipts: defineTable({
|
|
192
|
-
tenant: v.string(),
|
|
193
|
-
operation: v.string(),
|
|
194
|
-
scope: v.string(),
|
|
195
|
-
key: v.string(),
|
|
196
|
-
versions: v.object({
|
|
197
|
-
operation: v.string(),
|
|
198
|
-
contract: v.string(),
|
|
199
|
-
binding: v.string(),
|
|
200
|
-
fingerprintPolicy: v.string(),
|
|
201
|
-
}),
|
|
202
|
-
fingerprint: v.string(),
|
|
203
|
-
state: v.union(v.literal('pending'), v.literal('completed')),
|
|
204
|
-
result: v.optional(v.any()),
|
|
205
|
-
}).index('by_identity', ['operation', 'scope', 'key']),
|
|
206
|
-
})
|
|
207
|
-
type Model = DataModelFromSchemaDefinition<typeof schema>
|
|
208
|
-
const mutation: MutationBuilder<Model, 'public'> = mutationGeneric
|
|
209
|
-
const query: QueryBuilder<Model, 'public'> = queryGeneric
|
|
210
|
-
const action: ActionBuilder<Model, 'public'> = actionGeneric
|
|
211
|
-
const internalMutation: MutationBuilder<Model, 'internal'> = internalMutationGeneric
|
|
212
|
-
const internalQuery: QueryBuilder<Model, 'internal'> = internalQueryGeneric
|
|
213
|
-
const internalAction: ActionBuilder<Model, 'internal'> = internalActionGeneric
|
|
214
|
-
const errors = defineErrorContract({
|
|
215
|
-
MISSING: { message: 'Document missing', details: { id: z.string() } },
|
|
216
|
-
})
|
|
217
|
-
const document = z.object({ id: zid('documents'), title: z.string() }).strict()
|
|
218
|
-
const createInput = z.object({ title: z.string().trim().min(1) }).strict()
|
|
219
|
-
const renameInput = z.object({ id: zid('documents'), title: z.string().trim().min(1) }).strict()
|
|
220
|
-
const getInput = z.object({ id: zid('documents') }).strict()
|
|
221
|
-
type Document = z.infer<typeof document>
|
|
222
|
-
interface Repository {
|
|
223
|
-
create(title: string): Promise<Document>
|
|
224
|
-
rename(id: Document['id'], title: string): Promise<Document>
|
|
225
|
-
get(id: Document['id']): Promise<Document | null>
|
|
226
|
-
}
|
|
227
|
-
class Documents extends Context.Service<Documents, Repository>()('Documents') {}
|
|
228
|
-
class DocumentReader extends Context.Service<DocumentReader, Pick<Repository, 'get'>>()(
|
|
229
|
-
'DocumentReader',
|
|
230
|
-
) {}
|
|
231
|
-
type ReadHost = { db: GenericDatabaseReader<Model> }
|
|
232
|
-
const bounds = {
|
|
233
|
-
maxDepth: 8,
|
|
234
|
-
maxNodes: 100,
|
|
235
|
-
maxBytes: 4096,
|
|
236
|
-
maxArrayLength: 20,
|
|
237
|
-
maxObjectFields: 20,
|
|
238
|
-
}
|
|
239
|
-
function receipts(
|
|
240
|
-
db: GenericDatabaseWriter<Model>,
|
|
241
|
-
tenant: string,
|
|
242
|
-
actor: string,
|
|
243
|
-
role: 'owner' | null,
|
|
244
|
-
) {
|
|
245
|
-
const trustedScope = JSON.stringify([tenant, actor])
|
|
246
|
-
return transactionalIdempotency({
|
|
247
|
-
final: { replayResult: document, encode: (value) => value },
|
|
248
|
-
authorize: (invocation) => {
|
|
249
|
-
if (role !== 'owner') throw new Error('Receipt permission denied')
|
|
250
|
-
if (invocation.identity.scope !== trustedScope) throw new Error('Receipt scope denied')
|
|
251
|
-
},
|
|
252
|
-
lookup: (identity) =>
|
|
253
|
-
db
|
|
254
|
-
.query('receipts')
|
|
255
|
-
.withIndex('by_identity', (q) =>
|
|
256
|
-
q.eq('operation', identity.operation).eq('scope', identity.scope).eq('key', identity.key),
|
|
257
|
-
)
|
|
258
|
-
.take(2),
|
|
259
|
-
fingerprint: (canonical) => canonical,
|
|
260
|
-
claim: (identity, receipt) =>
|
|
261
|
-
db.insert('receipts', {
|
|
262
|
-
tenant,
|
|
263
|
-
...identity,
|
|
264
|
-
versions: receipt.versions,
|
|
265
|
-
fingerprint: receipt.fingerprint,
|
|
266
|
-
state: 'pending',
|
|
267
|
-
}),
|
|
268
|
-
complete: (id, receipt) => db.patch(id, { state: 'completed', result: receipt.result }),
|
|
269
|
-
})
|
|
270
|
-
}
|
|
271
|
-
type Host = {
|
|
272
|
-
db: GenericDatabaseWriter<Model>
|
|
273
|
-
tenant: string
|
|
274
|
-
actor: string
|
|
275
|
-
role: 'owner' | null
|
|
276
|
-
repository: Repository
|
|
277
|
-
invocation: IdempotencyInvocation
|
|
278
|
-
}
|
|
279
|
-
const foundation = createEffectFoundation({
|
|
280
|
-
observability: {
|
|
281
|
-
enabled: false,
|
|
282
|
-
classifyError: () => ({ outcome: 'failed', errorCode: 'DOCUMENT_FAILURE' }),
|
|
283
|
-
},
|
|
284
|
-
writeAudit: (host: Host, entry) =>
|
|
285
|
-
host.db
|
|
286
|
-
.insert('audits', { tenant: host.tenant, actor: host.actor, operation: entry.operation })
|
|
287
|
-
.then(() => undefined),
|
|
288
|
-
})
|
|
289
|
-
export const ordinaryCommands = foundation.Command({
|
|
290
|
-
context: (host: Host) => host,
|
|
291
|
-
operations: ({ command }) => ({
|
|
292
|
-
create: command({
|
|
293
|
-
input: createInput,
|
|
294
|
-
result: document,
|
|
295
|
-
replayResult: document,
|
|
296
|
-
classification: 'business',
|
|
297
|
-
prepare: (host) =>
|
|
298
|
-
receipts(host.db, host.tenant, host.actor, host.role).prepare(host.invocation),
|
|
299
|
-
handler: async (input, host) => host.repository.create(input.title),
|
|
300
|
-
audit: ({ result }, host) => ({
|
|
301
|
-
operation: 'documents.create',
|
|
302
|
-
actorId: host.actor,
|
|
303
|
-
aggregate: { type: 'document', id: result.id },
|
|
304
|
-
payload: {},
|
|
305
|
-
}),
|
|
306
|
-
}),
|
|
307
|
-
}),
|
|
308
|
-
})
|
|
309
|
-
export const commands = foundation.Command({
|
|
310
|
-
context: (host: Host) => host,
|
|
311
|
-
operations: ({ command }) => ({
|
|
312
|
-
create: command({
|
|
313
|
-
input: createInput,
|
|
314
|
-
result: document,
|
|
315
|
-
replayResult: document,
|
|
316
|
-
classification: 'business',
|
|
317
|
-
prepare: (host) =>
|
|
318
|
-
receipts(host.db, host.tenant, host.actor, host.role).prepare(host.invocation),
|
|
319
|
-
handler: Effect.fn('Documents.create')(function* (input: { title: string }) {
|
|
320
|
-
const repository = yield* Documents
|
|
321
|
-
return yield* Effect.promise(() => repository.create(input.title))
|
|
322
|
-
}),
|
|
323
|
-
audit: ({ result }, host) => ({
|
|
324
|
-
operation: 'documents.write',
|
|
325
|
-
actorId: host.actor,
|
|
326
|
-
aggregate: { type: 'document', id: result.id },
|
|
327
|
-
payload: {},
|
|
328
|
-
}),
|
|
329
|
-
}),
|
|
330
|
-
rename: command({
|
|
331
|
-
input: renameInput,
|
|
332
|
-
result: document,
|
|
333
|
-
classification: 'business',
|
|
334
|
-
handler: (input) =>
|
|
335
|
-
Effect.gen(function* () {
|
|
336
|
-
const repository = yield* Documents
|
|
337
|
-
return yield* Effect.promise(() => repository.rename(input.id, input.title))
|
|
338
|
-
}),
|
|
339
|
-
audit: ({ result }, host) => ({
|
|
340
|
-
operation: 'documents.write',
|
|
341
|
-
actorId: host.actor,
|
|
342
|
-
aggregate: { type: 'document', id: result.id },
|
|
343
|
-
payload: {},
|
|
344
|
-
}),
|
|
345
|
-
}),
|
|
346
|
-
}),
|
|
347
|
-
})
|
|
348
|
-
export const queries = foundation.Query({
|
|
349
|
-
context: (host: ReadHost) => host,
|
|
350
|
-
operations: ({ query }) => ({
|
|
351
|
-
get: query({
|
|
352
|
-
input: getInput,
|
|
353
|
-
result: document,
|
|
354
|
-
handler: ({ id }) =>
|
|
355
|
-
Effect.gen(function* () {
|
|
356
|
-
const repository = yield* DocumentReader
|
|
357
|
-
const value = yield* Effect.promise(() => repository.get(id))
|
|
358
|
-
if (!value) return yield* Effect.fail(errors.create('MISSING', { id }))
|
|
359
|
-
return value
|
|
360
|
-
}),
|
|
361
|
-
}),
|
|
362
|
-
}),
|
|
363
|
-
})
|
|
364
|
-
function repository(db: GenericDatabaseWriter<Model>, tenant: string): Repository {
|
|
365
|
-
return {
|
|
366
|
-
create: async (title) => ({ id: await db.insert('documents', { tenant, title }), title }),
|
|
367
|
-
rename: async (id, title) => {
|
|
368
|
-
await db.patch(id, { title })
|
|
369
|
-
return { id, title }
|
|
370
|
-
},
|
|
371
|
-
get: async (id) => {
|
|
372
|
-
const row = await db.get(id)
|
|
373
|
-
return row ? { id: row._id, title: row.title } : null
|
|
374
|
-
},
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
const auth = createAuthFunctions<Model, 'owner'>({
|
|
378
|
-
query,
|
|
379
|
-
mutation,
|
|
380
|
-
action,
|
|
381
|
-
internalQuery,
|
|
382
|
-
internalMutation,
|
|
383
|
-
internalAction,
|
|
384
|
-
getAuthUser: async (ctx) => {
|
|
385
|
-
const identity = await ctx.auth.getUserIdentity()
|
|
386
|
-
return identity ? { id: identity.subject } : null
|
|
387
|
-
},
|
|
388
|
-
mapRole: (role) => (role === 'owner' ? 'owner' : null),
|
|
389
|
-
adminRoles: ['owner'],
|
|
390
|
-
security: {
|
|
391
|
-
tenancy: { tables: ['documents', 'receipts', 'audits'] },
|
|
392
|
-
rules: (bundle) => ({
|
|
393
|
-
documents: {
|
|
394
|
-
insert: async () => bundle.role === 'owner',
|
|
395
|
-
modify: async () => bundle.role === 'owner',
|
|
396
|
-
},
|
|
397
|
-
receipts: {
|
|
398
|
-
insert: async () => bundle.role === 'owner',
|
|
399
|
-
modify: async () => bundle.role === 'owner',
|
|
400
|
-
},
|
|
401
|
-
audits: { insert: async () => bundle.role === 'owner' },
|
|
402
|
-
}),
|
|
403
|
-
},
|
|
404
|
-
})
|
|
405
|
-
const securedMutation = effectZodApiBuilder(auth.authMutation, {
|
|
406
|
-
services: (ctx) => Context.make(Documents, repository(ctx.db, ctx.tenant)),
|
|
407
|
-
errors,
|
|
408
|
-
})
|
|
409
|
-
export const create = securedMutation({
|
|
410
|
-
args: { key: z.string().min(1), title: z.string() },
|
|
411
|
-
returns: document,
|
|
412
|
-
handler: (ctx, args) => {
|
|
413
|
-
const invocation = captureIdempotencyInvocation({
|
|
414
|
-
binding: { kind: 'mutation', atomicity: 'same-mutation' },
|
|
415
|
-
identity: {
|
|
416
|
-
operation: 'documents.create',
|
|
417
|
-
scope: JSON.stringify([ctx.tenant, ctx.actor.userId]),
|
|
418
|
-
key: args.key,
|
|
419
|
-
},
|
|
420
|
-
versions: { operation: '1', contract: '1', binding: '1', fingerprintPolicy: 'raw-1' },
|
|
421
|
-
rawInput: { title: args.title },
|
|
422
|
-
canonical: { bounds },
|
|
423
|
-
})
|
|
424
|
-
const host: Host = {
|
|
425
|
-
db: ctx.db,
|
|
426
|
-
tenant: ctx.tenant,
|
|
427
|
-
actor: ctx.actor.userId,
|
|
428
|
-
role: ctx.role,
|
|
429
|
-
repository: repository(ctx.db, ctx.tenant),
|
|
430
|
-
invocation,
|
|
431
|
-
}
|
|
432
|
-
return commands.exec('create', { title: args.title }, host)
|
|
433
|
-
},
|
|
434
|
-
})
|
|
435
|
-
const securedQuery = effectZodApiBuilder(auth.authQuery, {
|
|
436
|
-
services: (ctx) =>
|
|
437
|
-
Context.make(DocumentReader, {
|
|
438
|
-
get: async (id) => {
|
|
439
|
-
const row = await ctx.db.get(id)
|
|
440
|
-
return row ? { id: row._id, title: row.title } : null
|
|
441
|
-
},
|
|
442
|
-
}),
|
|
443
|
-
errors,
|
|
444
|
-
})
|
|
445
|
-
export const get = securedQuery({
|
|
446
|
-
args: getInput.shape,
|
|
447
|
-
returns: document,
|
|
448
|
-
handler: (ctx, args) => queries.exec('get', args, ctx),
|
|
449
|
-
})
|
|
450
|
-
export function serverCreate(host: Host, input: { title: string }) {
|
|
451
|
-
return commands
|
|
452
|
-
.exec('create', input, host)
|
|
453
|
-
.pipe(Effect.provideService(Documents, host.repository))
|
|
454
|
-
}
|
|
455
|
-
const missingProvider = effectApiBuilder(internalMutation, { services: () => Context.empty() })
|
|
456
|
-
// @ts-expect-error The API cannot close an invocation while Documents remains unprovided.
|
|
457
|
-
missingProvider({
|
|
458
|
-
args: { id: v.id('documents') },
|
|
459
|
-
returns: v.object({ id: v.id('documents'), title: v.string() }),
|
|
460
|
-
handler: (_ctx, { id }) =>
|
|
461
|
-
Effect.gen(function* () {
|
|
462
|
-
const repository = yield* Documents
|
|
463
|
-
const value = yield* Effect.promise(() => repository.get(id))
|
|
464
|
-
if (!value) return yield* Effect.fail(errors.create('MISSING', { id }))
|
|
465
|
-
return value
|
|
466
|
-
}),
|
|
467
|
-
})
|
|
468
|
-
|
|
469
|
-
// References come from registered native functions.
|
|
470
|
-
const stepBinding = workflowBinding({
|
|
471
|
-
kind: 'mutation',
|
|
472
|
-
operation: 'integratedRename.rename',
|
|
473
|
-
operationVersion: '1',
|
|
474
|
-
contractVersion: '1',
|
|
475
|
-
bindingVersion: '1',
|
|
476
|
-
args: z.object({ id: z.string(), title: z.string() }).strict(),
|
|
477
|
-
result: z.object({ title: z.string() }).strict(),
|
|
478
|
-
reference: internal.integratedRename.applyRename,
|
|
479
|
-
})
|
|
480
|
-
export function nativeWorkflowStep(
|
|
481
|
-
step: WorkflowCtx,
|
|
482
|
-
execution: WorkflowExecution<{ id: FixtureId<'crudNotes'>; title: string }>,
|
|
483
|
-
) {
|
|
484
|
-
return runWorkflowStep(step, stepBinding, execution, { inline: true })
|
|
485
|
-
}
|
|
486
|
-
workflowBinding({
|
|
487
|
-
kind: 'action',
|
|
488
|
-
operation: 'integratedRename.rename',
|
|
489
|
-
operationVersion: '1',
|
|
490
|
-
contractVersion: '1',
|
|
491
|
-
bindingVersion: '1',
|
|
492
|
-
args: z.object({ id: z.string(), title: z.string() }).strict(),
|
|
493
|
-
result: z.object({ title: z.string() }).strict(),
|
|
494
|
-
// @ts-expect-error A registered mutation reference cannot be an action binding.
|
|
495
|
-
reference: internal.integratedRename.applyRename,
|
|
496
|
-
})
|
|
497
|
-
|
|
498
|
-
workflowBinding({
|
|
499
|
-
...stepBinding,
|
|
500
|
-
args: z.object({ id: z.string(), title: z.number() }),
|
|
501
|
-
// @ts-expect-error The real mutation takes a string title, not a numeric title.
|
|
502
|
-
reference: internal.integratedRename.applyRename,
|
|
503
|
-
})
|
|
504
|
-
workflowBinding({
|
|
505
|
-
...stepBinding,
|
|
506
|
-
result: z.object({ title: z.number() }),
|
|
507
|
-
// @ts-expect-error The real mutation returns a string title, not a numeric title.
|
|
508
|
-
reference: internal.integratedRename.applyRename,
|
|
509
|
-
})
|
|
510
|
-
|
|
511
|
-
// This registry is the exact declaration executed by the registered acceptance fixture.
|
|
512
|
-
// The native adapter provides its Notes repository for each new invocation.
|
|
513
|
-
export function rejectMissingRenameService(host: IntegratedRenameHost) {
|
|
514
|
-
const missingRenameProvider = effectApiBuilder(fixtureInternalMutation, {
|
|
515
|
-
services: () => Context.empty(),
|
|
516
|
-
})
|
|
517
|
-
// @ts-expect-error The actual rename operation still requires its injected Notes service.
|
|
518
|
-
missingRenameProvider({
|
|
519
|
-
args: { id: v.string(), title: v.string() },
|
|
520
|
-
returns: v.object({ title: v.string() }),
|
|
521
|
-
handler: (_ctx, input) => integratedRenameCommands.exec('rename', input, host),
|
|
522
|
-
})
|
|
523
|
-
}
|
|
524
|
-
export function invokeNativeRename(ctx: ActionCtx, input: { id: string; title: string }) {
|
|
525
|
-
return ctx.runMutation(api.integratedRename.rename, input)
|
|
526
|
-
}
|
|
527
|
-
export function startNativeRename(ctx: ActionCtx, input: { id: string; title: string }) {
|
|
528
|
-
return ctx.runMutation(api.integratedRename.start, input)
|
|
529
|
-
}
|
|
530
|
-
export function renameDocumentTool(ctx: ActionCtx) {
|
|
531
|
-
const executor = bindOperationExecutor(selectedRename, {
|
|
532
|
-
owner: selectedRename.owner,
|
|
533
|
-
key: selectedRename.key,
|
|
534
|
-
execute: (input) => invokeNativeRename(ctx, input),
|
|
535
|
-
})
|
|
536
|
-
return createOperationTools({
|
|
537
|
-
renameDocument: {
|
|
538
|
-
operation: selectedRename,
|
|
539
|
-
executor,
|
|
540
|
-
description: 'Rename through the same authorized audited native boundary',
|
|
541
|
-
converter: {
|
|
542
|
-
dialect: operationToolDialect,
|
|
543
|
-
convert: () => ({
|
|
544
|
-
type: 'object',
|
|
545
|
-
properties: { id: { type: 'string' }, title: { type: 'string' } },
|
|
546
|
-
required: ['id', 'title'],
|
|
547
|
-
additionalProperties: false,
|
|
548
|
-
}),
|
|
549
|
-
},
|
|
550
|
-
},
|
|
551
|
-
})
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
// Tools explicitly select a raw descriptor and execute the real secured native reference.
|
|
555
|
-
export function invokeNativeCreate(ctx: ActionCtx, key: string, payload: { title: string }) {
|
|
556
|
-
return ctx.runMutation(api.idempotency.save, { key, payload })
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
export function createDocumentTool(ctx: ActionCtx, key: string) {
|
|
560
|
-
const selected = nativeIdempotencyOperation
|
|
561
|
-
const executor = bindOperationExecutor(selected, {
|
|
562
|
-
owner: selected.owner,
|
|
563
|
-
key: selected.key,
|
|
564
|
-
execute: (payload) => invokeNativeCreate(ctx, key, payload),
|
|
565
|
-
})
|
|
566
|
-
return createOperationTools({
|
|
567
|
-
createDocument: {
|
|
568
|
-
operation: selected,
|
|
569
|
-
executor,
|
|
570
|
-
description: 'Create an authorized document title',
|
|
571
|
-
converter: {
|
|
572
|
-
dialect: operationToolDialect,
|
|
573
|
-
convert: () => ({
|
|
574
|
-
type: 'object',
|
|
575
|
-
properties: { title: { type: 'string' } },
|
|
576
|
-
required: ['title'],
|
|
577
|
-
additionalProperties: false,
|
|
578
|
-
}),
|
|
579
|
-
},
|
|
580
|
-
},
|
|
581
|
-
})
|
|
582
|
-
}
|
|
583
|
-
export function decodeRemoteFailure(error: ConvexError<Value>) {
|
|
584
|
-
return errors.decode(error.data)
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
const documentTable = tenantTable('documents', () => ({ title: z.string() }), {
|
|
588
|
-
commandFields: ['title'],
|
|
589
|
-
serverFields: [],
|
|
590
|
-
publicFields: ['title'],
|
|
591
|
-
})
|
|
592
|
-
export const documentCrud = createEffectCrud({
|
|
593
|
-
foundation,
|
|
594
|
-
table: documentTable,
|
|
595
|
-
context: (host: Host) => host,
|
|
596
|
-
aggregateType: 'document',
|
|
597
|
-
actor: (host) => host.actor,
|
|
598
|
-
enrich: (host) => ({ tenant: host.tenant }),
|
|
599
|
-
read: {
|
|
600
|
-
maxPageSize: 20,
|
|
601
|
-
project: (row) => ({ title: row.title }),
|
|
602
|
-
get: (host, id) => host.db.get(id),
|
|
603
|
-
list: (host, options) =>
|
|
604
|
-
host.db
|
|
605
|
-
.query('documents')
|
|
606
|
-
.withIndex('by_tenant', (q) => q.eq('tenant', host.tenant))
|
|
607
|
-
.paginate(options),
|
|
608
|
-
},
|
|
609
|
-
})
|
|
610
|
-
export const rawTitleContract = zodContract(z.string().trim())
|
|
611
|
-
export const standardTitleContract = standardContract(z.string())
|
|
612
|
-
export const nativeDocumentIdContract = convexContract(v.id('documents'))
|
|
613
|
-
```
|
|
614
|
-
|
|
615
|
-
</details>
|