cvx-kit 0.0.7 → 0.1.0
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 +104 -0
- package/README.md +11 -10
- package/ROADMAP.md +126 -0
- package/dist/agent-tools.d.mts +45 -0
- package/dist/agent-tools.mjs +2 -0
- package/dist/agent-tools.mjs.map +1 -0
- package/dist/auth.d.mts +9 -1
- package/dist/auth.mjs +1 -1
- package/dist/auth.mjs.map +1 -1
- package/dist/components/foundation/client.d.mts +44 -3
- 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 +2 -1
- 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/query/query.d.mts +29 -2
- package/dist/components/foundation/modules/query/query.mjs +1 -1
- package/dist/components/foundation/modules/query/query.mjs.map +1 -1
- package/dist/crud.d.mts +79 -0
- package/dist/crud.mjs +2 -0
- package/dist/crud.mjs.map +1 -0
- package/dist/index.d.mts +9 -3
- package/dist/index.mjs +1 -1
- package/dist/middleware.d.mts +46 -0
- package/dist/middleware.mjs +2 -0
- package/dist/middleware.mjs.map +1 -0
- package/dist/state-machine.d.mts +18 -0
- package/dist/state-machine.mjs +2 -0
- package/dist/state-machine.mjs.map +1 -0
- package/dist/tenancy.d.mts +1 -1
- package/dist/webhooks.d.mts +164 -0
- package/dist/webhooks.mjs +2 -0
- package/dist/webhooks.mjs.map +1 -0
- package/dist/zod-table.d.mts +34 -3
- package/dist/zod-table.mjs +1 -1
- package/dist/zod-table.mjs.map +1 -1
- package/docs/agent-tools.md +41 -0
- package/docs/commands.md +48 -0
- package/docs/crud.md +62 -0
- package/docs/llms.md +16 -3
- package/docs/plans/2026-08-14-001-feat-v0.1.0-feature-set-plan.md +461 -0
- package/docs/upgrading.md +152 -0
- package/docs/webhooks.md +58 -0
- package/package.json +21 -1
- package/scripts/extract-changelog.mjs +29 -0
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "feat: v0.1.0 feature set — the six roadmap helpers + release discipline"
|
|
3
|
+
type: feat
|
|
4
|
+
date: 2026-08-14
|
|
5
|
+
artifact_contract: ce-unified-plan/v1
|
|
6
|
+
artifact_readiness: implementation-ready
|
|
7
|
+
execution: code
|
|
8
|
+
product_contract_source: ce-plan-bootstrap
|
|
9
|
+
origin: ROADMAP.md
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# feat: v0.1.0 feature set — the six roadmap helpers + release discipline
|
|
13
|
+
|
|
14
|
+
## Summary
|
|
15
|
+
|
|
16
|
+
Implement cvx-kit `v0.1.0` as defined in `ROADMAP.md`: the six planned
|
|
17
|
+
features (pagination bridge, CRUD command factory, state-machine helper,
|
|
18
|
+
rate-limit middleware factory, webhook boundary helper, agent tool
|
|
19
|
+
generator), the typed-middleware-context tightening they build on, and the
|
|
20
|
+
release discipline that starts at 0.1.0 — `CHANGELOG.md`, GitHub Releases on
|
|
21
|
+
every tag, and a shipped upgrade guide (the "migration file") documenting the
|
|
22
|
+
old way → new way for consumers on 0.0.x.
|
|
23
|
+
|
|
24
|
+
**Product Contract preservation:** n/a — solo plan; scope confirmed in-session.
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Problem Frame
|
|
29
|
+
|
|
30
|
+
cvx-kit 0.0.8 has the structural kernel (boundaries, auth, tenancy, commands,
|
|
31
|
+
middleware) but consumers still hand-write the same per-entity code: CRUD
|
|
32
|
+
operations, transition guards, pagination plumbing, rate limits, webhook
|
|
33
|
+
dedup, and agent tool definitions. 0.1.0 is "the big one" (see
|
|
34
|
+
`docs/explain/roadmap-to-1.0.0.html`): ship the full helper surface in one
|
|
35
|
+
release so consumers adopt a complete kit once, and switch on the semver
|
|
36
|
+
discipline (breaking = minor only, CHANGELOG, GitHub Releases) at the same
|
|
37
|
+
moment.
|
|
38
|
+
|
|
39
|
+
## Requirements
|
|
40
|
+
|
|
41
|
+
- R1. `paginated(dto)` helpers + a `paginate` terminal on `include` give
|
|
42
|
+
cursor pagination with zod-typed args/returns.
|
|
43
|
+
- R2. `createCrudCommands` generates create/update/archive operations from a
|
|
44
|
+
`zodTable`, fully inside the audited command pipeline.
|
|
45
|
+
- R3. `createStateMachine` produces typed transition predicates/assertions
|
|
46
|
+
from a constants tuple, usable as command guards.
|
|
47
|
+
- R4. A packaged rate-limit middleware factory works on both Command and
|
|
48
|
+
Query middleware seams, keyed by ctx (default `ctx.tenant`).
|
|
49
|
+
- R5. A webhook boundary helper provides signature verification + natural-key
|
|
50
|
+
dedup + delegation to internal functions.
|
|
51
|
+
- R6. `createAgentTools` emits agent tool definitions from the existing
|
|
52
|
+
`tools.*` masks whose handlers route through command executors —
|
|
53
|
+
shape-compatible with `@convex-dev/agent`, no dependency on it.
|
|
54
|
+
- R7. Middleware context enrichment is typed: `next({ context })` widens the
|
|
55
|
+
downstream ctx type; the existing loose form keeps compiling.
|
|
56
|
+
- R8. `CHANGELOG.md` exists; every release tag gets a GitHub Release whose
|
|
57
|
+
notes come from the CHANGELOG (user-directed: changelog also on GitHub
|
|
58
|
+
versioning).
|
|
59
|
+
- R9. A shipped upgrade guide documents 0.0.x → 0.1.0 per feature (old way →
|
|
60
|
+
new way), and every new feature has a docs page/section + `llms.md` entry
|
|
61
|
+
+ tests.
|
|
62
|
+
- R10. All existing invariants hold: facade-only foundation runtime exports,
|
|
63
|
+
middleware inside pipeline invariants, strict boundary schemas, tarball =
|
|
64
|
+
dist + docs + `src/test.ts` only.
|
|
65
|
+
|
|
66
|
+
## Key Technical Decisions
|
|
67
|
+
|
|
68
|
+
- KTD1. **One new runtime dependency only: `@convex-dev/rate-limiter`**
|
|
69
|
+
(session-settled: user-approved — chosen over also depending on
|
|
70
|
+
`@convex-dev/agent`: the rate-limit factory calls the limiter at runtime,
|
|
71
|
+
while agent tools can be emitted shape-compatible). The host mounts the
|
|
72
|
+
rate-limiter component; the kit factory receives the constructed
|
|
73
|
+
`RateLimiter` instance (injection, per the kit's no-singletons rule).
|
|
74
|
+
- KTD2. **Agent tool generator is dependency-free** (session-settled:
|
|
75
|
+
user-approved). It returns `{ name, description, args, handler }` records
|
|
76
|
+
where `args` is a zod schema (using `jsonSafeZid` internally) — directly
|
|
77
|
+
consumable by `@convex-dev/agent`'s `createTool` and adaptable to ai-sdk
|
|
78
|
+
`tool()`. Verified against `@convex-dev/agent@0.6.4`'s contract.
|
|
79
|
+
- KTD3. **Typed middleware context lands first** (session-settled:
|
|
80
|
+
user-directed — pulled from the consolidation list because the rate-limit
|
|
81
|
+
factory should land on final typing, not the loose v1). Backward
|
|
82
|
+
compatible: an untyped `next({ context })` still compiles.
|
|
83
|
+
- KTD4. **New helpers ship as subpath exports** matching the existing
|
|
84
|
+
pattern (`cvx-kit/pagination` style is rejected — see below): `crud`,
|
|
85
|
+
`state-machine`, `middleware`, `webhooks`, `agent-tools` each get a
|
|
86
|
+
subpath + index re-export; pagination joins existing homes (`zod-table`
|
|
87
|
+
for schema helpers, `auth` for the include terminal) rather than a new
|
|
88
|
+
module, since it derives from those seams.
|
|
89
|
+
- KTD5a. convex-helpers' same-named `crud` helper was considered and
|
|
90
|
+
rejected: it bypasses the command pipeline (no audits, no guards, hard
|
|
91
|
+
deletes) — `src/docs/crud.md` states the distinction.
|
|
92
|
+
- KTD5. **CRUD factory takes the Foundation-bound `Command` class as input**
|
|
93
|
+
— `createCrudCommands({ Command, table, ... })` — never imports it. Keeps
|
|
94
|
+
the facade-only rule intact: the factory is a *user* of the destructured
|
|
95
|
+
facade, exactly like a domain module.
|
|
96
|
+
- KTD6. **Webhook dedup storage is host-owned**: the helper exports a
|
|
97
|
+
`webhookEventsTable()` (a plain `zodTable`) the host registers in its
|
|
98
|
+
schema, and the boundary helper receives the table name + internal
|
|
99
|
+
function references. The kit gains no tables (components own zero
|
|
100
|
+
application capability).
|
|
101
|
+
- KTD7. **GitHub Releases are created by the existing release workflow**
|
|
102
|
+
(user-directed: "the changelog also should be on the github versioning") —
|
|
103
|
+
a step after successful publish extracts the current version's CHANGELOG
|
|
104
|
+
section and runs `gh release create` with it. The deprecated action pins
|
|
105
|
+
(`actions/checkout`, `actions/setup-node`) get bumped in the same touch.
|
|
106
|
+
- KTD8. **Upgrade guide ships in package docs** (session-settled:
|
|
107
|
+
user-approved — chosen over root-only `MIGRATING.md`): authored at
|
|
108
|
+
`src/docs/upgrading.md`, synced to `docs/` like the rest, linked from
|
|
109
|
+
README and `llms.md`.
|
|
110
|
+
|
|
111
|
+
## High-Level Technical Design
|
|
112
|
+
|
|
113
|
+
How the six features attach to existing seams (nothing new is load-bearing —
|
|
114
|
+
every feature is a consumer of an existing extension point):
|
|
115
|
+
|
|
116
|
+
```mermaid
|
|
117
|
+
flowchart LR
|
|
118
|
+
subgraph existing [Existing seams — unchanged]
|
|
119
|
+
ZT[zodTable masks]
|
|
120
|
+
INC[include builder]
|
|
121
|
+
CMD[Foundation Command\npipeline + guards]
|
|
122
|
+
MW[middleware chain]
|
|
123
|
+
SYS[system* constructors]
|
|
124
|
+
end
|
|
125
|
+
P[pagination bridge] --> ZT
|
|
126
|
+
P --> INC
|
|
127
|
+
CRUD[createCrudCommands] --> CMD
|
|
128
|
+
SM[createStateMachine] -->|guard| CMD
|
|
129
|
+
RL[rateLimit factory] --> MW
|
|
130
|
+
WH[webhook boundary] --> SYS
|
|
131
|
+
AT[createAgentTools] -->|handlers| CMD
|
|
132
|
+
AT --> ZT
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Directional guidance, not implementation specification.
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## Implementation Units
|
|
140
|
+
|
|
141
|
+
### U1. Typed middleware context accumulation
|
|
142
|
+
|
|
143
|
+
**Goal:** `next({ context })` widens the downstream ctx type; guards and
|
|
144
|
+
handlers after an enriching middleware see the enrichment in types.
|
|
145
|
+
**Requirements:** R7, R10. **Dependencies:** none.
|
|
146
|
+
**Files:** `src/components/foundation/client.ts`,
|
|
147
|
+
`src/components/foundation/modules/query/query.ts`,
|
|
148
|
+
`test/command.test.ts`, `test/query-middleware.test.ts`.
|
|
149
|
+
**Approach:** Generic parameter on the middleware helper
|
|
150
|
+
(`Command.middleware<Ctx, Extension>`) so the middleware's declared extension
|
|
151
|
+
threads into the chain's context type; runtime unchanged (merge already
|
|
152
|
+
works). Keep `CommandMiddleware`/`QueryMiddleware` assignable from the loose
|
|
153
|
+
form. TanStack-style full inference across an array is explicitly NOT the
|
|
154
|
+
goal for 0.1.0 — per-middleware declared extensions are enough.
|
|
155
|
+
**Test scenarios:**
|
|
156
|
+
- A middleware declaring `{ vendor: string }` lets the guard/handler use
|
|
157
|
+
`ctx.vendor` without casts (type-level test via `tsc` on a fixture).
|
|
158
|
+
- Existing untyped middleware in `test/command.test.ts` compiles unchanged.
|
|
159
|
+
- Runtime ordering tests keep passing (no behavior change).
|
|
160
|
+
**Verification:** typecheck + full suite green; no `as never` needed in the
|
|
161
|
+
middleware tests that previously required it.
|
|
162
|
+
|
|
163
|
+
### U2. Pagination bridge
|
|
164
|
+
|
|
165
|
+
**Goal:** cursor pagination with zod-typed boundaries.
|
|
166
|
+
**Requirements:** R1, R10. **Dependencies:** none.
|
|
167
|
+
**Files:** `src/zod-table.ts` (arg/return schema helpers), `src/auth.ts`
|
|
168
|
+
(`paginate` terminal on `IncludedQuery`), `test/pagination.test.ts`,
|
|
169
|
+
`test/fixture/functions.ts` (one paginated query),
|
|
170
|
+
`src/docs/zod-table.md`, `src/docs/auth.md`, `src/docs/llms.md`.
|
|
171
|
+
**Approach:**
|
|
172
|
+
1. `paginated(dto)` → `{ args: convexToZod(paginationOptsValidator)`-based
|
|
173
|
+
opts schema, `returns:` a zod page result (`page: dto[]`, `isDone`,
|
|
174
|
+
`continueCursor`) via convex-helpers bridges.
|
|
175
|
+
2. `include(...).paginate(opts, transform?)` terminal calling Convex's
|
|
176
|
+
`query.paginate(opts)` on the selected indexed query; page size bounded by
|
|
177
|
+
the same `maxRows` policy as `execute`.
|
|
178
|
+
**Execution note:** verify early that a paginated read works through the
|
|
179
|
+
RLS-wrapped reader (tenancy fixture) — if the wrapped reader doesn't support
|
|
180
|
+
`paginate`, that's a planning-assumption failure to surface, not to patch
|
|
181
|
+
around silently.
|
|
182
|
+
**Test scenarios:**
|
|
183
|
+
- Happy path: two pages traverse a seeded fixture table via an index;
|
|
184
|
+
cursors chain; `isDone` terminates.
|
|
185
|
+
- DTO projection: page items pass through `toPublicDto` transform.
|
|
186
|
+
- Bound: page size above `maxRows` rejects with `INVALID_REQUEST_BOUNDARY`.
|
|
187
|
+
- Tenancy: paginated scan in the tenant fixture returns only the caller
|
|
188
|
+
tenant's rows.
|
|
189
|
+
- Short pages: with foreign-tenant rows interleaved, pages may be shorter
|
|
190
|
+
than `numItems` but cursors remain correct and traversal completes.
|
|
191
|
+
**Verification:** suite green including the tenancy pagination case.
|
|
192
|
+
|
|
193
|
+
### U3. State-machine helper
|
|
194
|
+
|
|
195
|
+
**Goal:** typed transition legality from constants tuples.
|
|
196
|
+
**Requirements:** R3. **Dependencies:** none.
|
|
197
|
+
**Files:** `src/state-machine.ts` (new), `src/index.ts`, `package.json`
|
|
198
|
+
(exports subpath `./state-machine`), `test/state-machine.test.ts`,
|
|
199
|
+
`src/docs/commands.md` (guard example), `src/docs/llms.md`.
|
|
200
|
+
**Approach:** `createStateMachine(STATES, transitions)` where `transitions`
|
|
201
|
+
is `Partial<Record<State, readonly State[]>>` typed against the tuple.
|
|
202
|
+
Returns `{ states, can(from, to), assert(from, to, errors?) }`; `assert`
|
|
203
|
+
throws via the injected `ErrorFactory` (default `KitError`,
|
|
204
|
+
`INVALID_TRANSITION`) so it drops into command guards.
|
|
205
|
+
**Patterns to follow:** vocabulary-tuple convention from `conventions.md`;
|
|
206
|
+
error injection from `src/errors.ts`.
|
|
207
|
+
**Test scenarios:**
|
|
208
|
+
- Legal transition passes; illegal throws `INVALID_TRANSITION`.
|
|
209
|
+
- A state with no outgoing transitions rejects everything.
|
|
210
|
+
- Type-level: a literal not in the tuple fails to compile in `transitions`.
|
|
211
|
+
- Custom `ErrorFactory` receives the throw.
|
|
212
|
+
**Verification:** suite green; the commands.md guard example compiles as
|
|
213
|
+
written in a fixture.
|
|
214
|
+
|
|
215
|
+
### U4. CRUD command factory
|
|
216
|
+
|
|
217
|
+
**Goal:** standard create/update/archive operations from one declaration.
|
|
218
|
+
**Requirements:** R2, R10. **Dependencies:** U1 (types). (U3 is referenced
|
|
219
|
+
only by the archive-guard docs example, not by the implementation — U3 and U4
|
|
220
|
+
can proceed in parallel.)
|
|
221
|
+
**Files:** `src/crud.ts` (new), `src/index.ts`, `package.json` (subpath
|
|
222
|
+
`./crud`), `test/crud.test.ts` (real-runtime via a fixture),
|
|
223
|
+
`test/fixture/` additions, `src/docs/crud.md` (new), `src/docs/llms.md`,
|
|
224
|
+
README modules section.
|
|
225
|
+
**Approach:** `createCrudCommands({ Command, table, aggregateType,
|
|
226
|
+
classification?, actor, archive?, guards?, middleware? })` returning
|
|
227
|
+
`{ operations, executeCreate, executeUpdate, executeArchive }`:
|
|
228
|
+
1. Operation keys derived `<tableName>.create|update|archive`.
|
|
229
|
+
2. `create` input = `table.commandInput` (+ injected ids the host declares);
|
|
230
|
+
handler inserts with `tenant`/server fields stamped via a host-provided
|
|
231
|
+
`enrich(ctx, command)` callback (keeps the factory tenancy-agnostic).
|
|
232
|
+
3. `update` input = commandInput partial + id; `archive` sets `archivedAt`
|
|
233
|
+
(soft delete per kit doctrine — no hard-delete operation is generated).
|
|
234
|
+
4. Audit derivation built from `aggregateType` + actor mapping; `aggregates:
|
|
235
|
+
[aggregateType]` declared so the allowlist enforces it.
|
|
236
|
+
**Test scenarios:**
|
|
237
|
+
- Create/update/archive execute on the real runtime through authMutation
|
|
238
|
+
adapters in the fixture; rows reflect changes; audits written with the
|
|
239
|
+
declared aggregate type.
|
|
240
|
+
- Update with a field outside `commandFields` rejects (strict schema).
|
|
241
|
+
- Archive sets `archivedAt`, leaves the row (no delete).
|
|
242
|
+
- Guards passed per-operation run before handlers (reuse guard-order test
|
|
243
|
+
approach from `test/command.test.ts`).
|
|
244
|
+
- Generated operations respect `classification` default `'business'`.
|
|
245
|
+
- Tenancy: in the tenant fixture (factory registered behind tenant RLS rules),
|
|
246
|
+
an update/archive targeting another tenant's document id rejects, and a
|
|
247
|
+
create whose enrich fails to stamp `tenant` is rejected by RLS — mirrors
|
|
248
|
+
U2's tenancy case. `enrich` is REQUIRED when the table is a tenantTable
|
|
249
|
+
(compile-level where feasible).
|
|
250
|
+
**Verification:** suite green; `docs/crud.md` example is the fixture code.
|
|
251
|
+
|
|
252
|
+
### U5. Rate-limit middleware factory
|
|
253
|
+
|
|
254
|
+
**Goal:** first packaged middleware.
|
|
255
|
+
**Requirements:** R4, R10. **Dependencies:** U1.
|
|
256
|
+
**Files:** `src/middleware.ts` (new; home for packaged middleware),
|
|
257
|
+
`src/index.ts`, `package.json` (dependency `@convex-dev/rate-limiter@^0.3`,
|
|
258
|
+
subpath `./middleware`), `test/middleware.test.ts`,
|
|
259
|
+
`src/docs/commands.md` (middleware section), `src/docs/tenancy.md` (keying
|
|
260
|
+
note), `src/docs/llms.md`.
|
|
261
|
+
**Approach:** `rateLimit({ limiter, name, key?, onLimit? })` returning a
|
|
262
|
+
`CommandMiddleware` (and a query variant): before `next()`, call
|
|
263
|
+
`limiter.limit(ctx, name, { key: key?.(ctx) ?? ctx.tenant })`; on rejection
|
|
264
|
+
throw via `ErrorFactory` (`RATE_LIMITED`, carrying `retryAfter` in the
|
|
265
|
+
message/metadata) or call `onLimit`. The limiter instance is injected —
|
|
266
|
+
checked against registry snapshot of `@convex-dev/rate-limiter@0.3.2` (re-verify at install) (`new
|
|
267
|
+
RateLimiter(components.rateLimiter, { limits })`, `limit(ctx, name, { key
|
|
268
|
+
})` returning status). The kit never mounts the component; docs show host
|
|
269
|
+
mounting.
|
|
270
|
+
**Test scenarios:**
|
|
271
|
+
- Fake limiter injected: under-limit passes through and handler runs.
|
|
272
|
+
- Over-limit: handler never runs, `RATE_LIMITED` thrown, audit skipped.
|
|
273
|
+
- Default key is `ctx.tenant`; custom `key` fn receives the ctx.
|
|
274
|
+
- No `key` fn and `ctx.tenant` undefined: configuration error, handler never
|
|
275
|
+
runs.
|
|
276
|
+
- Works in both registry-wide and per-operation middleware slots (order
|
|
277
|
+
test).
|
|
278
|
+
**Verification:** suite green; publint clean with the new dependency external
|
|
279
|
+
in the build (attw stays deferred per Scope Boundaries).
|
|
280
|
+
|
|
281
|
+
### U6. Webhook boundary helper
|
|
282
|
+
|
|
283
|
+
**Goal:** the sanctioned webhook pattern as code.
|
|
284
|
+
**Requirements:** R5, R10. **Dependencies:** none.
|
|
285
|
+
**Files:** `src/webhooks.ts` (new), `src/index.ts`, `package.json` (subpath
|
|
286
|
+
`./webhooks`), `test/webhooks.test.ts` (real runtime),
|
|
287
|
+
`test/fixture-webhooks/` (schema + functions + `_generated` stub),
|
|
288
|
+
`src/docs/webhooks.md` (new), `src/docs/llms.md`.
|
|
289
|
+
**Approach:**
|
|
290
|
+
1. `webhookEventsTable()` — a plain `zodTable` (`eventKey`, `receivedAt`,
|
|
291
|
+
`source`) the host registers; `by_eventKey` index documented.
|
|
292
|
+
2. `createWebhookBoundary({ verify, eventKey, table?, errors? })` returning
|
|
293
|
+
`handle(ctx, request, target)` for use inside the host's `httpAction`:
|
|
294
|
+
verify signature over the RAW request body (`request.text()` handed to
|
|
295
|
+
`verify` before any parsing; docs example uses constant-time comparison —
|
|
296
|
+
`crypto.subtle` HMAC verify — with the secret read from a Convex env var;
|
|
297
|
+
throw on failure), compute the natural key, `runMutation(target, payload)` where `target` is a host internal
|
|
298
|
+
mutation that calls the kit's `recordWebhookEvent(ctx, key)` guard —
|
|
299
|
+
insert-if-absent, return `duplicate: true` when seen (dedup is
|
|
300
|
+
transactional inside the mutation, not in the action).
|
|
301
|
+
**Execution note:** dedup must live in the mutation (transactional), not the
|
|
302
|
+
action — design the API so the helper makes this the easy path.
|
|
303
|
+
**Test scenarios:**
|
|
304
|
+
- First delivery processes; identical redelivery (same natural key) is a
|
|
305
|
+
no-op reporting duplicate.
|
|
306
|
+
- Failed signature verification throws before any write.
|
|
307
|
+
- Verification runs against the raw body bytes, not a re-serialized parse.
|
|
308
|
+
- Two different events with different keys both process.
|
|
309
|
+
- Key derivation follows `${event}:${id}:${updatedAt}` in the docs example.
|
|
310
|
+
**Verification:** suite green on the real runtime fixture.
|
|
311
|
+
|
|
312
|
+
### U7. Agent tool generator
|
|
313
|
+
|
|
314
|
+
**Goal:** LLM tools that are audited commands.
|
|
315
|
+
**Requirements:** R6, R10. **Dependencies:** U2 (list args derive from
|
|
316
|
+
pagination opts), U4 (mutation executors are the natural handlers).
|
|
317
|
+
**Files:** `src/agent-tools.ts` (new), `src/zod-table.ts` (switch `tools.update`/`tools.id`
|
|
318
|
+
masks from `zid` to `jsonSafeZid` so the masks are JSON-schema-safe as KTD2
|
|
319
|
+
assumes; compile-time Id types preserved), `src/index.ts`,
|
|
320
|
+
`package.json` (subpath `./agent-tools`), `test/agent-tools.test.ts`,
|
|
321
|
+
`src/docs/agent-tools.md` (new), `src/docs/llms.md`.
|
|
322
|
+
**Approach:** `createAgentTools(table, { create?, update?, get?, list? })`
|
|
323
|
+
mapping the existing `table.tools.*` masks to tool records `{ name,
|
|
324
|
+
description, args, handler }`: `args` are the masks (already `jsonSafeZid`
|
|
325
|
+
-safe), `description` derived (`Create a <tableName> record`, overridable),
|
|
326
|
+
`handler: (ctx, input) => executor(ctx, input)` wiring the caller-supplied
|
|
327
|
+
executors. Read contract: `get` uses the `tools.id`
|
|
328
|
+
mask with a caller-supplied read handler; `list` args derive from U2's
|
|
329
|
+
pagination opts schema with a caller-supplied query handler (reads do not
|
|
330
|
+
route through commands). Shape-compatible with `@convex-dev/agent`'s
|
|
331
|
+
`createTool` (checked against registry snapshot 2026-08-14; re-verify at
|
|
332
|
+
install time) — the host spreads the record into `createTool` or
|
|
333
|
+
ai-sdk `tool()`; the kit imports neither.
|
|
334
|
+
**Test scenarios:**
|
|
335
|
+
- Generated `args` for create equals `table.tools.insert` (mask reuse, no
|
|
336
|
+
drift).
|
|
337
|
+
- Handler invocation routes to the supplied executor with parsed input.
|
|
338
|
+
- Ids present as plain strings in JSON schema (jsonSafeZid behavior holds
|
|
339
|
+
through the mask).
|
|
340
|
+
- Only requested tool kinds are emitted; names are `<tableName>_<verb>`.
|
|
341
|
+
**Verification:** suite green; docs example type-checks against a stub
|
|
342
|
+
`createTool` signature in tests.
|
|
343
|
+
|
|
344
|
+
### U8. CHANGELOG + GitHub Releases + workflow hardening
|
|
345
|
+
|
|
346
|
+
**Goal:** release discipline switched on.
|
|
347
|
+
**Requirements:** R8. **Dependencies:** none (can land first).
|
|
348
|
+
**Files:** `CHANGELOG.md` (new; backfill 0.0.1–0.0.8 briefly, then 0.1.0),
|
|
349
|
+
`.github/workflows/release.yml` (release-notes step + updated action pins),
|
|
350
|
+
`README.md` (Releasing section), `ROADMAP.md` (check boxes as they complete).
|
|
351
|
+
**Approach:** Keep-a-Changelog format. After the publish step succeeds, a
|
|
352
|
+
step extracts the current tag's section from `CHANGELOG.md` and creates the
|
|
353
|
+
GitHub Release (`gh release create "$TAG" --notes-file`), using the
|
|
354
|
+
workflow's `GITHUB_TOKEN` (needs `contents: write` on the job — scope it to
|
|
355
|
+
the release job only). Bump `actions/checkout` and `actions/setup-node` pins
|
|
356
|
+
to current majors to clear the Node-20 deprecation annotation.
|
|
357
|
+
**Test scenarios:** `Test expectation: none — CI configuration; verified by
|
|
358
|
+
the 0.1.0 release run producing a GitHub Release with the changelog section
|
|
359
|
+
as notes.`
|
|
360
|
+
**Verification:** (1) the CHANGELOG-section extraction runs locally against
|
|
361
|
+
the backfilled file and yields the 0.1.0 section verbatim BEFORE tagging;
|
|
362
|
+
(2) the v0.1.0 tag run publishes to npm AND creates a GitHub Release with
|
|
363
|
+
correct notes. Backfill source of truth: `git log` + release history, not
|
|
364
|
+
memory.
|
|
365
|
+
|
|
366
|
+
### U9. Upgrade guide (the migration file) + docs pass
|
|
367
|
+
|
|
368
|
+
**Goal:** consumers on 0.0.x know the new way.
|
|
369
|
+
**Requirements:** R9. **Dependencies:** U1–U7 (documents them).
|
|
370
|
+
**Files:** `src/docs/upgrading.md` (new), `src/docs/llms.md` (exports map +
|
|
371
|
+
rules updates for all new modules), `README.md` (docs list + modules),
|
|
372
|
+
`src/docs/conventions.md` (where the new helpers fit the file anatomy).
|
|
373
|
+
**Approach:** `upgrading.md` is organized old way → new way per feature:
|
|
374
|
+
hand-written CRUD operations → `createCrudCommands`; hand-rolled transition
|
|
375
|
+
`if`s → `createStateMachine`; `.take()` loops → `paginate`; hand-rolled
|
|
376
|
+
limiter checks → `rateLimit` middleware; ad-hoc webhook dedup →
|
|
377
|
+
`createWebhookBoundary`; hand-written agent tools → `createAgentTools`;
|
|
378
|
+
untyped middleware ctx → typed extensions. Each entry: before-snippet,
|
|
379
|
+
after-snippet, and what stays unchanged. Also records the 0.1.0 semver
|
|
380
|
+
contract switch WITH the caveat (per review): the six new export surfaces
|
|
381
|
+
get final-name review during implementation, and the breaking window
|
|
382
|
+
formally closes at the follow-up consolidation release (name decision + API
|
|
383
|
+
audit) — CHANGELOG and upgrading.md both state this.
|
|
384
|
+
**Test scenarios:** `Test expectation: none — documentation; correctness is
|
|
385
|
+
enforced by U1–U7 examples being lifted from fixture code.`
|
|
386
|
+
**Verification:** every new export appears in `llms.md`'s tables; docs sync
|
|
387
|
+
ships the new pages in the tarball (dry-run check).
|
|
388
|
+
|
|
389
|
+
### U10. Release v0.1.0
|
|
390
|
+
|
|
391
|
+
**Goal:** ship it. **Requirements:** all. **Dependencies:** U1–U9.
|
|
392
|
+
**Files:** `package.json` (version via `npm version minor`).
|
|
393
|
+
**Approach:** full local verification (build → typecheck → test → tarball
|
|
394
|
+
dry-run), commit, `npm version minor`, push tag; watch the workflow publish
|
|
395
|
+
npm + GitHub Release.
|
|
396
|
+
**Test scenarios:** `Test expectation: none — release mechanics; gates are
|
|
397
|
+
the Verification Contract.`
|
|
398
|
+
**Verification:** registry shows 0.1.0 with provenance; GitHub Release
|
|
399
|
+
exists with changelog notes; a consumer install shows the new subpaths.
|
|
400
|
+
|
|
401
|
+
---
|
|
402
|
+
|
|
403
|
+
## Scope Boundaries
|
|
404
|
+
|
|
405
|
+
**In scope:** the ten units above.
|
|
406
|
+
|
|
407
|
+
### Deferred to Follow-Up Work
|
|
408
|
+
- Package-name decision + full export-name API audit (user decision;
|
|
409
|
+
remaining 0.1.0 roadmap items — do before or immediately after this ships,
|
|
410
|
+
since the upgrade guide will need a rename note if the name changes).
|
|
411
|
+
- `attw` + pack dry-run in CI (roadmap 0.1.0 CI-hardening item not blocking
|
|
412
|
+
the features; the pin bumps land in U8).
|
|
413
|
+
- Full TanStack-style middleware chain inference (per-middleware declared
|
|
414
|
+
extensions are the 0.1.0 contract; array-level inference is future work).
|
|
415
|
+
|
|
416
|
+
### Outside this product's identity
|
|
417
|
+
- Idempotency store, frontend bindings, multi-provider auth abstractions
|
|
418
|
+
(per `ROADMAP.md` out-of-scope section).
|
|
419
|
+
|
|
420
|
+
## Open Questions (deferred to implementation)
|
|
421
|
+
- ~~RLS-wrapped `paginate` support~~ RESOLVED during review: the wrapper
|
|
422
|
+
supports `paginate` (convex-helpers filter WrapQuery). Real caveat:
|
|
423
|
+
filtered pagination can return pages SHORTER than `numItems` when RLS
|
|
424
|
+
rejects rows — U2 tests cursor correctness under mid-page filtering and
|
|
425
|
+
the docs state the short-page contract.
|
|
426
|
+
- Exact `RateLimiter` status shape on rejection (`retryAfter` field name) —
|
|
427
|
+
read from the installed package at implementation time.
|
|
428
|
+
|
|
429
|
+
## Risks
|
|
430
|
+
- **Dependency drift:** `@convex-dev/rate-limiter` peer-depends on
|
|
431
|
+
`convex ^1.24.8` (compatible) but pulls a React peer — verify it stays a
|
|
432
|
+
server-only import path so consumers without React aren't warned. If
|
|
433
|
+
noisy, document or mark peer-optional in kit docs.
|
|
434
|
+
- **U4 API surface:** the CRUD factory is the most opinion-dense new API;
|
|
435
|
+
wrong defaults here are expensive post-0.1.0. Mitigation: fixture-first
|
|
436
|
+
design (write the consuming fixture before the factory), and the archive
|
|
437
|
+
operation stays soft-delete-only.
|
|
438
|
+
- **Release workflow token:** `gh release create` needs `contents: write`;
|
|
439
|
+
the job currently has `contents: read`. Scope the elevation to the release
|
|
440
|
+
job only.
|
|
441
|
+
|
|
442
|
+
## Verification Contract
|
|
443
|
+
- `bun run build` (includes bridge + publint), `bun run typecheck`,
|
|
444
|
+
`bun run test` all green.
|
|
445
|
+
- Tarball dry-run: dist + docs + `src/test.ts` only; all new docs pages
|
|
446
|
+
present; new subpaths resolve (publint).
|
|
447
|
+
- Tenancy suite still proves isolation with pagination added.
|
|
448
|
+
- Every new runtime export has ≥1 test and an `llms.md` row.
|
|
449
|
+
|
|
450
|
+
## Definition of Done
|
|
451
|
+
- All ten units complete; `cvx-kit@0.1.0` live on npm with provenance.
|
|
452
|
+
- GitHub Release v0.1.0 exists with CHANGELOG-derived notes.
|
|
453
|
+
- `ROADMAP.md` 0.1.0 feature checkboxes checked (consolidation items that
|
|
454
|
+
remain open — name decision, API audit — explicitly unchecked with a note).
|
|
455
|
+
|
|
456
|
+
## Sources & Research
|
|
457
|
+
- `ROADMAP.md` (origin), `docs/explain/roadmap-to-1.0.0.html`.
|
|
458
|
+
- Session context: this repo's full source (authored this session).
|
|
459
|
+
- Registry: `@convex-dev/rate-limiter@0.3.2` API (constructor, named limits,
|
|
460
|
+
`limit(ctx, name, { key })`); `@convex-dev/agent@0.6.4` peers/contract.
|
|
461
|
+
- convex-helpers: `convexToZod` (zod4), `paginationOptsValidator` (convex).
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# Upgrading to 0.1.0 — the new way of things
|
|
2
|
+
|
|
3
|
+
The migration file for consumers on 0.0.x. Each section: the old way you were
|
|
4
|
+
hand-writing, the 0.1.0 way, and what stays unchanged. Nothing existing
|
|
5
|
+
breaks in this release except one boundary-mask improvement (see the last
|
|
6
|
+
section).
|
|
7
|
+
|
|
8
|
+
**Semver contract from 0.1.0:** breaking changes land only on minor bumps;
|
|
9
|
+
patches are additive or fixes. One caveat, stated honestly: the breaking
|
|
10
|
+
window formally closes at the follow-up consolidation release (package-name
|
|
11
|
+
decision + full export-name API audit) — until then the six new helper
|
|
12
|
+
surfaces may still be renamed in a minor. Every tag now gets a GitHub
|
|
13
|
+
Release with CHANGELOG notes.
|
|
14
|
+
|
|
15
|
+
## Pagination — was: `.take()` loops or hand-bridged validators
|
|
16
|
+
|
|
17
|
+
Old way:
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
// hand-rolled: convexToZod(paginationOptsValidator) in every project,
|
|
21
|
+
// or worse, .execute(100) and client-side slicing.
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
New way:
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import { paginated } from 'cvx-kit/zod-table'
|
|
28
|
+
|
|
29
|
+
const page = paginated(<entities>.publicDto)
|
|
30
|
+
export const list = authQuery({
|
|
31
|
+
args: { paginationOpts: page.args.paginationOpts },
|
|
32
|
+
returns: page.result,
|
|
33
|
+
handler: (ctx, args) =>
|
|
34
|
+
ctx.include(ctx.db.query('<entities>'))
|
|
35
|
+
.matching('by_tenant', (ix) => ix.eq('tenant', ctx.tenant))
|
|
36
|
+
.paginate(args.paginationOpts, (rows) => rows.map(<entities>.toPublicDto)),
|
|
37
|
+
})
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Unchanged: `execute(limit)` for non-cursor reads; the maxRows bound applies
|
|
41
|
+
to `numItems` too. Under RLS, pages may be SHORTER than `numItems` when
|
|
42
|
+
rules reject rows mid-page — cursors remain correct, keep paging until
|
|
43
|
+
`isDone`.
|
|
44
|
+
|
|
45
|
+
## CRUD — was: hand-written create/update/archive per entity
|
|
46
|
+
|
|
47
|
+
New way:
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
import { createCrudCommands } from 'cvx-kit/crud'
|
|
51
|
+
|
|
52
|
+
const crud = createCrudCommands({
|
|
53
|
+
Command, // destructured from your Foundation
|
|
54
|
+
table: <entities>,
|
|
55
|
+
aggregateType: '<entity>',
|
|
56
|
+
actor: (ctx) => ctx.actor.userId,
|
|
57
|
+
enrich: (ctx) => ({ tenant: ctx.tenant }), // REQUIRED for tenantTables
|
|
58
|
+
})
|
|
59
|
+
// crud.executeCreate / executeUpdate / executeArchive — audited commands
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Unchanged: your api/ adapters stay thin; bespoke operations keep using
|
|
63
|
+
`Command.operation` directly. Archive is soft-delete only, by doctrine.
|
|
64
|
+
|
|
65
|
+
## Transition guards — was: hand-written `if` chains
|
|
66
|
+
|
|
67
|
+
New way:
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
import { createStateMachine } from 'cvx-kit/state-machine'
|
|
71
|
+
|
|
72
|
+
const machine = createStateMachine(<ENTITY>_STATES, {
|
|
73
|
+
draft: ['published', 'archived'],
|
|
74
|
+
published: ['archived'],
|
|
75
|
+
})
|
|
76
|
+
// in a command guard:
|
|
77
|
+
guard: async (ctx, command) => machine.assert(row.state, command.to)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Rate limiting — was: hand-rolled counters or none
|
|
81
|
+
|
|
82
|
+
New way (host mounts `@convex-dev/rate-limiter`; the kit stays
|
|
83
|
+
dependency-free — it receives the instance):
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
import { rateLimit } from 'cvx-kit/middleware'
|
|
87
|
+
|
|
88
|
+
const commands = new Command(operations, {
|
|
89
|
+
middleware: [rateLimit({ limiter, name: '<entities>.write' })],
|
|
90
|
+
})
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Keyed by `ctx.tenant` by default. No tenant and no `key` fn →
|
|
94
|
+
`RATE_LIMIT_KEY_MISSING` (configuration error, never a shared bucket).
|
|
95
|
+
|
|
96
|
+
## Webhooks — was: ad-hoc verification and dedup in the action
|
|
97
|
+
|
|
98
|
+
New way:
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
import { createWebhookBoundary, recordWebhookEvent, webhookEventsTable } from 'cvx-kit/webhooks'
|
|
102
|
+
|
|
103
|
+
// schema: webhookEvents: webhookEventsTable().table.index('by_eventKey', ['eventKey'])
|
|
104
|
+
const boundary = createWebhookBoundary({
|
|
105
|
+
verify: async (raw, request) => timingSafeHmacVerify(raw, request), // RAW body, constant-time, secret from env
|
|
106
|
+
eventKey: (raw) => { const e = JSON.parse(raw); return `${e.event}:${e.id}:${e.updatedAt}` },
|
|
107
|
+
})
|
|
108
|
+
// http.ts routes to boundary.handle(ctx, request, internal.<module>.functions.applyEvent)
|
|
109
|
+
// applyEvent (systemMutation) calls recordWebhookEvent FIRST — transactional dedup.
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Dedup rows are host-owned: prune on a retention window longer than the
|
|
113
|
+
provider's redelivery horizon (cleanup cron over `receivedAt`).
|
|
114
|
+
|
|
115
|
+
## Agent tools — was: hand-written tool definitions with raw db access
|
|
116
|
+
|
|
117
|
+
New way:
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
import { createAgentTools } from 'cvx-kit/agent-tools'
|
|
121
|
+
|
|
122
|
+
const tools = createAgentTools(<entities>, {
|
|
123
|
+
create: (ctx, input) => crud.executeCreate(ctx, input), // audited command
|
|
124
|
+
get: (ctx, { id }) => loadDto(ctx, id), // reads: query handlers
|
|
125
|
+
list: (ctx, { paginationOpts }) => listPage(ctx, paginationOpts),
|
|
126
|
+
})
|
|
127
|
+
// host: spread each record into @convex-dev/agent's createTool (or ai-sdk tool())
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Shape-compatible, dependency-free; checked against `@convex-dev/agent@0.6.x`
|
|
131
|
+
— re-verify at install.
|
|
132
|
+
|
|
133
|
+
## Typed middleware context — was: loose `next({ context })`
|
|
134
|
+
|
|
135
|
+
New way:
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
const withVendor = Command.middleware<Ctx, { vendor: Vendor }>(
|
|
139
|
+
async ({ context, next }) => next({ context: { vendor: await load(context) } }),
|
|
140
|
+
)
|
|
141
|
+
// registries/operations accept typed middleware without casts
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Unchanged: untyped middleware keeps compiling.
|
|
145
|
+
|
|
146
|
+
## The one behavior change: tool masks use jsonSafeZid
|
|
147
|
+
|
|
148
|
+
`<table>.tools.update` and `<table>.tools.id` now type ids with
|
|
149
|
+
`jsonSafeZid` instead of `zid`, so generated JSON schemas present ids as
|
|
150
|
+
plain strings (compile-time `Id<...>` preserved). If you relied on the masks
|
|
151
|
+
producing `zid` runtime metadata, re-check; for LLM tool schemas this is the
|
|
152
|
+
fix you wanted.
|
package/docs/webhooks.md
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# `cvx-kit/webhooks` — the sanctioned webhook boundary
|
|
2
|
+
|
|
3
|
+
Webhooks are the one boundary that genuinely needs deduplication (Convex's
|
|
4
|
+
client gives exactly-once for its own mutations; external providers redeliver).
|
|
5
|
+
The helper encodes the pattern: **verify over the raw body (fail closed) →
|
|
6
|
+
derive the natural key → delegate to an internal mutation whose first act is
|
|
7
|
+
transactional dedup**.
|
|
8
|
+
|
|
9
|
+
## Wiring
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
// schema — host-owned dedup table (the kit gains no tables):
|
|
13
|
+
webhookEvents: webhookEventsTable().table.index('by_eventKey', ['eventKey'])
|
|
14
|
+
|
|
15
|
+
// boundary — used inside the host's httpAction:
|
|
16
|
+
const boundary = createWebhookBoundary({
|
|
17
|
+
// RAW body, BEFORE parsing. Constant-time comparison (crypto.subtle HMAC
|
|
18
|
+
// verify), secret from a Convex environment variable — never in code.
|
|
19
|
+
verify: async (raw, request) => verifyVendorSignature(raw, request.headers),
|
|
20
|
+
// The natural-key pattern:
|
|
21
|
+
eventKey: (raw) => {
|
|
22
|
+
const event = JSON.parse(raw)
|
|
23
|
+
return `${event.type}:${event.id}:${event.updatedAt}`
|
|
24
|
+
},
|
|
25
|
+
source: 'vendor',
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
// http.ts — routing only:
|
|
29
|
+
http.route({ path: '/webhooks/vendor', method: 'POST',
|
|
30
|
+
handler: httpAction((ctx, request) =>
|
|
31
|
+
boundary.handle(ctx, request, internal.<module>.functions.applyVendorEvent)) })
|
|
32
|
+
|
|
33
|
+
// the target — a systemMutation; dedup FIRST, transactionally:
|
|
34
|
+
export const applyVendorEvent = systemMutation({
|
|
35
|
+
args: { eventKey: z.string(), payload: z.string(), source: z.string() },
|
|
36
|
+
handler: async (ctx, args) => {
|
|
37
|
+
const { duplicate } = await recordWebhookEvent(ctx, { key: args.eventKey, source: args.source })
|
|
38
|
+
if (duplicate) return null
|
|
39
|
+
const payload = JSON.parse(args.payload) // parse INSIDE the trusted path
|
|
40
|
+
// ...apply, via domain executors
|
|
41
|
+
return null
|
|
42
|
+
},
|
|
43
|
+
})
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Rules
|
|
47
|
+
|
|
48
|
+
1. **Dedup lives in the mutation, never the action.** The action can race;
|
|
49
|
+
the mutation's insert-if-absent is transactional.
|
|
50
|
+
2. **Verification is over raw bytes.** Re-serialized/parsed bodies break or
|
|
51
|
+
weaken HMACs; the helper hands `verify` the raw `request.text()`.
|
|
52
|
+
3. **Fail closed.** `verify` returning false OR throwing both reject with
|
|
53
|
+
`WEBHOOK_SIGNATURE_INVALID`, before any delegation.
|
|
54
|
+
4. **Retention is yours.** Dedup rows grow forever unless pruned — run a
|
|
55
|
+
cleanup cron over `receivedAt` with a window comfortably longer than the
|
|
56
|
+
provider's redelivery horizon (days, not hours).
|
|
57
|
+
5. Providers with signed timestamps (Stripe-style): also enforce a tolerance
|
|
58
|
+
window inside `verify`.
|