cvx-kit 0.0.8 → 0.1.1

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.
Files changed (73) hide show
  1. package/CHANGELOG.md +132 -0
  2. package/README.md +14 -10
  3. package/ROADMAP.md +126 -0
  4. package/dist/agent-tools.d.mts +45 -0
  5. package/dist/agent-tools.mjs +2 -0
  6. package/dist/agent-tools.mjs.map +1 -0
  7. package/dist/auth.d.mts +9 -1
  8. package/dist/auth.mjs +1 -1
  9. package/dist/auth.mjs.map +1 -1
  10. package/dist/components/approvals/_generated/api.d.mts +2 -0
  11. package/dist/components/approvals/_generated/api.mjs.map +1 -1
  12. package/dist/components/approvals/_generated/component.d.mts +7 -0
  13. package/dist/components/approvals/_generated/dataModel.d.mts +1 -1
  14. package/dist/components/approvals/client.d.mts +4 -4
  15. package/dist/components/approvals/client.mjs +1 -1
  16. package/dist/components/approvals/client.mjs.map +1 -1
  17. package/dist/components/approvals/constants.d.mts +8 -2
  18. package/dist/components/approvals/constants.mjs +1 -1
  19. package/dist/components/approvals/constants.mjs.map +1 -1
  20. package/dist/components/approvals/decisions.mjs +1 -1
  21. package/dist/components/approvals/decisions.mjs.map +1 -1
  22. package/dist/components/approvals/health.d.mts +15 -0
  23. package/dist/components/approvals/health.mjs +2 -0
  24. package/dist/components/approvals/health.mjs.map +1 -0
  25. package/dist/components/approvals/requests.mjs +1 -1
  26. package/dist/components/approvals/requests.mjs.map +1 -1
  27. package/dist/components/approvals/schema.js +1 -0
  28. package/dist/components/approvals/validators.d.mts +119 -2
  29. package/dist/components/approvals/validators.mjs +1 -1
  30. package/dist/components/approvals/validators.mjs.map +1 -1
  31. package/dist/components/approvals/workflow_steps.d.mts +2 -25
  32. package/dist/components/approvals/workflow_steps.mjs.map +1 -1
  33. package/dist/components/foundation/_generated/dataModel.d.mts +1 -1
  34. package/dist/components/foundation/client.d.mts +26 -9
  35. package/dist/components/foundation/client.mjs.map +1 -1
  36. package/dist/components/foundation/modules/query/query.d.mts +19 -8
  37. package/dist/components/foundation/modules/query/query.mjs.map +1 -1
  38. package/dist/components/foundation/{schema.mjs → schema.js} +0 -1
  39. package/dist/crud.d.mts +79 -0
  40. package/dist/crud.mjs +2 -0
  41. package/dist/crud.mjs.map +1 -0
  42. package/dist/index.d.mts +9 -4
  43. package/dist/index.mjs +1 -1
  44. package/dist/middleware.d.mts +46 -0
  45. package/dist/middleware.mjs +2 -0
  46. package/dist/middleware.mjs.map +1 -0
  47. package/dist/state-machine.d.mts +18 -0
  48. package/dist/state-machine.mjs +2 -0
  49. package/dist/state-machine.mjs.map +1 -0
  50. package/dist/tenancy.d.mts +1 -1
  51. package/dist/webhooks.d.mts +164 -0
  52. package/dist/webhooks.mjs +2 -0
  53. package/dist/webhooks.mjs.map +1 -0
  54. package/dist/zod-table.d.mts +34 -3
  55. package/dist/zod-table.mjs +1 -1
  56. package/dist/zod-table.mjs.map +1 -1
  57. package/docs/agent-tools.md +41 -0
  58. package/docs/approvals.md +50 -2
  59. package/docs/crud.md +62 -0
  60. package/docs/llms.md +8 -1
  61. package/docs/plans/2026-08-14-001-feat-v0.1.0-feature-set-plan.md +461 -0
  62. package/docs/upgrading.md +175 -0
  63. package/docs/webhooks.md +58 -0
  64. package/package.json +25 -2
  65. package/scripts/convex-config-bridge.mjs +41 -3
  66. package/scripts/extract-changelog.mjs +29 -0
  67. package/scripts/smoke-packed-components.mjs +374 -0
  68. package/src/test.ts +2 -2
  69. package/dist/components/approvals/schema.mjs +0 -2
  70. package/dist/components/approvals/schema.mjs.map +0 -1
  71. package/dist/components/foundation/schema.mjs.map +0 -1
  72. /package/dist/components/approvals/{schema.d.mts → schema.d.ts} +0 -0
  73. /package/dist/components/foundation/{schema.d.mts → schema.d.ts} +0 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"middleware.mjs","names":[],"sources":["../src/middleware.ts"],"sourcesContent":["import { defaultErrors, type ErrorFactory } from './errors'\n\n/**\n * Structural surface of @convex-dev/rate-limiter's RateLimiter (checked\n * against 0.3.x; re-verify at install). The host mounts the component and\n * constructs the limiter; the kit only ever receives the instance —\n * injection keeps the kit dependency-free.\n */\nexport type RateLimiterLike = {\n\tlimit: (\n\t\tctx: never,\n\t\tname: string,\n\t\toptions?: { key?: string },\n\t) => Promise<{ ok: boolean; retryAfter?: number }>\n}\n\n/**\n * The first packaged middleware: rate limiting on the Command/Query\n * middleware seams. Runs before guards and the handler; a rejected call\n * throws RATE_LIMITED (audit and handler never run). Keyed by ctx.tenant by\n * default — when no key fn is given and ctx.tenant is undefined, this is a\n * configuration error, not a silent shared bucket.\n *\n * The returned middleware is shape-compatible with both AnyCommandMiddleware\n * and AnyQueryMiddleware.\n */\nexport function rateLimit(config: {\n\tlimiter: RateLimiterLike\n\tname: string\n\t/** Defaults to ctx.tenant. */\n\tkey?: (context: never) => string\n\t/** Observe a rejection before the throw (metrics, headers). */\n\tonLimit?: (\n\t\tstatus: { ok: boolean; retryAfter?: number },\n\t\tcontext: never,\n\t) => void | Promise<void>\n\terrors?: ErrorFactory\n}) {\n\tconst errors = config.errors ?? defaultErrors\n\treturn async (input: {\n\t\tcontext: never\n\t\tnext: (options?: { context?: unknown }) => Promise<unknown>\n\t}): Promise<unknown> => {\n\t\tconst key = config.key\n\t\t\t? config.key(input.context)\n\t\t\t: (input.context as { tenant?: string }).tenant\n\t\tif (key === undefined) {\n\t\t\treturn errors.throw({\n\t\t\t\tcode: 'RATE_LIMIT_KEY_MISSING',\n\t\t\t\tmessage: `rateLimit(\"${config.name}\") has no key: ctx.tenant is undefined and no key fn was configured`,\n\t\t\t})\n\t\t}\n\t\tconst status = await config.limiter.limit(input.context, config.name, {\n\t\t\tkey,\n\t\t})\n\t\tif (!status.ok) {\n\t\t\tawait config.onLimit?.(status, input.context)\n\t\t\treturn errors.throw({\n\t\t\t\tcode: 'RATE_LIMITED',\n\t\t\t\tmessage: `Rate limit \"${config.name}\" exceeded${\n\t\t\t\t\tstatus.retryAfter === undefined\n\t\t\t\t\t\t? ''\n\t\t\t\t\t\t: `; retry after ${Math.ceil(status.retryAfter)}ms`\n\t\t\t\t}`,\n\t\t\t})\n\t\t}\n\t\treturn input.next()\n\t}\n}\n"],"mappings":"6CA0BA,SAAgB,EAAU,EAWvB,CACF,IAAM,EAAS,EAAO,QAAU,EAChC,OAAO,KAAO,IAGU,CACvB,IAAM,EAAM,EAAO,IAChB,EAAO,IAAI,EAAM,OAAO,EACvB,EAAM,QAAgC,OAC1C,GAAI,IAAQ,IAAA,GACX,OAAO,EAAO,MAAM,CACnB,KAAM,yBACN,QAAS,cAAc,EAAO,KAAK,oEACpC,CAAC,EAEF,IAAM,EAAS,MAAM,EAAO,QAAQ,MAAM,EAAM,QAAS,EAAO,KAAM,CACrE,KACD,CAAC,EAYD,OAXK,EAAO,GAWL,EAAM,KAAK,GAVjB,MAAM,EAAO,UAAU,EAAQ,EAAM,OAAO,EACrC,EAAO,MAAM,CACnB,KAAM,eACN,QAAS,eAAe,EAAO,KAAK,YACnC,EAAO,aAAe,IAAA,GACnB,GACA,iBAAiB,KAAK,KAAK,EAAO,UAAU,EAAE,KAEnD,CAAC,EAGH,CACD"}
@@ -0,0 +1,18 @@
1
+ import { ErrorFactory } from "./errors.mjs";
2
+ //#region src/state-machine.d.ts
3
+ /**
4
+ * Typed transition legality from a constants tuple (the conventions.md
5
+ * vocabulary pattern). The transition map is typed against the tuple, so an
6
+ * invalid literal cannot compile; `assert` throws via the injected
7
+ * ErrorFactory so it drops straight into a command guard.
8
+ */
9
+ declare function createStateMachine<const State extends string>(states: readonly State[], transitions: Partial<Record<NoInfer<State>, readonly NoInfer<State>[]>>, options?: {
10
+ errors?: ErrorFactory;
11
+ }): Readonly<{
12
+ states: readonly State[];
13
+ can: (from: State, to: State) => boolean;
14
+ assert: (from: State, to: State) => void;
15
+ }>;
16
+ //#endregion
17
+ export { createStateMachine };
18
+ //# sourceMappingURL=state-machine.d.mts.map
@@ -0,0 +1,2 @@
1
+ import{defaultErrors as e}from"./errors.mjs";function t(t,n,r){let i=r?.errors??e,a=new Map(t.map(e=>[e,new Set(n[e]??[])]));function o(e,t){return a.get(e)?.has(t)??!1}function s(e,t){o(e,t)||i.throw({code:`INVALID_TRANSITION`,message:`Transition "${e}" → "${t}" is not legal`})}return Object.freeze({states:t,can:o,assert:s})}export{t as createStateMachine};
2
+ //# sourceMappingURL=state-machine.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"state-machine.mjs","names":[],"sources":["../src/state-machine.ts"],"sourcesContent":["import { defaultErrors, type ErrorFactory } from './errors'\n\n/**\n * Typed transition legality from a constants tuple (the conventions.md\n * vocabulary pattern). The transition map is typed against the tuple, so an\n * invalid literal cannot compile; `assert` throws via the injected\n * ErrorFactory so it drops straight into a command guard.\n */\nexport function createStateMachine<const State extends string>(\n\tstates: readonly State[],\n\t// NoInfer: the vocabulary comes from the tuple; a stray literal in the\n\t// transition map must fail to compile, not widen the state union.\n\ttransitions: Partial<Record<NoInfer<State>, readonly NoInfer<State>[]>>,\n\toptions?: { errors?: ErrorFactory },\n) {\n\tconst errors = options?.errors ?? defaultErrors\n\tconst legal = new Map<State, ReadonlySet<State>>(\n\t\tstates.map((state) => [state, new Set(transitions[state] ?? [])]),\n\t)\n\n\tfunction can(from: State, to: State): boolean {\n\t\treturn legal.get(from)?.has(to) ?? false\n\t}\n\n\tfunction assert(from: State, to: State): void {\n\t\tif (!can(from, to)) {\n\t\t\terrors.throw({\n\t\t\t\tcode: 'INVALID_TRANSITION',\n\t\t\t\tmessage: `Transition \"${from}\" → \"${to}\" is not legal`,\n\t\t\t})\n\t\t}\n\t}\n\n\treturn Object.freeze({ states, can, assert })\n}\n"],"mappings":"6CAQA,SAAgB,EACf,EAGA,EACA,EACC,CACD,IAAM,EAAS,GAAS,QAAU,EAC5B,EAAQ,IAAI,IACjB,EAAO,IAAK,GAAU,CAAC,EAAO,IAAI,IAAI,EAAY,IAAU,CAAC,CAAC,CAAC,CAAC,CACjE,EAEA,SAAS,EAAI,EAAa,EAAoB,CAC7C,OAAO,EAAM,IAAI,CAAI,CAAC,EAAE,IAAI,CAAE,GAAK,EACpC,CAEA,SAAS,EAAO,EAAa,EAAiB,CACxC,EAAI,EAAM,CAAE,GAChB,EAAO,MAAM,CACZ,KAAM,qBACN,QAAS,eAAe,EAAK,OAAO,EAAG,eACxC,CAAC,CAEH,CAEA,OAAO,OAAO,OAAO,CAAE,SAAQ,MAAK,QAAO,CAAC,CAC7C"}
@@ -1,6 +1,6 @@
1
1
  import { ErrorFactory } from "./errors.mjs";
2
- import { RLSConfig, Rules, wrapDatabaseReader, wrapDatabaseWriter } from "convex-helpers/server/rowLevelSecurity";
3
2
  import { GenericDataModel, TableNamesInDataModel } from "convex/server";
3
+ import { RLSConfig, Rules, wrapDatabaseReader, wrapDatabaseWriter } from "convex-helpers/server/rowLevelSecurity";
4
4
  //#region src/tenancy.d.ts
5
5
  /** The server-owned tenancy field stamped on every tenant table row. */
6
6
  declare const TENANT_FIELD: 'tenant';
@@ -0,0 +1,164 @@
1
+ import { ErrorFactory } from "./errors.mjs";
2
+ import { z } from "zod";
3
+ //#region src/webhooks.d.ts
4
+ /**
5
+ * The host-owned dedup table (the kit gains no tables). Register it in the
6
+ * schema with the by_eventKey index:
7
+ *
8
+ * webhookEvents: webhookEventsTable().table.index('by_eventKey', ['eventKey'])
9
+ *
10
+ * Rows are host-owned data: prune them on a retention window comfortably
11
+ * longer than the provider's redelivery horizon (a cleanup cron over
12
+ * `receivedAt` — see docs/webhooks.md).
13
+ */
14
+ declare function webhookEventsTable(): {
15
+ tableName: "webhookEvents";
16
+ schema: z.ZodObject<{
17
+ createdAt: z.ZodOptional<z.ZodNumber>;
18
+ updatedAt: z.ZodOptional<z.ZodNumber>;
19
+ archivedAt: z.ZodOptional<z.ZodNumber>;
20
+ _id: import("convex-helpers/server/zod4").Zid<"webhookEvents">;
21
+ _creationTime: z.ZodNumber;
22
+ eventKey: z.ZodString;
23
+ source: z.ZodString;
24
+ receivedAt: z.ZodNumber;
25
+ }, z.core.$strict>;
26
+ storage: z.ZodObject<{
27
+ createdAt: z.ZodOptional<z.ZodNumber>;
28
+ updatedAt: z.ZodOptional<z.ZodNumber>;
29
+ archivedAt: z.ZodOptional<z.ZodNumber>;
30
+ eventKey: z.ZodString;
31
+ source: z.ZodString;
32
+ receivedAt: z.ZodNumber;
33
+ }, z.core.$strict>;
34
+ insertSchema: z.ZodObject<Omit<{
35
+ eventKey: z.ZodString;
36
+ source: z.ZodString;
37
+ receivedAt: z.ZodNumber;
38
+ }, "archivedAt" | "createdAt" | "updatedAt">, z.core.$strip>;
39
+ updateSchema: z.ZodObject<{
40
+ eventKey: z.ZodOptional<z.ZodString>;
41
+ source: z.ZodOptional<z.ZodString>;
42
+ receivedAt: z.ZodOptional<z.ZodNumber>;
43
+ }, z.core.$strip>;
44
+ commandInput: z.ZodObject<{}, z.core.$strict>;
45
+ publicDto: z.ZodObject<{}, z.core.$strict>;
46
+ toPublicDto: (row: {
47
+ createdAt?: number | undefined;
48
+ updatedAt?: number | undefined;
49
+ archivedAt?: number | undefined;
50
+ eventKey: string;
51
+ source: string;
52
+ receivedAt: number;
53
+ }) => Record<string, never>;
54
+ table: import("convex/server").TableDefinition<import("convex/values").VObject<{
55
+ createdAt?: number | undefined;
56
+ updatedAt?: number | undefined;
57
+ archivedAt?: number | undefined;
58
+ eventKey: string;
59
+ source: string;
60
+ receivedAt: number;
61
+ }, {
62
+ createdAt: import("convex/values").VFloat64<number | undefined, "optional">;
63
+ updatedAt: import("convex/values").VFloat64<number | undefined, "optional">;
64
+ archivedAt: import("convex/values").VFloat64<number | undefined, "optional">;
65
+ eventKey: import("convex/values").VString<string, "required">;
66
+ source: import("convex/values").VString<string, "required">;
67
+ receivedAt: import("convex/values").VFloat64<number, "required">;
68
+ }, "required", "archivedAt" | "createdAt" | "eventKey" | "receivedAt" | "source" | "updatedAt">, {}, {}, {}>;
69
+ insert: {
70
+ (): z.ZodObject<Omit<{
71
+ eventKey: z.ZodString;
72
+ source: z.ZodString;
73
+ receivedAt: z.ZodNumber;
74
+ }, "archivedAt" | "createdAt" | "updatedAt">, z.core.$strip>;
75
+ <OmitMask extends z.util.Mask<"eventKey" | "receivedAt" | "source">>(omit: OmitMask): z.ZodObject<Omit<Omit<{
76
+ eventKey: z.ZodString;
77
+ source: z.ZodString;
78
+ receivedAt: z.ZodNumber;
79
+ }, "archivedAt" | "createdAt" | "updatedAt">, Extract<"eventKey", keyof OmitMask> | Extract<"receivedAt", keyof OmitMask> | Extract<"source", keyof OmitMask>> extends (infer T) ? { [k in keyof T]: T[k]; } : never, z.core.$strip>;
80
+ };
81
+ update: {
82
+ (): z.ZodObject<{
83
+ eventKey: z.ZodOptional<z.ZodString>;
84
+ source: z.ZodOptional<z.ZodString>;
85
+ receivedAt: z.ZodOptional<z.ZodNumber>;
86
+ }, z.core.$strip>;
87
+ <OmitMask extends z.util.Mask<"eventKey" | "receivedAt" | "source">>(omit: OmitMask): z.ZodObject<Omit<{
88
+ eventKey: z.ZodOptional<z.ZodString>;
89
+ source: z.ZodOptional<z.ZodString>;
90
+ receivedAt: z.ZodOptional<z.ZodNumber>;
91
+ }, Extract<"eventKey", keyof OmitMask> | Extract<"receivedAt", keyof OmitMask> | Extract<"source", keyof OmitMask>> extends (infer T) ? { [k in keyof T]: T[k]; } : never, z.core.$strip>;
92
+ };
93
+ tools: {
94
+ insert: z.ZodObject<{}, z.core.$strict>;
95
+ update: z.ZodObject<{
96
+ data: z.ZodObject<{}, z.core.$strict>;
97
+ id: z.ZodType<import("convex/values").GenericId<"webhookEvents">, string, z.core.$ZodTypeInternals<import("convex/values").GenericId<"webhookEvents">, string>>;
98
+ }, z.core.$strict>;
99
+ id: z.ZodObject<{
100
+ id: z.ZodType<import("convex/values").GenericId<"webhookEvents">, string, z.core.$ZodTypeInternals<import("convex/values").GenericId<"webhookEvents">, string>>;
101
+ }, z.core.$strict>;
102
+ };
103
+ };
104
+ type DedupContext = {
105
+ db: {
106
+ query: (table: never) => {
107
+ withIndex: (index: never, range: (builder: {
108
+ eq: (field: never, value: never) => unknown;
109
+ }) => unknown) => {
110
+ first: () => Promise<unknown | null>;
111
+ };
112
+ };
113
+ insert: (table: never, value: never) => Promise<unknown>;
114
+ };
115
+ };
116
+ /**
117
+ * Transactional insert-if-absent dedup guard. Call this FIRST inside the
118
+ * internal mutation the boundary delegates to — dedup must live in the
119
+ * mutation (transactional), never in the action. Returns duplicate: true
120
+ * when the natural key was already recorded; the caller then no-ops.
121
+ */
122
+ declare function recordWebhookEvent(ctx: DedupContext, input: {
123
+ key: string;
124
+ source?: string;
125
+ table?: string;
126
+ }): Promise<{
127
+ duplicate: boolean;
128
+ }>;
129
+ type WebhookBoundaryConfig = {
130
+ /**
131
+ * Verifies the provider signature over the RAW body bytes — the helper
132
+ * hands it `request.text()` BEFORE any parsing. Implementations must use
133
+ * a constant-time comparison (e.g. crypto.subtle HMAC verify) and read
134
+ * the secret from a Convex environment variable. Return false or throw
135
+ * to reject.
136
+ */
137
+ verify: (raw: string, request: Request) => boolean | Promise<boolean>;
138
+ /**
139
+ * Derives the natural dedup key — the `${event}:${id}:${updatedAt}`
140
+ * pattern. Receives the raw body and the request; parse inside if needed.
141
+ */
142
+ eventKey: (raw: string, request: Request) => string | Promise<string>;
143
+ source?: string;
144
+ errors?: ErrorFactory;
145
+ };
146
+ /**
147
+ * The sanctioned webhook pattern as code: verify (raw body, fail closed) →
148
+ * derive natural key → delegate to a host internal mutation whose FIRST act
149
+ * is recordWebhookEvent (transactional dedup). Use inside the host's
150
+ * httpAction:
151
+ *
152
+ * const boundary = createWebhookBoundary({ verify, eventKey })
153
+ * http.route({ path: '/webhooks/vendor', method: 'POST',
154
+ * handler: httpAction((ctx, request) =>
155
+ * boundary.handle(ctx, request, internal.domain.vendor.functions.applyEvent)) })
156
+ */
157
+ declare function createWebhookBoundary(config: WebhookBoundaryConfig): {
158
+ handle: (ctx: {
159
+ runMutation: (target: never, args: never) => Promise<unknown>;
160
+ }, request: Request, target: unknown) => Promise<Response>;
161
+ };
162
+ //#endregion
163
+ export { WebhookBoundaryConfig, createWebhookBoundary, recordWebhookEvent, webhookEventsTable };
164
+ //# sourceMappingURL=webhooks.d.mts.map
@@ -0,0 +1,2 @@
1
+ import{defaultErrors as e}from"./errors.mjs";import{zodTable as t}from"./zod-table.mjs";import{z as n}from"zod";function r(){return t(`webhookEvents`,()=>({eventKey:n.string(),source:n.string(),receivedAt:n.number()}))}async function i(e,t){let n=t.table??`webhookEvents`;return await e.db.query(n).withIndex(`by_eventKey`,e=>e.eq(`eventKey`,t.key)).first()?{duplicate:!0}:(await e.db.insert(n,{eventKey:t.key,source:t.source??`unknown`,receivedAt:Date.now()}),{duplicate:!1})}function a(t){let n=t.errors??e;async function r(e,r,i){let a=await r.text(),o=!1;try{o=await t.verify(a,r)}catch{o=!1}o||n.throw({code:`WEBHOOK_SIGNATURE_INVALID`,message:`Webhook signature verification failed`});let s=await t.eventKey(a,r);return await e.runMutation(i,{eventKey:s,payload:a,source:t.source??`unknown`}),new Response(null,{status:200})}return{handle:r}}export{a as createWebhookBoundary,i as recordWebhookEvent,r as webhookEventsTable};
2
+ //# sourceMappingURL=webhooks.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"webhooks.mjs","names":[],"sources":["../src/webhooks.ts"],"sourcesContent":["import { z } from 'zod'\nimport { defaultErrors, type ErrorFactory } from './errors'\nimport { zodTable } from './zod-table'\n\n/**\n * The host-owned dedup table (the kit gains no tables). Register it in the\n * schema with the by_eventKey index:\n *\n * webhookEvents: webhookEventsTable().table.index('by_eventKey', ['eventKey'])\n *\n * Rows are host-owned data: prune them on a retention window comfortably\n * longer than the provider's redelivery horizon (a cleanup cron over\n * `receivedAt` — see docs/webhooks.md).\n */\nexport function webhookEventsTable() {\n\treturn zodTable('webhookEvents', () => ({\n\t\teventKey: z.string(),\n\t\tsource: z.string(),\n\t\treceivedAt: z.number(),\n\t}))\n}\n\ntype DedupContext = {\n\tdb: {\n\t\tquery: (table: never) => {\n\t\t\twithIndex: (\n\t\t\t\tindex: never,\n\t\t\t\trange: (builder: { eq: (field: never, value: never) => unknown }) => unknown,\n\t\t\t) => { first: () => Promise<unknown | null> }\n\t\t}\n\t\tinsert: (table: never, value: never) => Promise<unknown>\n\t}\n}\n\n/**\n * Transactional insert-if-absent dedup guard. Call this FIRST inside the\n * internal mutation the boundary delegates to — dedup must live in the\n * mutation (transactional), never in the action. Returns duplicate: true\n * when the natural key was already recorded; the caller then no-ops.\n */\nexport async function recordWebhookEvent(\n\tctx: DedupContext,\n\tinput: { key: string; source?: string; table?: string },\n): Promise<{ duplicate: boolean }> {\n\tconst table = (input.table ?? 'webhookEvents') as never\n\tconst existing = await ctx.db\n\t\t.query(table)\n\t\t.withIndex('by_eventKey' as never, (builder) =>\n\t\t\tbuilder.eq('eventKey' as never, input.key as never),\n\t\t)\n\t\t.first()\n\tif (existing) return { duplicate: true }\n\tawait ctx.db.insert(table, {\n\t\teventKey: input.key,\n\t\tsource: input.source ?? 'unknown',\n\t\treceivedAt: Date.now(),\n\t} as never)\n\treturn { duplicate: false }\n}\n\nexport type WebhookBoundaryConfig = {\n\t/**\n\t * Verifies the provider signature over the RAW body bytes — the helper\n\t * hands it `request.text()` BEFORE any parsing. Implementations must use\n\t * a constant-time comparison (e.g. crypto.subtle HMAC verify) and read\n\t * the secret from a Convex environment variable. Return false or throw\n\t * to reject.\n\t */\n\tverify: (raw: string, request: Request) => boolean | Promise<boolean>\n\t/**\n\t * Derives the natural dedup key — the `${event}:${id}:${updatedAt}`\n\t * pattern. Receives the raw body and the request; parse inside if needed.\n\t */\n\teventKey: (raw: string, request: Request) => string | Promise<string>\n\tsource?: string\n\terrors?: ErrorFactory\n}\n\n/**\n * The sanctioned webhook pattern as code: verify (raw body, fail closed) →\n * derive natural key → delegate to a host internal mutation whose FIRST act\n * is recordWebhookEvent (transactional dedup). Use inside the host's\n * httpAction:\n *\n * const boundary = createWebhookBoundary({ verify, eventKey })\n * http.route({ path: '/webhooks/vendor', method: 'POST',\n * handler: httpAction((ctx, request) =>\n * boundary.handle(ctx, request, internal.domain.vendor.functions.applyEvent)) })\n */\nexport function createWebhookBoundary(config: WebhookBoundaryConfig) {\n\tconst errors = config.errors ?? defaultErrors\n\n\tasync function handle(\n\t\tctx: {\n\t\t\trunMutation: (target: never, args: never) => Promise<unknown>\n\t\t},\n\t\trequest: Request,\n\t\ttarget: unknown,\n\t): Promise<Response> {\n\t\tconst raw = await request.text()\n\t\tlet verified = false\n\t\ttry {\n\t\t\tverified = await config.verify(raw, request)\n\t\t} catch {\n\t\t\tverified = false\n\t\t}\n\t\tif (!verified) {\n\t\t\terrors.throw({\n\t\t\t\tcode: 'WEBHOOK_SIGNATURE_INVALID',\n\t\t\t\tmessage: 'Webhook signature verification failed',\n\t\t\t})\n\t\t}\n\t\tconst key = await config.eventKey(raw, request)\n\t\tawait ctx.runMutation(target as never, {\n\t\t\teventKey: key,\n\t\t\tpayload: raw,\n\t\t\tsource: config.source ?? 'unknown',\n\t\t} as never)\n\t\treturn new Response(null, { status: 200 })\n\t}\n\n\treturn { handle }\n}\n"],"mappings":"gHAcA,SAAgB,GAAqB,CACpC,OAAO,EAAS,qBAAwB,CACvC,SAAU,EAAE,OAAO,EACnB,OAAQ,EAAE,OAAO,EACjB,WAAY,EAAE,OAAO,CACtB,EAAE,CACH,CAoBA,eAAsB,EACrB,EACA,EACkC,CAClC,IAAM,EAAS,EAAM,OAAS,gBAa9B,OANI,MANmB,EAAI,GACzB,MAAM,CAAK,CAAC,CACZ,UAAU,cAAyB,GACnC,EAAQ,GAAG,WAAqB,EAAM,GAAY,CACnD,CAAC,CACA,MAAM,EACa,CAAE,UAAW,EAAK,GACvC,MAAM,EAAI,GAAG,OAAO,EAAO,CAC1B,SAAU,EAAM,IAChB,OAAQ,EAAM,QAAU,UACxB,WAAY,KAAK,IAAI,CACtB,CAAU,EACH,CAAE,UAAW,EAAM,EAC3B,CA+BA,SAAgB,EAAsB,EAA+B,CACpE,IAAM,EAAS,EAAO,QAAU,EAEhC,eAAe,EACd,EAGA,EACA,EACoB,CACpB,IAAM,EAAM,MAAM,EAAQ,KAAK,EAC3B,EAAW,GACf,GAAI,CACH,EAAW,MAAM,EAAO,OAAO,EAAK,CAAO,CAC5C,MAAQ,CACP,EAAW,EACZ,CACK,GACJ,EAAO,MAAM,CACZ,KAAM,4BACN,QAAS,uCACV,CAAC,EAEF,IAAM,EAAM,MAAM,EAAO,SAAS,EAAK,CAAO,EAM9C,OALA,MAAM,EAAI,YAAY,EAAiB,CACtC,SAAU,EACV,QAAS,EACT,OAAQ,EAAO,QAAU,SAC1B,CAAU,EACH,IAAI,SAAS,KAAM,CAAE,OAAQ,GAAI,CAAC,CAC1C,CAEA,MAAO,CAAE,QAAO,CACjB"}
@@ -99,10 +99,10 @@ declare function zodTable<Table extends string, Fields extends Shape, const Serv
99
99
  }, CommandFields[number]> extends (infer T_4) ? { -readonly [P in keyof T_4]: T_4[P]; } : never) extends (infer T_6 extends Readonly<{
100
100
  [k: string]: z.core.$ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>>;
101
101
  }>) ? { -readonly [k in keyof T_6]: z.ZodOptional<T_6[k]>; } : never, z.core.$strict>;
102
- id: import("convex-helpers/server/zod4").Zid<Table>;
102
+ id: z.ZodType<GenericId<Table>, string, z.core.$ZodTypeInternals<GenericId<Table>, string>>;
103
103
  }, z.core.$strict>;
104
104
  id: z.ZodObject<{
105
- id: import("convex-helpers/server/zod4").Zid<Table>;
105
+ id: z.ZodType<GenericId<Table>, string, z.core.$ZodTypeInternals<GenericId<Table>, string>>;
106
106
  }, z.core.$strict>;
107
107
  };
108
108
  };
@@ -126,6 +126,37 @@ declare function tenantTable<Table extends string, Fields extends Shape, const S
126
126
  */
127
127
  declare function createModule<const Maps extends readonly Record<string, TableDefinition<any>>[]>(...maps: Maps): UnionToIntersection<Maps[number]>;
128
128
  type UnionToIntersection<Union> = (Union extends unknown ? (member: Union) => void : never) extends ((member: infer Intersection) => void) ? Intersection : never;
129
+ /**
130
+ * Cursor-pagination boundary schemas bridged from Convex's own validators:
131
+ * `args.paginationOpts` for function args, `result` for the page shape.
132
+ * Pair with `include(...).paginate(opts)` (cvx-kit/auth). The result object
133
+ * is deliberately non-strict — Convex may attach engine fields (splitCursor,
134
+ * pageStatus); the page items themselves are validated against the dto.
135
+ * Note: under row-level security, pages may be SHORTER than numItems when
136
+ * rules reject rows mid-page; cursors remain correct.
137
+ */
138
+ declare function paginated<Dto extends z.ZodType>(dto: Dto): {
139
+ args: {
140
+ paginationOpts: z.ZodObject<{
141
+ numItems: z.ZodNumber;
142
+ cursor: z.ZodUnion<readonly [z.ZodString, z.ZodNull]>;
143
+ endCursor: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodNull]>>;
144
+ id: z.ZodOptional<z.ZodNumber>;
145
+ maximumRowsRead: z.ZodOptional<z.ZodNumber>;
146
+ maximumBytesRead: z.ZodOptional<z.ZodNumber>;
147
+ }, z.core.$strict>;
148
+ };
149
+ result: z.ZodObject<{
150
+ page: z.ZodArray<Dto>;
151
+ isDone: z.ZodBoolean;
152
+ continueCursor: z.ZodString;
153
+ splitCursor: z.ZodOptional<z.ZodNullable<z.ZodString>>;
154
+ pageStatus: z.ZodOptional<z.ZodNullable<z.ZodEnum<{
155
+ SplitRecommended: "SplitRecommended";
156
+ SplitRequired: "SplitRequired";
157
+ }>>>;
158
+ }, z.core.$strip>;
159
+ };
129
160
  /** Preserves discriminated-union storage while retaining the same table owner. */
130
161
  declare function zodVariantTable<Table extends string, Schema extends z.ZodType>(tableName: Table, storage: Schema): {
131
162
  tableName: Table;
@@ -140,5 +171,5 @@ declare function zodVariantTable<Table extends string, Schema extends z.ZodType>
140
171
  declare function jsonSafeZid<Table extends string>(tableName: Table): z.ZodType<GenericId<Table>, string>;
141
172
  type ZodObjectOmit<Schema extends z.ZodObject<any, any>, OmitMask extends z.util.Mask<keyof Schema['shape']>> = Schema extends z.ZodObject<infer ObjectShape, infer Config> ? z.ZodObject<z.util.Flatten<Omit<ObjectShape, Extract<keyof ObjectShape, keyof OmitMask>>>, Config> : never;
142
173
  //#endregion
143
- export { TIMESTAMP_FIELDS, TableBoundaryOptions, createModule, jsonSafeZid, tenantTable, zodTable, zodVariantTable };
174
+ export { TIMESTAMP_FIELDS, TableBoundaryOptions, createModule, jsonSafeZid, paginated, tenantTable, zodTable, zodVariantTable };
144
175
  //# sourceMappingURL=zod-table.d.mts.map
@@ -1,2 +1,2 @@
1
- import{zid as e,zodToConvex as t}from"convex-helpers/server/zod4";import{defineTable as n}from"convex/server";import{z as r}from"zod";const i={createdAt:r.number().optional(),updatedAt:r.number().optional(),archivedAt:r.number().optional()},a=[`createdAt`,`updatedAt`,`archivedAt`];function o(o,s,c={}){let l={...s(e),...i},u=r.object(l).strict(),d=u.extend({_id:e(o),_creationTime:r.number()}),m=r.object(p(l,[...a,...c.serverFields??[]])).strict(),h=m.partial(),g=r.object(f(l,c.commandFields??[])).strict(),_=r.object(f(l,c.publicFields??[])).strict(),v=e=>_.parse(Object.fromEntries(Object.keys(_.shape).map(t=>[t,e[t]])));function y(e){return e?m.omit(e):m}function b(e){return e?h.omit(e):h}return{tableName:o,schema:d,storage:u,insertSchema:m,updateSchema:h,commandInput:g,publicDto:_,toPublicDto:v,table:n(t(u)),insert:y,update:b,tools:{insert:g,update:r.object({data:g.partial(),id:e(o)}).strict(),id:r.object({id:e(o)}).strict()}}}const s={tenant:r.string().min(1)};function c(e,t,n={}){return o(e,e=>({...t(e),...s}),{...n,serverFields:[...n.serverFields??[],`tenant`]})}function l(...e){let t={};for(let n of e)for(let[e,r]of Object.entries(n)){if(e in t)throw Error(`Table "${e}" is declared by more than one module`);t[e]=r}return t}function u(e,r){return{tableName:e,storage:r,insertSchema:r,table:n(t(r))}}function d(e){return r.string().describe(`Convex document id for table "${e}"`)}function f(e,t){return Object.fromEntries(t.map(t=>[t,e[t]]))}function p(e,t){let n=new Set(t);return Object.fromEntries(Object.entries(e).filter(([e])=>!n.has(e)))}export{a as TIMESTAMP_FIELDS,l as createModule,d as jsonSafeZid,c as tenantTable,o as zodTable,u as zodVariantTable};
1
+ import{convexToZod as e,zid as t,zodToConvex as n}from"convex-helpers/server/zod4";import{defineTable as r,paginationOptsValidator as i}from"convex/server";import{z as a}from"zod";const o={createdAt:a.number().optional(),updatedAt:a.number().optional(),archivedAt:a.number().optional()},s=[`createdAt`,`updatedAt`,`archivedAt`];function c(e,i,c={}){let l={...i(t),...o},u=a.object(l).strict(),d=u.extend({_id:t(e),_creationTime:a.number()}),f=a.object(g(l,[...s,...c.serverFields??[]])).strict(),p=f.partial(),_=a.object(h(l,c.commandFields??[])).strict(),v=a.object(h(l,c.publicFields??[])).strict(),y=e=>v.parse(Object.fromEntries(Object.keys(v.shape).map(t=>[t,e[t]])));function b(e){return e?f.omit(e):f}function x(e){return e?p.omit(e):p}return{tableName:e,schema:d,storage:u,insertSchema:f,updateSchema:p,commandInput:_,publicDto:v,toPublicDto:y,table:r(n(u)),insert:b,update:x,tools:{insert:_,update:a.object({data:_.partial(),id:m(e)}).strict(),id:a.object({id:m(e)}).strict()}}}const l={tenant:a.string().min(1)};function u(e,t,n={}){return c(e,e=>({...t(e),...l}),{...n,serverFields:[...n.serverFields??[],`tenant`]})}function d(...e){let t={};for(let n of e)for(let[e,r]of Object.entries(n)){if(e in t)throw Error(`Table "${e}" is declared by more than one module`);t[e]=r}return t}function f(t){return{args:{paginationOpts:e(i)},result:a.object({page:a.array(t),isDone:a.boolean(),continueCursor:a.string(),splitCursor:a.string().nullable().optional(),pageStatus:a.enum([`SplitRecommended`,`SplitRequired`]).nullable().optional()})}}function p(e,t){return{tableName:e,storage:t,insertSchema:t,table:r(n(t))}}function m(e){return a.string().describe(`Convex document id for table "${e}"`)}function h(e,t){return Object.fromEntries(t.map(t=>[t,e[t]]))}function g(e,t){let n=new Set(t);return Object.fromEntries(Object.entries(e).filter(([e])=>!n.has(e)))}export{s as TIMESTAMP_FIELDS,d as createModule,m as jsonSafeZid,f as paginated,u as tenantTable,c as zodTable,p as zodVariantTable};
2
2
  //# sourceMappingURL=zod-table.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"zod-table.mjs","names":[],"sources":["../src/zod-table.ts"],"sourcesContent":["import {\n\ttype ConvexValidatorFromZod,\n\tzid,\n\tzodToConvex,\n} from 'convex-helpers/server/zod4'\nimport { defineTable, type TableDefinition } from 'convex/server'\nimport type { GenericId } from 'convex/values'\nimport { z } from 'zod'\n\ntype Shape = Record<string, z.ZodType>\ntype ShapeKey<Fields extends Shape> = Extract<keyof Fields, string>\n\n/**\n * Opinionated lifecycle timestamps — every table has them, server-owned,\n * always excluded from insert/command boundaries. Maintained by the\n * `timestamps` trigger helper (see cvx-kit/triggers).\n */\nconst timestampShape = {\n\tcreatedAt: z.number().optional(),\n\tupdatedAt: z.number().optional(),\n\tarchivedAt: z.number().optional(),\n} satisfies Shape\n\ntype TimestampShape = typeof timestampShape\n\nexport const TIMESTAMP_FIELDS = [\n\t'createdAt',\n\t'updatedAt',\n\t'archivedAt',\n] as const\n\nexport type TableBoundaryOptions<\n\tFields extends Shape,\n\tServerFields extends readonly ShapeKey<Fields>[],\n\tCommandFields extends readonly ShapeKey<Fields>[],\n\tPublicFields extends readonly ShapeKey<Fields & TimestampShape>[],\n> = {\n\tserverFields?: ServerFields\n\tcommandFields?: CommandFields\n\tpublicFields?: PublicFields\n}\n\n/** Defines one table and derives its storage, document, write, and DTO boundaries. */\nexport function zodTable<\n\tTable extends string,\n\tFields extends Shape,\n\tconst ServerFields extends readonly ShapeKey<Fields>[] = readonly [],\n\tconst CommandFields extends readonly ShapeKey<Fields>[] = readonly [],\n\tconst PublicFields extends readonly ShapeKey<\n\t\tFields & TimestampShape\n\t>[] = readonly [],\n>(\n\ttableName: Table,\n\tfields: (id: typeof zid) => Fields,\n\toptions: TableBoundaryOptions<\n\t\tFields,\n\t\tServerFields,\n\t\tCommandFields,\n\t\tPublicFields\n\t> = {},\n) {\n\tconst shape = { ...fields(zid), ...timestampShape } as Fields &\n\t\tTimestampShape\n\tconst storage = z.object(shape).strict()\n\tconst schema = storage.extend({\n\t\t_id: zid(tableName),\n\t\t_creationTime: z.number(),\n\t})\n\tconst insertSchema = z\n\t\t.object(\n\t\t\tomitShape(shape, [\n\t\t\t\t...TIMESTAMP_FIELDS,\n\t\t\t\t...(options.serverFields ?? ([] as never)),\n\t\t\t] as never),\n\t\t)\n\t\t.strict() as z.ZodObject<\n\t\tOmit<Fields, ServerFields[number] | (typeof TIMESTAMP_FIELDS)[number]>\n\t>\n\tconst updateSchema = insertSchema.partial()\n\tconst commandInput = z\n\t\t.object(pickShape(shape, options.commandFields ?? ([] as never)))\n\t\t.strict()\n\tconst publicDto = z\n\t\t.object(pickShape(shape, options.publicFields ?? ([] as never)))\n\t\t.strict()\n\tconst toPublicDto = (row: z.input<typeof storage>) =>\n\t\tpublicDto.parse(\n\t\t\tObject.fromEntries(\n\t\t\t\tObject.keys(publicDto.shape).map((key) => [\n\t\t\t\t\tkey,\n\t\t\t\t\t(row as Record<string, unknown>)[key],\n\t\t\t\t]),\n\t\t\t),\n\t\t)\n\n\ttype InsertSchema = typeof insertSchema\n\ttype UpdateSchema = typeof updateSchema\n\ttype InsertMask = z.util.Mask<keyof InsertSchema['shape']>\n\ttype UpdateMask = z.util.Mask<keyof UpdateSchema['shape']>\n\n\tfunction insert(): InsertSchema\n\tfunction insert<OmitMask extends InsertMask>(\n\t\tomit: OmitMask,\n\t): ZodObjectOmit<InsertSchema, OmitMask>\n\tfunction insert(omit?: InsertMask) {\n\t\treturn omit ? insertSchema.omit(omit as never) : insertSchema\n\t}\n\n\tfunction update(): UpdateSchema\n\tfunction update<OmitMask extends UpdateMask>(\n\t\tomit: OmitMask,\n\t): ZodObjectOmit<UpdateSchema, OmitMask>\n\tfunction update(omit?: UpdateMask) {\n\t\treturn omit ? updateSchema.omit(omit as never) : updateSchema\n\t}\n\n\treturn {\n\t\ttableName,\n\t\tschema,\n\t\tstorage,\n\t\tinsertSchema,\n\t\tupdateSchema,\n\t\tcommandInput,\n\t\tpublicDto,\n\t\ttoPublicDto,\n\t\ttable: defineTable(zodToConvex(storage)) as TableDefinition<\n\t\t\tConvexValidatorFromZod<typeof storage, 'required'>\n\t\t>,\n\t\tinsert,\n\t\tupdate,\n\t\ttools: {\n\t\t\tinsert: commandInput,\n\t\t\tupdate: z\n\t\t\t\t.object({ data: commandInput.partial(), id: zid(tableName) })\n\t\t\t\t.strict(),\n\t\t\tid: z.object({ id: zid(tableName) }).strict(),\n\t\t},\n\t}\n}\n\nconst tenantShape = {\n\ttenant: z.string().min(1),\n} satisfies Shape\n\ntype TenantShape = typeof tenantShape\n\n/**\n * A zodTable whose rows are tenant-owned: injects the server-owned `tenant`\n * field (the tenancy boundary — see cvx-kit/tenancy). Like timestamps, it is\n * excluded from insert/update/command boundaries; handlers stamp it from\n * ctx.tenant, row-level security matches on it, and the tenantOwnership\n * trigger forbids reassigning it. Expose it via publicFields only when a DTO\n * genuinely needs it.\n */\nexport function tenantTable<\n\tTable extends string,\n\tFields extends Shape,\n\tconst ServerFields extends readonly ShapeKey<Fields>[] = readonly [],\n\tconst CommandFields extends readonly ShapeKey<Fields>[] = readonly [],\n\tconst PublicFields extends readonly ShapeKey<\n\t\tFields & TenantShape & TimestampShape\n\t>[] = readonly [],\n>(\n\ttableName: Table,\n\tfields: (id: typeof zid) => Fields,\n\toptions: TableBoundaryOptions<\n\t\tFields & TenantShape,\n\t\treadonly (ServerFields[number] | 'tenant')[],\n\t\tCommandFields,\n\t\tPublicFields\n\t> = {},\n) {\n\treturn zodTable(\n\t\ttableName,\n\t\t(id) => ({ ...fields(id), ...tenantShape }),\n\t\t{\n\t\t\t...options,\n\t\t\tserverFields: [\n\t\t\t\t...((options.serverFields ?? []) as readonly (\n\t\t\t\t\t| ServerFields[number]\n\t\t\t\t\t| 'tenant'\n\t\t\t\t)[]),\n\t\t\t\t'tenant',\n\t\t\t] as never,\n\t\t} as never,\n\t) as ReturnType<\n\t\ttypeof zodTable<\n\t\t\tTable,\n\t\t\tFields & TenantShape,\n\t\t\treadonly (ServerFields[number] | 'tenant')[],\n\t\t\tCommandFields,\n\t\t\tPublicFields\n\t\t>\n\t>\n}\n\n/**\n * Merges per-module table maps into the application schema map, rejecting\n * duplicate table names across modules — the module-registry combinator for\n * domain/table.ts: `defineSchema(createModule(catalogTables, salesTables))`.\n */\nexport function createModule<\n\tconst Maps extends readonly Record<string, TableDefinition<any>>[],\n>(...maps: Maps): UnionToIntersection<Maps[number]> {\n\tconst combined: Record<string, TableDefinition<any>> = {}\n\tfor (const map of maps) {\n\t\tfor (const [tableName, definition] of Object.entries(map)) {\n\t\t\tif (tableName in combined) {\n\t\t\t\tthrow new Error(\n\t\t\t\t\t`Table \"${tableName}\" is declared by more than one module`,\n\t\t\t\t)\n\t\t\t}\n\t\t\tcombined[tableName] = definition\n\t\t}\n\t}\n\treturn combined as UnionToIntersection<Maps[number]>\n}\n\ntype UnionToIntersection<Union> = (\n\tUnion extends unknown\n\t\t? (member: Union) => void\n\t\t: never\n) extends (member: infer Intersection) => void\n\t? Intersection\n\t: never\n\n/** Preserves discriminated-union storage while retaining the same table owner. */\nexport function zodVariantTable<Table extends string, Schema extends z.ZodType>(\n\ttableName: Table,\n\tstorage: Schema,\n) {\n\treturn {\n\t\ttableName,\n\t\tstorage,\n\t\tinsertSchema: storage,\n\t\ttable: defineTable(zodToConvex(storage)),\n\t}\n}\n\n/**\n * A zid that presents as a plain string in generated JSON schemas so\n * LLM-tool inputs stay primitive, while keeping the Id type at compile time.\n */\nexport function jsonSafeZid<Table extends string>(tableName: Table) {\n\treturn z\n\t\t.string()\n\t\t.describe(\n\t\t\t`Convex document id for table \"${tableName}\"`,\n\t\t) as unknown as z.ZodType<GenericId<Table>, string>\n}\n\ntype ZodObjectOmit<\n\tSchema extends z.ZodObject<any, any>,\n\tOmitMask extends z.util.Mask<keyof Schema['shape']>,\n> =\n\tSchema extends z.ZodObject<infer ObjectShape, infer Config>\n\t\t? z.ZodObject<\n\t\t\t\tz.util.Flatten<\n\t\t\t\t\tOmit<ObjectShape, Extract<keyof ObjectShape, keyof OmitMask>>\n\t\t\t\t>,\n\t\t\t\tConfig\n\t\t\t>\n\t\t: never\n\nfunction pickShape<\n\tFields extends Shape,\n\tconst Keys extends readonly ShapeKey<Fields>[],\n>(fields: Fields, keys: Keys) {\n\treturn Object.fromEntries(keys.map((key) => [key, fields[key]])) as Pick<\n\t\tFields,\n\t\tKeys[number]\n\t>\n}\n\nfunction omitShape<\n\tFields extends Shape,\n\tconst Keys extends readonly ShapeKey<Fields>[],\n>(fields: Fields, keys: Keys) {\n\tconst omitted = new Set<string>(keys)\n\treturn Object.fromEntries(\n\t\tObject.entries(fields).filter(([key]) => !omitted.has(key)),\n\t) as Omit<Fields, Keys[number]>\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"zod-table.mjs","names":[],"sources":["../src/zod-table.ts"],"sourcesContent":["import {\n\ttype ConvexValidatorFromZod,\n\tconvexToZod,\n\tzid,\n\tzodToConvex,\n} from 'convex-helpers/server/zod4'\nimport {\n\tdefineTable,\n\tpaginationOptsValidator,\n\ttype TableDefinition,\n} from 'convex/server'\nimport type { GenericId } from 'convex/values'\nimport { z } from 'zod'\n\ntype Shape = Record<string, z.ZodType>\ntype ShapeKey<Fields extends Shape> = Extract<keyof Fields, string>\n\n/**\n * Opinionated lifecycle timestamps — every table has them, server-owned,\n * always excluded from insert/command boundaries. Maintained by the\n * `timestamps` trigger helper (see cvx-kit/triggers).\n */\nconst timestampShape = {\n\tcreatedAt: z.number().optional(),\n\tupdatedAt: z.number().optional(),\n\tarchivedAt: z.number().optional(),\n} satisfies Shape\n\ntype TimestampShape = typeof timestampShape\n\nexport const TIMESTAMP_FIELDS = [\n\t'createdAt',\n\t'updatedAt',\n\t'archivedAt',\n] as const\n\nexport type TableBoundaryOptions<\n\tFields extends Shape,\n\tServerFields extends readonly ShapeKey<Fields>[],\n\tCommandFields extends readonly ShapeKey<Fields>[],\n\tPublicFields extends readonly ShapeKey<Fields & TimestampShape>[],\n> = {\n\tserverFields?: ServerFields\n\tcommandFields?: CommandFields\n\tpublicFields?: PublicFields\n}\n\n/** Defines one table and derives its storage, document, write, and DTO boundaries. */\nexport function zodTable<\n\tTable extends string,\n\tFields extends Shape,\n\tconst ServerFields extends readonly ShapeKey<Fields>[] = readonly [],\n\tconst CommandFields extends readonly ShapeKey<Fields>[] = readonly [],\n\tconst PublicFields extends readonly ShapeKey<\n\t\tFields & TimestampShape\n\t>[] = readonly [],\n>(\n\ttableName: Table,\n\tfields: (id: typeof zid) => Fields,\n\toptions: TableBoundaryOptions<\n\t\tFields,\n\t\tServerFields,\n\t\tCommandFields,\n\t\tPublicFields\n\t> = {},\n) {\n\tconst shape = { ...fields(zid), ...timestampShape } as Fields &\n\t\tTimestampShape\n\tconst storage = z.object(shape).strict()\n\tconst schema = storage.extend({\n\t\t_id: zid(tableName),\n\t\t_creationTime: z.number(),\n\t})\n\tconst insertSchema = z\n\t\t.object(\n\t\t\tomitShape(shape, [\n\t\t\t\t...TIMESTAMP_FIELDS,\n\t\t\t\t...(options.serverFields ?? ([] as never)),\n\t\t\t] as never),\n\t\t)\n\t\t.strict() as z.ZodObject<\n\t\tOmit<Fields, ServerFields[number] | (typeof TIMESTAMP_FIELDS)[number]>\n\t>\n\tconst updateSchema = insertSchema.partial()\n\tconst commandInput = z\n\t\t.object(pickShape(shape, options.commandFields ?? ([] as never)))\n\t\t.strict()\n\tconst publicDto = z\n\t\t.object(pickShape(shape, options.publicFields ?? ([] as never)))\n\t\t.strict()\n\tconst toPublicDto = (row: z.input<typeof storage>) =>\n\t\tpublicDto.parse(\n\t\t\tObject.fromEntries(\n\t\t\t\tObject.keys(publicDto.shape).map((key) => [\n\t\t\t\t\tkey,\n\t\t\t\t\t(row as Record<string, unknown>)[key],\n\t\t\t\t]),\n\t\t\t),\n\t\t)\n\n\ttype InsertSchema = typeof insertSchema\n\ttype UpdateSchema = typeof updateSchema\n\ttype InsertMask = z.util.Mask<keyof InsertSchema['shape']>\n\ttype UpdateMask = z.util.Mask<keyof UpdateSchema['shape']>\n\n\tfunction insert(): InsertSchema\n\tfunction insert<OmitMask extends InsertMask>(\n\t\tomit: OmitMask,\n\t): ZodObjectOmit<InsertSchema, OmitMask>\n\tfunction insert(omit?: InsertMask) {\n\t\treturn omit ? insertSchema.omit(omit as never) : insertSchema\n\t}\n\n\tfunction update(): UpdateSchema\n\tfunction update<OmitMask extends UpdateMask>(\n\t\tomit: OmitMask,\n\t): ZodObjectOmit<UpdateSchema, OmitMask>\n\tfunction update(omit?: UpdateMask) {\n\t\treturn omit ? updateSchema.omit(omit as never) : updateSchema\n\t}\n\n\treturn {\n\t\ttableName,\n\t\tschema,\n\t\tstorage,\n\t\tinsertSchema,\n\t\tupdateSchema,\n\t\tcommandInput,\n\t\tpublicDto,\n\t\ttoPublicDto,\n\t\ttable: defineTable(zodToConvex(storage)) as TableDefinition<\n\t\t\tConvexValidatorFromZod<typeof storage, 'required'>\n\t\t>,\n\t\tinsert,\n\t\tupdate,\n\t\ttools: {\n\t\t\t// jsonSafeZid (not zid): tool masks feed generated JSON schemas, so\n\t\t\t// ids must present as plain strings while keeping the Id<...> type.\n\t\t\tinsert: commandInput,\n\t\t\tupdate: z\n\t\t\t\t.object({ data: commandInput.partial(), id: jsonSafeZid(tableName) })\n\t\t\t\t.strict(),\n\t\t\tid: z.object({ id: jsonSafeZid(tableName) }).strict(),\n\t\t},\n\t}\n}\n\nconst tenantShape = {\n\ttenant: z.string().min(1),\n} satisfies Shape\n\ntype TenantShape = typeof tenantShape\n\n/**\n * A zodTable whose rows are tenant-owned: injects the server-owned `tenant`\n * field (the tenancy boundary — see cvx-kit/tenancy). Like timestamps, it is\n * excluded from insert/update/command boundaries; handlers stamp it from\n * ctx.tenant, row-level security matches on it, and the tenantOwnership\n * trigger forbids reassigning it. Expose it via publicFields only when a DTO\n * genuinely needs it.\n */\nexport function tenantTable<\n\tTable extends string,\n\tFields extends Shape,\n\tconst ServerFields extends readonly ShapeKey<Fields>[] = readonly [],\n\tconst CommandFields extends readonly ShapeKey<Fields>[] = readonly [],\n\tconst PublicFields extends readonly ShapeKey<\n\t\tFields & TenantShape & TimestampShape\n\t>[] = readonly [],\n>(\n\ttableName: Table,\n\tfields: (id: typeof zid) => Fields,\n\toptions: TableBoundaryOptions<\n\t\tFields & TenantShape,\n\t\treadonly (ServerFields[number] | 'tenant')[],\n\t\tCommandFields,\n\t\tPublicFields\n\t> = {},\n) {\n\treturn zodTable(\n\t\ttableName,\n\t\t(id) => ({ ...fields(id), ...tenantShape }),\n\t\t{\n\t\t\t...options,\n\t\t\tserverFields: [\n\t\t\t\t...((options.serverFields ?? []) as readonly (\n\t\t\t\t\t| ServerFields[number]\n\t\t\t\t\t| 'tenant'\n\t\t\t\t)[]),\n\t\t\t\t'tenant',\n\t\t\t] as never,\n\t\t} as never,\n\t) as ReturnType<\n\t\ttypeof zodTable<\n\t\t\tTable,\n\t\t\tFields & TenantShape,\n\t\t\treadonly (ServerFields[number] | 'tenant')[],\n\t\t\tCommandFields,\n\t\t\tPublicFields\n\t\t>\n\t>\n}\n\n/**\n * Merges per-module table maps into the application schema map, rejecting\n * duplicate table names across modules — the module-registry combinator for\n * domain/table.ts: `defineSchema(createModule(catalogTables, salesTables))`.\n */\nexport function createModule<\n\tconst Maps extends readonly Record<string, TableDefinition<any>>[],\n>(...maps: Maps): UnionToIntersection<Maps[number]> {\n\tconst combined: Record<string, TableDefinition<any>> = {}\n\tfor (const map of maps) {\n\t\tfor (const [tableName, definition] of Object.entries(map)) {\n\t\t\tif (tableName in combined) {\n\t\t\t\tthrow new Error(\n\t\t\t\t\t`Table \"${tableName}\" is declared by more than one module`,\n\t\t\t\t)\n\t\t\t}\n\t\t\tcombined[tableName] = definition\n\t\t}\n\t}\n\treturn combined as UnionToIntersection<Maps[number]>\n}\n\ntype UnionToIntersection<Union> = (\n\tUnion extends unknown\n\t\t? (member: Union) => void\n\t\t: never\n) extends (member: infer Intersection) => void\n\t? Intersection\n\t: never\n\n/**\n * Cursor-pagination boundary schemas bridged from Convex's own validators:\n * `args.paginationOpts` for function args, `result` for the page shape.\n * Pair with `include(...).paginate(opts)` (cvx-kit/auth). The result object\n * is deliberately non-strict — Convex may attach engine fields (splitCursor,\n * pageStatus); the page items themselves are validated against the dto.\n * Note: under row-level security, pages may be SHORTER than numItems when\n * rules reject rows mid-page; cursors remain correct.\n */\nexport function paginated<Dto extends z.ZodType>(dto: Dto) {\n\treturn {\n\t\targs: { paginationOpts: convexToZod(paginationOptsValidator) },\n\t\tresult: z.object({\n\t\t\tpage: z.array(dto),\n\t\t\tisDone: z.boolean(),\n\t\t\tcontinueCursor: z.string(),\n\t\t\tsplitCursor: z.string().nullable().optional(),\n\t\t\tpageStatus: z\n\t\t\t\t.enum(['SplitRecommended', 'SplitRequired'])\n\t\t\t\t.nullable()\n\t\t\t\t.optional(),\n\t\t}),\n\t}\n}\n\n/** Preserves discriminated-union storage while retaining the same table owner. */\nexport function zodVariantTable<Table extends string, Schema extends z.ZodType>(\n\ttableName: Table,\n\tstorage: Schema,\n) {\n\treturn {\n\t\ttableName,\n\t\tstorage,\n\t\tinsertSchema: storage,\n\t\ttable: defineTable(zodToConvex(storage)),\n\t}\n}\n\n/**\n * A zid that presents as a plain string in generated JSON schemas so\n * LLM-tool inputs stay primitive, while keeping the Id type at compile time.\n */\nexport function jsonSafeZid<Table extends string>(tableName: Table) {\n\treturn z\n\t\t.string()\n\t\t.describe(\n\t\t\t`Convex document id for table \"${tableName}\"`,\n\t\t) as unknown as z.ZodType<GenericId<Table>, string>\n}\n\ntype ZodObjectOmit<\n\tSchema extends z.ZodObject<any, any>,\n\tOmitMask extends z.util.Mask<keyof Schema['shape']>,\n> =\n\tSchema extends z.ZodObject<infer ObjectShape, infer Config>\n\t\t? z.ZodObject<\n\t\t\t\tz.util.Flatten<\n\t\t\t\t\tOmit<ObjectShape, Extract<keyof ObjectShape, keyof OmitMask>>\n\t\t\t\t>,\n\t\t\t\tConfig\n\t\t\t>\n\t\t: never\n\nfunction pickShape<\n\tFields extends Shape,\n\tconst Keys extends readonly ShapeKey<Fields>[],\n>(fields: Fields, keys: Keys) {\n\treturn Object.fromEntries(keys.map((key) => [key, fields[key]])) as Pick<\n\t\tFields,\n\t\tKeys[number]\n\t>\n}\n\nfunction omitShape<\n\tFields extends Shape,\n\tconst Keys extends readonly ShapeKey<Fields>[],\n>(fields: Fields, keys: Keys) {\n\tconst omitted = new Set<string>(keys)\n\treturn Object.fromEntries(\n\t\tObject.entries(fields).filter(([key]) => !omitted.has(key)),\n\t) as Omit<Fields, Keys[number]>\n}\n"],"mappings":"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"}
@@ -0,0 +1,41 @@
1
+ # `cvx-kit/agent-tools` — agents get audited commands
2
+
3
+ `createAgentTools` turns a table's tool masks into framework-agnostic tool
4
+ records `{ name, description, args, handler }` — directly spreadable into
5
+ `@convex-dev/agent`'s `createTool`, adaptable to ai-sdk `tool()`, and the
6
+ kit depends on neither (shape checked against `@convex-dev/agent@0.6.x`;
7
+ re-verify at install).
8
+
9
+ The point is the routing, not the shape: **mutation handlers go through
10
+ command executors**, so every agent action is validated, guarded, audited,
11
+ and observed. An agent never gets raw db access.
12
+
13
+ ```ts
14
+ import { createAgentTools } from 'cvx-kit/agent-tools'
15
+
16
+ const tools = createAgentTools(<entities>, {
17
+ // mutations — audited commands (createCrudCommands executors fit exactly):
18
+ create: (ctx, input) => crud.executeCreate(ctx, input),
19
+ update: (ctx, input) => crud.executeUpdate(ctx, input),
20
+ archive: (ctx, input) => crud.executeArchive(ctx, input),
21
+ // reads — caller-supplied query handlers (reads don't route through commands):
22
+ get: (ctx, { id }) => loadDto(ctx, id),
23
+ list: (ctx, { paginationOpts }) => listPage(ctx, paginationOpts),
24
+ })
25
+
26
+ // host-side, with @convex-dev/agent:
27
+ // const agentTools = Object.fromEntries(Object.values(tools).map((t) =>
28
+ // [t.name, createTool({ description: t.description, args: t.args, handler: t.handler })]))
29
+ ```
30
+
31
+ ## Contracts
32
+
33
+ - **Args are the table masks, no drift**: `create` = `tools.insert`
34
+ (`commandFields`), `update` = `tools.update`, `archive`/`get` = `tools.id`,
35
+ `list` = the pagination opts boundary. Ids present as plain strings in
36
+ generated JSON schemas (`jsonSafeZid`) while keeping `Id<...>` types.
37
+ - **Only the kinds you supply handlers for are emitted**; names are
38
+ `<tableName>_<verb>`; descriptions are derived and overridable via
39
+ `options.descriptions`.
40
+ - DTO discipline applies to reads: `get`/`list` handlers should return
41
+ `toPublicDto`-projected data, same as any public query.
package/docs/approvals.md CHANGED
@@ -16,6 +16,20 @@ import approvals from 'cvx-kit/components/approvals/convex.config'
16
16
  app.use(approvals)
17
17
  ```
18
18
 
19
+ After installation or upgrade, deploy once and run the component health query
20
+ from a host wrapper:
21
+
22
+ ```ts
23
+ return ctx.runQuery(components.approvals.health.check, {})
24
+ ```
25
+
26
+ It returns the installed approvals schema version and required index names. It
27
+ also executes reads through
28
+ `approvalDecisions.by_runId_and_decidedAt` and
29
+ `approvalDecisions.by_runId_and_stepKey_and_actor_actorRef`, failing with an
30
+ installation-specific diagnostic if the packaged component schema was not
31
+ deployed.
32
+
19
33
  The component mounts its own private `workflow` and `auditLog` children. Do
20
34
  **not** re-register those children at the app root under the same identity,
21
35
  and never reach into `components.approvals.workflow` / `.auditLog` from host
@@ -99,10 +113,14 @@ const { runId } = await publishApproval.start(ctx, {
99
113
  // decide — a manager approves or rejects
100
114
  await publishApproval.decide(ctx, { runId, decision: 'approved', reason }, ctx.actor)
101
115
 
102
- // observe
116
+ // observe from a host query
103
117
  await publishApproval.status(ctx, runId) // pending | approved | rejected | expired | canceled
104
118
  await publishApproval.evidence(ctx, runId) // per-step decision evidence
105
- await publishApproval.list(ctx, { scopeRef, ... })
119
+ await publishApproval.list(ctx, {
120
+ scopeRef,
121
+ state: 'pending',
122
+ paginationOpts: { cursor: null, numItems: 20 },
123
+ })
106
124
 
107
125
  // manage
108
126
  await publishApproval.cancel(ctx, runId, ctx.actor)
@@ -119,6 +137,8 @@ Because the component cannot know your tables, the handler must re-establish
119
137
  trust:
120
138
 
121
139
  ```ts
140
+ import { approvalCallbackArgs } from 'cvx-kit/components/approvals'
141
+
122
142
  export const applyDecision = systemMutation({
123
143
  args: approvalCallbackArgs,
124
144
  handler: async (ctx, input) => {
@@ -136,6 +156,34 @@ export const applyDecision = systemMutation({
136
156
  That `approvalRunId` check is the idempotency/staleness guard from the origin
137
157
  app — copy it.
138
158
 
159
+ For branch callbacks, `decision.evidence` contains
160
+ `{ stepKey, actorRef, decision, reason?, decidedAt }` items. Use the exported
161
+ `approvalCallbackArgs` validator instead of duplicating that protocol boundary.
162
+
163
+ ### Paginated inboxes
164
+
165
+ Expose the configured workflow through a host query; component pagination uses
166
+ `convex-helpers` internally because Convex's built-in `.paginate()` is not
167
+ available inside components:
168
+
169
+ ```ts
170
+ export const approvalInbox = authQuery({
171
+ args: {
172
+ state: z.enum(['pending', 'approved', 'rejected', 'expired', 'canceled']).optional(),
173
+ paginationOpts: convexToZod(paginationOptsValidator),
174
+ },
175
+ handler: (ctx, args) => publishApproval.list(ctx, {
176
+ scopeRef: ctx.org.organizationId,
177
+ ...args,
178
+ }),
179
+ })
180
+ ```
181
+
182
+ The result keeps Convex's standard pagination shape, so React consumers can
183
+ call `usePaginatedQuery(api.approvals.approvalInbox, { state: 'pending' },
184
+ { initialNumItems: 20 })`. Pass each returned cursor only back to the same
185
+ scope/state query.
186
+
139
187
  ## Rules and footguns
140
188
 
141
189
  1. Root facade (`convex/approvals.ts`) instantiates the client; workflow
package/docs/crud.md ADDED
@@ -0,0 +1,62 @@
1
+ # `cvx-kit/crud` — the CRUD command factory
2
+
3
+ `createCrudCommands` generates the three standard operations every entity
4
+ starts with — create, update, archive — from one zodTable declaration,
5
+ **fully inside the audited command pipeline**: strict command inputs from
6
+ `commandFields`, mandatory audit with a declared aggregate type (enforced by
7
+ the aggregates allowlist), observability, guards, and middleware.
8
+
9
+ > convex-helpers ships a same-named `crud` helper. It was deliberately not
10
+ > used here: it bypasses the command pipeline — no audits, no guards, and
11
+ > hard deletes. This factory is the kit-doctrine equivalent.
12
+
13
+ ## Usage
14
+
15
+ ```ts
16
+ import { createCrudCommands } from 'cvx-kit/crud'
17
+ import { Command } from '../foundation' // the destructured facade
18
+
19
+ const crud = createCrudCommands({
20
+ Command,
21
+ table: <entities>, // a zodTable / tenantTable
22
+ aggregateType: '<entity>',
23
+ classification: 'business', // default
24
+ actor: (ctx) => ctx.actor.userId, // audited actor
25
+ enrich: (ctx) => ({ tenant: ctx.tenant }), // server-owned stamps on create
26
+ guards: { archive: (ctx, command) => machine.assert(...) }, // optional
27
+ middleware: [rateLimit({ limiter, name: '<entities>.write' })], // optional
28
+ })
29
+
30
+ // thin api adapters:
31
+ export const create = authMutation({
32
+ args: <entities>.commandInput,
33
+ handler: (ctx, args) => crud.executeCreate(ctx, args),
34
+ })
35
+ ```
36
+
37
+ ## What gets generated
38
+
39
+ | Operation | Input | Effect | Audit aggregate id |
40
+ |---|---|---|---|
41
+ | `<table>.create` | `commandInput` (strict) + `enrich` stamps | insert | the new row id |
42
+ | `<table>.update` | `{ id, data: commandInput.partial() }` (strict) | patch | the row id |
43
+ | `<table>.archive` | `{ id }` | sets `archivedAt` (soft delete ONLY) | the row id |
44
+
45
+ No hard-delete operation is generated — kit doctrine (`noDelete` +
46
+ `archivedAt`).
47
+
48
+ ## Tenancy
49
+
50
+ - `enrich` is **required for tenantTables** — the factory fails fast with
51
+ `CRUD_ENRICH_REQUIRED` instead of letting untenanted rows ship. Stamp
52
+ `tenant: ctx.tenant` there.
53
+ - Generated update/archive take client-supplied ids: with `security.tenancy`
54
+ configured, the RLS-wrapped writer rejects cross-tenant writes (this is
55
+ covered by the kit's own tests) — but keep `requireTenantReference` in
56
+ bespoke guards that load rows.
57
+
58
+ ## Wiring agents
59
+
60
+ The executors are the natural handlers for `createAgentTools` mutation
61
+ kinds — agents get audited commands, never raw db access. See
62
+ `agent-tools.md`.
package/docs/llms.md CHANGED
@@ -7,6 +7,8 @@
7
7
  > `migration.md` (same directory).
8
8
  > Structure, file anatomy, and naming rules → `conventions.md` is
9
9
  > authoritative. Restructuring an existing raw project → `migration.md`.
10
+ > Upgrading from 0.0.x → `upgrading.md`. New 0.1.0 helpers: `crud.md`,
11
+ > `webhooks.md`, `agent-tools.md`; pagination in `zod-table.md`/`auth.md`.
10
12
 
11
13
  > **Placeholders.** Names like `documents`, `users`, `history`, `title`,
12
14
  > `ownerId`, and `documentPublish` in the examples below are illustrative
@@ -70,9 +72,14 @@ function constructors, a trigger registry, an audited command protocol
70
72
  | Import | Provides |
71
73
  |---|---|
72
74
  | `cvx-kit` | everything below re-exported (except components' defaults) |
73
- | `cvx-kit/zod-table` | `zodTable`, `tenantTable`, `createModule`, `zodVariantTable`, `jsonSafeZid`, `TIMESTAMP_FIELDS` |
75
+ | `cvx-kit/zod-table` | `zodTable`, `tenantTable`, `createModule`, `paginated`, `zodVariantTable`, `jsonSafeZid`, `TIMESTAMP_FIELDS` |
74
76
  | `cvx-kit/auth` | `createAuthFunctions` (incl. optional `security` RLS config), `createInclude`, `defaultRoleMap` |
75
77
  | `cvx-kit/tenancy` | `createTenantRules`, `composeRules`, `requireTenantReference`, `assertTenantOwned`, `TENANT_FIELD` |
78
+ | `cvx-kit/crud` | `createCrudCommands` — create/update/archive from a zodTable, inside the command pipeline; `enrich` REQUIRED for tenantTables |
79
+ | `cvx-kit/state-machine` | `createStateMachine` — typed transitions from constants tuples; `assert` drops into command guards |
80
+ | `cvx-kit/middleware` | `rateLimit` — packaged middleware over an injected rate-limiter instance; keyed by `ctx.tenant`, missing key = config error |
81
+ | `cvx-kit/webhooks` | `createWebhookBoundary`, `recordWebhookEvent`, `webhookEventsTable` — raw-body verify, natural-key dedup in the mutation |
82
+ | `cvx-kit/agent-tools` | `createAgentTools` — tool records from table masks; mutation handlers route through command executors |
76
83
  | `cvx-kit/triggers` | `createTriggers`, `timestamps`, `appendOnly`, `noDelete`, `tenantOwnership`, `Triggers` |
77
84
  | `cvx-kit/errors` | `KitError`, `defaultErrors`, `ErrorFactory` |
78
85
  | `cvx-kit/components/foundation` | `Foundation` — the ONLY runtime export besides the default component config for `app.use`. Everything (`Command`, `Query`, `observability`, `executeResultBoundary`, `projectResult`, `emitSemanticEvent`) destructures from the instance; nothing is importable loose. |