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.
@@ -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.1",
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
- "prepublishOnly": "bun run typecheck && bun run test && bun run build",
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"