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.
- package/CHANGELOG.md +132 -0
- package/README.md +14 -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/approvals/_generated/api.d.mts +2 -0
- package/dist/components/approvals/_generated/api.mjs.map +1 -1
- package/dist/components/approvals/_generated/component.d.mts +7 -0
- package/dist/components/approvals/_generated/dataModel.d.mts +1 -1
- package/dist/components/approvals/client.d.mts +4 -4
- package/dist/components/approvals/client.mjs +1 -1
- package/dist/components/approvals/client.mjs.map +1 -1
- package/dist/components/approvals/constants.d.mts +8 -2
- package/dist/components/approvals/constants.mjs +1 -1
- package/dist/components/approvals/constants.mjs.map +1 -1
- package/dist/components/approvals/decisions.mjs +1 -1
- package/dist/components/approvals/decisions.mjs.map +1 -1
- package/dist/components/approvals/health.d.mts +15 -0
- package/dist/components/approvals/health.mjs +2 -0
- package/dist/components/approvals/health.mjs.map +1 -0
- package/dist/components/approvals/requests.mjs +1 -1
- package/dist/components/approvals/requests.mjs.map +1 -1
- package/dist/components/approvals/schema.js +1 -0
- package/dist/components/approvals/validators.d.mts +119 -2
- package/dist/components/approvals/validators.mjs +1 -1
- package/dist/components/approvals/validators.mjs.map +1 -1
- package/dist/components/approvals/workflow_steps.d.mts +2 -25
- package/dist/components/approvals/workflow_steps.mjs.map +1 -1
- package/dist/components/foundation/_generated/dataModel.d.mts +1 -1
- package/dist/components/foundation/client.d.mts +26 -9
- package/dist/components/foundation/client.mjs.map +1 -1
- package/dist/components/foundation/modules/query/query.d.mts +19 -8
- package/dist/components/foundation/modules/query/query.mjs.map +1 -1
- package/dist/components/foundation/{schema.mjs → schema.js} +0 -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 -4
- 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/approvals.md +50 -2
- package/docs/crud.md +62 -0
- package/docs/llms.md +8 -1
- package/docs/plans/2026-08-14-001-feat-v0.1.0-feature-set-plan.md +461 -0
- package/docs/upgrading.md +175 -0
- package/docs/webhooks.md +58 -0
- package/package.json +25 -2
- package/scripts/convex-config-bridge.mjs +41 -3
- package/scripts/extract-changelog.mjs +29 -0
- package/scripts/smoke-packed-components.mjs +374 -0
- package/src/test.ts +2 -2
- package/dist/components/approvals/schema.mjs +0 -2
- package/dist/components/approvals/schema.mjs.map +0 -1
- package/dist/components/foundation/schema.mjs.map +0 -1
- /package/dist/components/approvals/{schema.d.mts → schema.d.ts} +0 -0
- /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"}
|
package/dist/tenancy.d.mts
CHANGED
|
@@ -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"}
|
package/dist/zod-table.d.mts
CHANGED
|
@@ -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:
|
|
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:
|
|
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
|
package/dist/zod-table.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{
|
|
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
|
package/dist/zod-table.mjs.map
CHANGED
|
@@ -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, {
|
|
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. |
|