cvx-kit 0.0.1 → 0.0.2
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 +16 -0
- package/docs/approvals.md +152 -0
- package/docs/architecture.md +124 -0
- package/docs/auth.md +157 -0
- package/docs/commands.md +177 -0
- package/docs/conventions.md +270 -0
- package/docs/convex-architecture-explainer.html +460 -0
- package/docs/llms.md +194 -0
- package/docs/maintainability.md +124 -0
- package/docs/migration.md +351 -0
- package/docs/triggers.md +70 -0
- package/docs/zod-table.md +150 -0
- package/package.json +6 -3
package/docs/triggers.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# `cvx-kit/triggers` — one application-wide mutation interceptor registry
|
|
2
|
+
|
|
3
|
+
A single `Triggers` registry (from convex-helpers) is created once per app and
|
|
4
|
+
handed to `createAuthFunctions`. From then on, every `authMutation`,
|
|
5
|
+
`roleMutation`, and `systemMutation` runs its writes through
|
|
6
|
+
`triggers.wrapDB(ctx)` — trigger enforcement is **structural, not a
|
|
7
|
+
convention**. A handler cannot skip a trigger because it never sees an
|
|
8
|
+
unwrapped `ctx.db`.
|
|
9
|
+
|
|
10
|
+
## Setup
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
// convex/triggers.ts — the single registry
|
|
14
|
+
import { appendOnly, createTriggers, noDelete, timestamps } from 'cvx-kit/triggers'
|
|
15
|
+
import type { DataModel } from './_generated/dataModel'
|
|
16
|
+
|
|
17
|
+
export const triggers = createTriggers<DataModel>()
|
|
18
|
+
|
|
19
|
+
// Server-owned lifecycle timestamps (see zod-table.md)
|
|
20
|
+
timestamps(triggers, 'documents', 'projects', 'members')
|
|
21
|
+
|
|
22
|
+
// Evidence tables: inserts only — updates/deletes throw and roll back
|
|
23
|
+
appendOnly(triggers, 'votes', 'history')
|
|
24
|
+
|
|
25
|
+
// Soft-delete discipline: hard deletes throw
|
|
26
|
+
noDelete(triggers, 'documents')
|
|
27
|
+
|
|
28
|
+
// Domain triggers register on the same instance
|
|
29
|
+
triggers.register('documents', async (ctx, change) => {
|
|
30
|
+
if (change.operation === 'insert') { /* denormalize, validate, cascade */ }
|
|
31
|
+
})
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
// convex/functions.ts
|
|
36
|
+
createAuthFunctions<DataModel>({ ..., triggers })
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## The built-in helpers
|
|
40
|
+
|
|
41
|
+
- **`timestamps(triggers, ...tables)`** — maintains zod-table's opinionated
|
|
42
|
+
fields: sets `createdAt` + `updatedAt` on insert, moves `updatedAt` on every
|
|
43
|
+
update. `archivedAt` stays under application control. Contains its own
|
|
44
|
+
recursion guard (a write that already moved `updatedAt` — including the
|
|
45
|
+
trigger's own patch — is left alone). Register once per table.
|
|
46
|
+
- **`appendOnly(triggers, ...tables)`** — inserts pass; any update or delete
|
|
47
|
+
throws, which **rolls back the whole transaction**. The pattern for
|
|
48
|
+
votes/history/audit-style evidence tables.
|
|
49
|
+
- **`noDelete(triggers, ...tables)`** — forbids hard deletes only; updates
|
|
50
|
+
pass. Pair with `archivedAt` for soft deletion.
|
|
51
|
+
|
|
52
|
+
`Triggers`, `Change`, and `Trigger` are re-exported for custom registrations.
|
|
53
|
+
|
|
54
|
+
## Footguns
|
|
55
|
+
|
|
56
|
+
1. **Anything outside the kit constructors bypasses every trigger.** Raw
|
|
57
|
+
`mutation`/`internalMutation` from `_generated/server`, and `ctx.db` inside
|
|
58
|
+
`convex-test`'s `t.run(...)`, are unwrapped. Never build a mutation outside
|
|
59
|
+
your `functions.ts`, and expect test-side `t.run` writes to be trigger-free
|
|
60
|
+
(useful for seeding, dangerous for assertions about trigger behavior).
|
|
61
|
+
2. **Wrapper spread order matters** if you compose ctx manually:
|
|
62
|
+
`{ ...wrapDB(ctx), ...otherStuff }` — `wrapDB` returns a full ctx, so
|
|
63
|
+
spreading it *after* anything that carries `db` restores the raw database.
|
|
64
|
+
The kit's constructors already order this correctly
|
|
65
|
+
(`{ ...wrapDB(ctx), include, ...authBundle }`).
|
|
66
|
+
3. **A throwing trigger aborts the transaction.** That is the point for
|
|
67
|
+
`appendOnly`/`noDelete`, but remember it when writing custom triggers —
|
|
68
|
+
validation triggers should throw; best-effort denormalization should not.
|
|
69
|
+
4. **Triggers run per write, in registration order.** Keep them small; heavy
|
|
70
|
+
work belongs in a scheduled function, not in a trigger.
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# `cvx-kit/zod-table` — one zod shape per entity, masked into every boundary
|
|
2
|
+
|
|
3
|
+
The core idea: an entity's shape is declared **once** as a zod object, and every
|
|
4
|
+
boundary that shape crosses — storage, insert, update, command input, public
|
|
5
|
+
DTO, LLM tool input — is a *derived mask* of that single declaration. There is
|
|
6
|
+
no second source of truth to drift.
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
import { z } from 'zod'
|
|
10
|
+
import { zodTable } from 'cvx-kit/zod-table'
|
|
11
|
+
|
|
12
|
+
export const documents = zodTable(
|
|
13
|
+
'documents',
|
|
14
|
+
(id) => ({
|
|
15
|
+
title: z.string(),
|
|
16
|
+
ownerId: id('users'), // `id` is convex-helpers' zid — typed Id<'users'>
|
|
17
|
+
secretNote: z.string(),
|
|
18
|
+
reviewState: z.enum(['draft', 'published']),
|
|
19
|
+
}),
|
|
20
|
+
{
|
|
21
|
+
serverFields: ['reviewState'], // excluded from inserts — server assigns it
|
|
22
|
+
commandFields: ['title'], // the ONLY fields a command may carry
|
|
23
|
+
publicFields: ['title', 'ownerId'], // the DTO allowlist — everything else is redacted
|
|
24
|
+
},
|
|
25
|
+
)
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## What you get back
|
|
29
|
+
|
|
30
|
+
| Property | What it is | Where you use it |
|
|
31
|
+
|---|---|---|
|
|
32
|
+
| `table` | `defineTable(...)` from the storage shape | `convex/schema.ts` |
|
|
33
|
+
| `storage` | zod object of the raw row (no `_id`/`_creationTime`) | validating rows |
|
|
34
|
+
| `schema` | `storage` + `_id` + `_creationTime` | full-document validation |
|
|
35
|
+
| `insertSchema` | storage minus timestamps minus `serverFields` | mutation args |
|
|
36
|
+
| `updateSchema` | `insertSchema.partial()` | patch mutation args |
|
|
37
|
+
| `commandInput` | strict pick of `commandFields` | command protocol inputs |
|
|
38
|
+
| `publicDto` | strict pick of `publicFields` | query return validators |
|
|
39
|
+
| `toPublicDto(row)` | projects **and re-parses** a row into the DTO | query handlers |
|
|
40
|
+
| `insert(omit?)` / `update(omit?)` | schema with per-callsite field omission | specialized mutations |
|
|
41
|
+
| `tools.insert` / `tools.update` / `tools.id` | ready-made LLM tool input schemas | agent tool definitions |
|
|
42
|
+
| `tableName` | the literal table name | indexes, triggers registration |
|
|
43
|
+
|
|
44
|
+
## Redaction is runtime, not just types
|
|
45
|
+
|
|
46
|
+
`toPublicDto` does not merely `pick` at the type level — it re-parses the
|
|
47
|
+
projected object through the strict `publicDto` schema. A field that leaks into
|
|
48
|
+
the projection throws instead of shipping to the client. Use it on **every**
|
|
49
|
+
row a public query returns:
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
export const get = authQuery({
|
|
53
|
+
args: { id: zid('documents') },
|
|
54
|
+
returns: documents.publicDto,
|
|
55
|
+
handler: async (ctx, { id }) => {
|
|
56
|
+
const row = await ctx.db.get(id)
|
|
57
|
+
if (!row) throw new KitError({ code: 'NOT_FOUND' })
|
|
58
|
+
return documents.toPublicDto(row) // secretNote can never leak
|
|
59
|
+
},
|
|
60
|
+
})
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Opinionated timestamps
|
|
64
|
+
|
|
65
|
+
Every `zodTable` bakes in three optional, **server-owned** fields:
|
|
66
|
+
|
|
67
|
+
- `createdAt` — set once on insert
|
|
68
|
+
- `updatedAt` — moved on every update
|
|
69
|
+
- `archivedAt` — soft-delete marker, under application control
|
|
70
|
+
|
|
71
|
+
They are always excluded from `insertSchema`, `updateSchema`, and
|
|
72
|
+
`commandInput` — a client or command can never write them. They are *not* in
|
|
73
|
+
`publicFields` by default; list them explicitly when a DTO needs them
|
|
74
|
+
(`publicFields: ['title', 'createdAt']`).
|
|
75
|
+
|
|
76
|
+
They maintain themselves only if you register the `timestamps` trigger once
|
|
77
|
+
per table (see `triggers.md`). Without the trigger they stay `undefined`.
|
|
78
|
+
|
|
79
|
+
Prefer `createdAt` over `_creationTime` in DTOs so the whole lifecycle
|
|
80
|
+
vocabulary is yours; `_creationTime` remains available for index ordering.
|
|
81
|
+
|
|
82
|
+
## Field masks — who sees what
|
|
83
|
+
|
|
84
|
+
Think of the options as three independent allowlists over the same shape:
|
|
85
|
+
|
|
86
|
+
- `serverFields` — *the server assigns these.* Excluded from `insertSchema`
|
|
87
|
+
and `updateSchema`. Example: `reviewState`, denormalized counters.
|
|
88
|
+
- `commandFields` — *a command may say these.* The command protocol's input
|
|
89
|
+
is exactly this pick, strict. Everything else must be derived by the handler.
|
|
90
|
+
- `publicFields` — *a client may see these.* The DTO is exactly this pick;
|
|
91
|
+
may include timestamp fields.
|
|
92
|
+
|
|
93
|
+
All derived objects are `.strict()` — unknown keys are rejected, in both
|
|
94
|
+
directions.
|
|
95
|
+
|
|
96
|
+
## `zodVariantTable` — discriminated unions
|
|
97
|
+
|
|
98
|
+
For tables whose rows are a discriminated union (events, polymorphic
|
|
99
|
+
resources), `zodTable`'s object-shape model doesn't apply. `zodVariantTable`
|
|
100
|
+
keeps the same "one owner per table" convention while accepting any zod type
|
|
101
|
+
as storage:
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
const events = zodVariantTable('events', z.discriminatedUnion('kind', [
|
|
105
|
+
z.object({ kind: z.literal('created'), by: zid('users') }),
|
|
106
|
+
z.object({ kind: z.literal('archived'), reason: z.string() }),
|
|
107
|
+
]))
|
|
108
|
+
// events.table → defineTable, events.insertSchema → the union itself
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Note: variant tables do not get the timestamp fields or boundary masks — the
|
|
112
|
+
union is the boundary.
|
|
113
|
+
|
|
114
|
+
## `jsonSafeZid` — Ids in LLM tool schemas
|
|
115
|
+
|
|
116
|
+
JSON-schema generators turn `zid('users')` into an opaque custom type that
|
|
117
|
+
confuses LLM tool calling. `jsonSafeZid('users')` presents as a plain
|
|
118
|
+
described string in the generated JSON schema while keeping `Id<'users'>` at
|
|
119
|
+
the type level:
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
const assignInput = z.object({
|
|
123
|
+
documentId: jsonSafeZid('documents'), // LLM sees: string, "Convex document id for table \"documents\""
|
|
124
|
+
})
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Wiring into `schema.ts`
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
// convex/schema.ts
|
|
131
|
+
import { defineSchema } from 'convex/server'
|
|
132
|
+
import { documents } from './tables/documents'
|
|
133
|
+
|
|
134
|
+
export default defineSchema({
|
|
135
|
+
documents: documents.table
|
|
136
|
+
.index('by_owner', ['ownerId'])
|
|
137
|
+
.index('by_owner_archived', ['ownerId', 'archivedAt']),
|
|
138
|
+
})
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Indexes are chained on `.table` exactly like a hand-written `defineTable`.
|
|
142
|
+
|
|
143
|
+
## Rules of thumb
|
|
144
|
+
|
|
145
|
+
1. One `zodTable` per entity, in its own module, exported by name.
|
|
146
|
+
2. Never define an inline `defineTable` or a second zod object for the same
|
|
147
|
+
entity — masks only.
|
|
148
|
+
3. Every public query returns `publicDto`-shaped data via `toPublicDto`.
|
|
149
|
+
4. Every command input is `commandInput` (or a documented composition of it).
|
|
150
|
+
5. Timestamps are trigger-maintained; handlers never write them.
|
package/package.json
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cvx-kit",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.2",
|
|
4
4
|
"description": "Reusable Convex application kit: foundation kernels, approvals component, zod table boundaries, WorkOS-backed auth function constructors, and audited command protocol.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"sideEffects": false,
|
|
8
8
|
"files": [
|
|
9
9
|
"dist",
|
|
10
|
-
"src"
|
|
10
|
+
"src",
|
|
11
|
+
"!src/docs",
|
|
12
|
+
"docs"
|
|
11
13
|
],
|
|
12
14
|
"exports": {
|
|
13
15
|
"./package.json": "./package.json",
|
|
@@ -67,7 +69,8 @@
|
|
|
67
69
|
"build": "vp pack",
|
|
68
70
|
"typecheck": "tsc --noEmit",
|
|
69
71
|
"test": "vp test run",
|
|
70
|
-
"
|
|
72
|
+
"docs:sync": "rm -f docs/*.md && cp src/docs/*.md docs/",
|
|
73
|
+
"prepublishOnly": "bun run typecheck && bun run test && bun run build && bun run docs:sync",
|
|
71
74
|
"build:codegen": "convex codegen --component-dir ./src/components/foundation && convex codegen --component-dir ./src/components/approvals && vp pack",
|
|
72
75
|
"release": "npm version patch && git push --follow-tags",
|
|
73
76
|
"release:minor": "npm version minor && git push --follow-tags"
|