cvx-kit 0.0.7 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +104 -0
- package/README.md +11 -10
- package/ROADMAP.md +126 -0
- package/dist/agent-tools.d.mts +45 -0
- package/dist/agent-tools.mjs +2 -0
- package/dist/agent-tools.mjs.map +1 -0
- package/dist/auth.d.mts +9 -1
- package/dist/auth.mjs +1 -1
- package/dist/auth.mjs.map +1 -1
- package/dist/components/foundation/client.d.mts +44 -3
- package/dist/components/foundation/client.mjs +1 -1
- package/dist/components/foundation/client.mjs.map +1 -1
- package/dist/components/foundation/modules/command/command.d.mts +2 -1
- package/dist/components/foundation/modules/command/command.mjs +1 -1
- package/dist/components/foundation/modules/command/command.mjs.map +1 -1
- package/dist/components/foundation/modules/query/query.d.mts +29 -2
- package/dist/components/foundation/modules/query/query.mjs +1 -1
- package/dist/components/foundation/modules/query/query.mjs.map +1 -1
- package/dist/crud.d.mts +79 -0
- package/dist/crud.mjs +2 -0
- package/dist/crud.mjs.map +1 -0
- package/dist/index.d.mts +9 -3
- package/dist/index.mjs +1 -1
- package/dist/middleware.d.mts +46 -0
- package/dist/middleware.mjs +2 -0
- package/dist/middleware.mjs.map +1 -0
- package/dist/state-machine.d.mts +18 -0
- package/dist/state-machine.mjs +2 -0
- package/dist/state-machine.mjs.map +1 -0
- package/dist/tenancy.d.mts +1 -1
- package/dist/webhooks.d.mts +164 -0
- package/dist/webhooks.mjs +2 -0
- package/dist/webhooks.mjs.map +1 -0
- package/dist/zod-table.d.mts +34 -3
- package/dist/zod-table.mjs +1 -1
- package/dist/zod-table.mjs.map +1 -1
- package/docs/agent-tools.md +41 -0
- package/docs/commands.md +48 -0
- package/docs/crud.md +62 -0
- package/docs/llms.md +16 -3
- package/docs/plans/2026-08-14-001-feat-v0.1.0-feature-set-plan.md +461 -0
- package/docs/upgrading.md +152 -0
- package/docs/webhooks.md +58 -0
- package/package.json +21 -1
- package/scripts/extract-changelog.mjs +29 -0
|
@@ -0,0 +1,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/commands.md
CHANGED
|
@@ -148,6 +148,54 @@ Guards deny by throwing; a denial before the handler never needs rollback
|
|
|
148
148
|
because nothing has executed. Keep guards read-only — a guard that writes is
|
|
149
149
|
a handler in disguise.
|
|
150
150
|
|
|
151
|
+
### Middleware — composable, next()-based
|
|
152
|
+
|
|
153
|
+
For wrap-around concerns (timing, tracing, context enrichment, replay
|
|
154
|
+
wrappers) that two disconnected callbacks can't express, both kernels take
|
|
155
|
+
Express/TanStack-style middleware. The chain runs **inside** the pipeline's
|
|
156
|
+
invariants: after the permission check, around [guards → handler], before the
|
|
157
|
+
result-schema re-parse, aggregate allowlist, and audit — so middleware can
|
|
158
|
+
never skip authorization, return an invalid result, or desynchronize audit
|
|
159
|
+
from effects.
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
const timing = Command.middleware(async ({ operation, next }) => {
|
|
163
|
+
const started = Date.now()
|
|
164
|
+
const result = await next() // inner middleware → guards → handler
|
|
165
|
+
observability.observe // (or your own sink)
|
|
166
|
+
console.info(`${operation} took ${Date.now() - started}ms`)
|
|
167
|
+
return result
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
const withVendor = Command.middleware(async ({ context, next }) =>
|
|
171
|
+
next({ context: { vendor: await loadVendor(context) } })) // enriches downstream ctx
|
|
172
|
+
|
|
173
|
+
const commands = new Command<Ctx, typeof operations>(operations, {
|
|
174
|
+
guard: (ctx) => assertNotReadonlyWindow(ctx),
|
|
175
|
+
middleware: [timing], // registry-wide, outermost
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
'<entities>.publish': Command.operation({
|
|
179
|
+
...,
|
|
180
|
+
middleware: [withVendor], // per-operation, inside registry chain
|
|
181
|
+
guard: (ctx, command) => { /* ctx.vendor available here */ },
|
|
182
|
+
})
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Order: registry middleware (array order) → operation middleware → default
|
|
186
|
+
guard → operation guard → handler. `next()` returns the downstream result;
|
|
187
|
+
`next({ context })` merges enrichment into the context handed to inner
|
|
188
|
+
middleware, guards, and the handler. Skipping `next()` short-circuits
|
|
189
|
+
(guards and handler never run — the returned value still must satisfy the
|
|
190
|
+
result schema); calling it twice throws `COMMAND_MIDDLEWARE_NEXT_REUSED`.
|
|
191
|
+
Whatever leaves the chain is re-parsed through the operation's strict result
|
|
192
|
+
schema — a middleware cannot fabricate an invalid result.
|
|
193
|
+
|
|
194
|
+
The Query kernel takes the same shape: `new Query({ defaults, middleware,
|
|
195
|
+
execute })` plus per-executor `middleware: [...]`, with `Query.middleware`
|
|
196
|
+
as the typing helper. Kernel middleware runs before executor middleware,
|
|
197
|
+
inside the host's injected `execute` policy.
|
|
198
|
+
|
|
151
199
|
If any step throws, the Convex transaction rolls back — handler writes and
|
|
152
200
|
audit entry together. Audit and effects can never disagree.
|
|
153
201
|
|
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
|
|
@@ -39,8 +41,14 @@ function constructors, a trigger registry, an audited command protocol
|
|
|
39
41
|
transaction. Optional per-operation `permission` (checked via the
|
|
40
42
|
Foundation's injected `checkPermission`) and `guard(ctx, command)`
|
|
41
43
|
preconditions run BEFORE the handler, plus a registry-wide default guard
|
|
42
|
-
(`new Command(operations, { guard })`).
|
|
43
|
-
|
|
44
|
+
(`new Command(operations, { guard })`). Composable middleware
|
|
45
|
+
(`Command.middleware(async ({ context, next }) => ...)`) wraps
|
|
46
|
+
[guards → handler] via registry `middleware: [...]` and per-operation
|
|
47
|
+
`middleware: [...]` — `next({ context })` enriches downstream ctx, and
|
|
48
|
+
the chain's output is re-parsed by the result schema. Query takes the
|
|
49
|
+
same shape (`Query.middleware`, kernel + per-executor arrays). Order:
|
|
50
|
+
permission → registry middleware → operation middleware → default guard
|
|
51
|
+
→ operation guard → handler → result parse → aggregates → audit.
|
|
44
52
|
6. **Never write timestamps by hand.** `createdAt`/`updatedAt` are maintained
|
|
45
53
|
by the `timestamps` trigger; `archivedAt` is the app-controlled soft-delete
|
|
46
54
|
marker.
|
|
@@ -64,9 +72,14 @@ function constructors, a trigger registry, an audited command protocol
|
|
|
64
72
|
| Import | Provides |
|
|
65
73
|
|---|---|
|
|
66
74
|
| `cvx-kit` | everything below re-exported (except components' defaults) |
|
|
67
|
-
| `cvx-kit/zod-table` | `zodTable`, `tenantTable`, `createModule`, `zodVariantTable`, `jsonSafeZid`, `TIMESTAMP_FIELDS` |
|
|
75
|
+
| `cvx-kit/zod-table` | `zodTable`, `tenantTable`, `createModule`, `paginated`, `zodVariantTable`, `jsonSafeZid`, `TIMESTAMP_FIELDS` |
|
|
68
76
|
| `cvx-kit/auth` | `createAuthFunctions` (incl. optional `security` RLS config), `createInclude`, `defaultRoleMap` |
|
|
69
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 |
|
|
70
83
|
| `cvx-kit/triggers` | `createTriggers`, `timestamps`, `appendOnly`, `noDelete`, `tenantOwnership`, `Triggers` |
|
|
71
84
|
| `cvx-kit/errors` | `KitError`, `defaultErrors`, `ErrorFactory` |
|
|
72
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. |
|