cvx-kit 0.0.1 → 0.0.3

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.
@@ -1 +1 @@
1
- {"version":3,"file":"triggers.mjs","names":[],"sources":["../src/triggers.ts"],"sourcesContent":["import {\n\tTriggers,\n\ttype Change,\n\ttype Trigger,\n} from 'convex-helpers/server/triggers'\nimport type {\n\tGenericDataModel,\n\tTableNamesInDataModel,\n} from 'convex/server'\n\nexport { Triggers }\nexport type { Change, Trigger }\n\n/**\n * One application-wide mutation interceptor registry. Pass it to\n * createAuthFunctions({ triggers }) so every authMutation/roleMutation/\n * systemMutation runs writes through it — trigger enforcement becomes\n * structural, not a convention.\n */\nexport function createTriggers<\n\tDataModel extends GenericDataModel,\n>(): Triggers<DataModel> {\n\treturn new Triggers<DataModel>()\n}\n\n/**\n * Marks tables as append-only evidence tables: inserts pass, every update or\n * delete throws. The ontology pattern for votes/history/audit-style tables.\n */\nexport function appendOnly<DataModel extends GenericDataModel>(\n\ttriggers: Triggers<DataModel>,\n\t...tables: readonly TableNamesInDataModel<DataModel>[]\n) {\n\tfor (const table of tables) {\n\t\ttriggers.register(table, async (_ctx, change) => {\n\t\t\tif (change.operation !== 'insert') {\n\t\t\t\tthrow new Error(`${table} is append-only`)\n\t\t\t}\n\t\t})\n\t}\n}\n\n/** Forbids hard deletes on the given tables (soft-delete discipline). */\nexport function noDelete<DataModel extends GenericDataModel>(\n\ttriggers: Triggers<DataModel>,\n\t...tables: readonly TableNamesInDataModel<DataModel>[]\n) {\n\tfor (const table of tables) {\n\t\ttriggers.register(table, async (_ctx, change) => {\n\t\t\tif (change.operation === 'delete') {\n\t\t\t\tthrow new Error(`${table} rows cannot be deleted`)\n\t\t\t}\n\t\t})\n\t}\n}\n\n/**\n * Maintains zod-table's opinionated lifecycle timestamps: createdAt and\n * updatedAt on insert, updatedAt on every update. archivedAt stays under\n * application control. Register once per table; writes made through the\n * auth constructors keep these fields correct automatically.\n */\nexport function timestamps<DataModel extends GenericDataModel>(\n\ttriggers: Triggers<DataModel>,\n\t...tables: readonly TableNamesInDataModel<DataModel>[]\n) {\n\tfor (const table of tables) {\n\t\ttriggers.register(table, async (ctx, change) => {\n\t\t\tif (change.operation === 'delete') return\n\t\t\tconst now = Date.now()\n\t\t\tif (change.operation === 'insert') {\n\t\t\t\tconst document = change.newDoc as { createdAt?: number }\n\t\t\t\tif (document.createdAt === undefined) {\n\t\t\t\t\tawait ctx.innerDb.patch(change.id, {\n\t\t\t\t\t\tcreatedAt: now,\n\t\t\t\t\t\tupdatedAt: now,\n\t\t\t\t\t} as never)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\t\tconst previous = change.oldDoc as { updatedAt?: number }\n\t\t\tconst current = change.newDoc as { updatedAt?: number }\n\t\t\t// A write that already moved updatedAt (including our own patch)\n\t\t\t// is left alone — this is the recursion guard.\n\t\t\tif (current.updatedAt === previous.updatedAt) {\n\t\t\t\tawait ctx.innerDb.patch(change.id, { updatedAt: now } as never)\n\t\t\t}\n\t\t})\n\t}\n}\n"],"mappings":"0DAmBA,SAAgB,GAES,CACxB,OAAO,IAAI,CACZ,CAMA,SAAgB,EACf,EACA,GAAG,EACF,CACD,IAAK,IAAM,KAAS,EACnB,EAAS,SAAS,EAAO,MAAO,EAAM,IAAW,CAChD,GAAI,EAAO,YAAc,SACxB,MAAU,MAAM,GAAG,EAAM,gBAAgB,CAE3C,CAAC,CAEH,CAGA,SAAgB,EACf,EACA,GAAG,EACF,CACD,IAAK,IAAM,KAAS,EACnB,EAAS,SAAS,EAAO,MAAO,EAAM,IAAW,CAChD,GAAI,EAAO,YAAc,SACxB,MAAU,MAAM,GAAG,EAAM,wBAAwB,CAEnD,CAAC,CAEH,CAQA,SAAgB,EACf,EACA,GAAG,EACF,CACD,IAAK,IAAM,KAAS,EACnB,EAAS,SAAS,EAAO,MAAO,EAAK,IAAW,CAC/C,GAAI,EAAO,YAAc,SAAU,OACnC,IAAM,EAAM,KAAK,IAAI,EACrB,GAAI,EAAO,YAAc,SAAU,CACjB,EAAO,OACX,YAAc,IAAA,IAC1B,MAAM,EAAI,QAAQ,MAAM,EAAO,GAAI,CAClC,UAAW,EACX,UAAW,CACZ,CAAU,EAEX,MACD,CACA,IAAM,EAAW,EAAO,OACR,EAAO,OAGX,YAAc,EAAS,WAClC,MAAM,EAAI,QAAQ,MAAM,EAAO,GAAI,CAAE,UAAW,CAAI,CAAU,CAEhE,CAAC,CAEH"}
1
+ {"version":3,"file":"triggers.mjs","names":[],"sources":["../src/triggers.ts"],"sourcesContent":["import {\n\tTriggers,\n\ttype Change,\n\ttype Trigger,\n} from 'convex-helpers/server/triggers'\nimport type {\n\tGenericDataModel,\n\tTableNamesInDataModel,\n} from 'convex/server'\n\nexport { Triggers }\nexport type { Change, Trigger }\n\n/**\n * One application-wide mutation interceptor registry. Pass it to\n * createAuthFunctions({ triggers }) so every authMutation/roleMutation/\n * systemMutation runs writes through it — trigger enforcement becomes\n * structural, not a convention.\n */\nexport function createTriggers<\n\tDataModel extends GenericDataModel,\n>(): Triggers<DataModel> {\n\treturn new Triggers<DataModel>()\n}\n\n/**\n * Marks tables as append-only evidence tables: inserts pass, every update or\n * delete throws. The ontology pattern for votes/history/audit-style tables.\n */\nexport function appendOnly<DataModel extends GenericDataModel>(\n\ttriggers: Triggers<DataModel>,\n\t...tables: readonly TableNamesInDataModel<DataModel>[]\n) {\n\tfor (const table of tables) {\n\t\ttriggers.register(table, async (_ctx, change) => {\n\t\t\tif (change.operation !== 'insert') {\n\t\t\t\tthrow new Error(`${table} is append-only`)\n\t\t\t}\n\t\t})\n\t}\n}\n\n/** Forbids hard deletes on the given tables (soft-delete discipline). */\nexport function noDelete<DataModel extends GenericDataModel>(\n\ttriggers: Triggers<DataModel>,\n\t...tables: readonly TableNamesInDataModel<DataModel>[]\n) {\n\tfor (const table of tables) {\n\t\ttriggers.register(table, async (_ctx, change) => {\n\t\t\tif (change.operation === 'delete') {\n\t\t\t\tthrow new Error(`${table} rows cannot be deleted`)\n\t\t\t}\n\t\t})\n\t}\n}\n\n/**\n * Post-image tenant invariant for tenantTable rows. Row-level security\n * authorizes a modification against the PRE-image, so ownership must also be\n * enforced against the post-image: inserts must carry a tenant, and updates\n * may never reassign it. Register for every tenant table (same registry that\n * drives createTenantRules).\n */\nexport function tenantOwnership<DataModel extends GenericDataModel>(\n\ttriggers: Triggers<DataModel>,\n\t...tables: readonly TableNamesInDataModel<DataModel>[]\n) {\n\tfor (const table of tables) {\n\t\ttriggers.register(table, async (_ctx, change) => {\n\t\t\tif (change.operation === 'insert') {\n\t\t\t\tconst document = change.newDoc as { tenant?: unknown }\n\t\t\t\tif (\n\t\t\t\t\ttypeof document.tenant !== 'string' ||\n\t\t\t\t\tdocument.tenant.length === 0\n\t\t\t\t) {\n\t\t\t\t\tthrow new Error(`${table} rows require a tenant`)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif (change.operation === 'update') {\n\t\t\t\tconst previous = change.oldDoc as { tenant?: unknown }\n\t\t\t\tconst current = change.newDoc as { tenant?: unknown }\n\t\t\t\tif (previous.tenant !== current.tenant) {\n\t\t\t\t\tthrow new Error(`${table} tenant ownership cannot be reassigned`)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\n/**\n * Maintains zod-table's opinionated lifecycle timestamps: createdAt and\n * updatedAt on insert, updatedAt on every update. archivedAt stays under\n * application control. Register once per table; writes made through the\n * auth constructors keep these fields correct automatically.\n */\nexport function timestamps<DataModel extends GenericDataModel>(\n\ttriggers: Triggers<DataModel>,\n\t...tables: readonly TableNamesInDataModel<DataModel>[]\n) {\n\tfor (const table of tables) {\n\t\ttriggers.register(table, async (ctx, change) => {\n\t\t\tif (change.operation === 'delete') return\n\t\t\tconst now = Date.now()\n\t\t\tif (change.operation === 'insert') {\n\t\t\t\tconst document = change.newDoc as { createdAt?: number }\n\t\t\t\tif (document.createdAt === undefined) {\n\t\t\t\t\tawait ctx.innerDb.patch(change.id, {\n\t\t\t\t\t\tcreatedAt: now,\n\t\t\t\t\t\tupdatedAt: now,\n\t\t\t\t\t} as never)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\t\tconst previous = change.oldDoc as { updatedAt?: number }\n\t\t\tconst current = change.newDoc as { updatedAt?: number }\n\t\t\t// A write that already moved updatedAt (including our own patch)\n\t\t\t// is left alone — this is the recursion guard.\n\t\t\tif (current.updatedAt === previous.updatedAt) {\n\t\t\t\tawait ctx.innerDb.patch(change.id, { updatedAt: now } as never)\n\t\t\t}\n\t\t})\n\t}\n}\n"],"mappings":"0DAmBA,SAAgB,GAES,CACxB,OAAO,IAAI,CACZ,CAMA,SAAgB,EACf,EACA,GAAG,EACF,CACD,IAAK,IAAM,KAAS,EACnB,EAAS,SAAS,EAAO,MAAO,EAAM,IAAW,CAChD,GAAI,EAAO,YAAc,SACxB,MAAU,MAAM,GAAG,EAAM,gBAAgB,CAE3C,CAAC,CAEH,CAGA,SAAgB,EACf,EACA,GAAG,EACF,CACD,IAAK,IAAM,KAAS,EACnB,EAAS,SAAS,EAAO,MAAO,EAAM,IAAW,CAChD,GAAI,EAAO,YAAc,SACxB,MAAU,MAAM,GAAG,EAAM,wBAAwB,CAEnD,CAAC,CAEH,CASA,SAAgB,EACf,EACA,GAAG,EACF,CACD,IAAK,IAAM,KAAS,EACnB,EAAS,SAAS,EAAO,MAAO,EAAM,IAAW,CAChD,GAAI,EAAO,YAAc,SAAU,CAClC,IAAM,EAAW,EAAO,OACxB,GACC,OAAO,EAAS,QAAW,UAC3B,EAAS,OAAO,SAAW,EAE3B,MAAU,MAAM,GAAG,EAAM,uBAAuB,EAEjD,MACD,CACA,GAAI,EAAO,YAAc,SAAU,CAClC,IAAM,EAAW,EAAO,OAClB,EAAU,EAAO,OACvB,GAAI,EAAS,SAAW,EAAQ,OAC/B,MAAU,MAAM,GAAG,EAAM,uCAAuC,CAElE,CACD,CAAC,CAEH,CAQA,SAAgB,EACf,EACA,GAAG,EACF,CACD,IAAK,IAAM,KAAS,EACnB,EAAS,SAAS,EAAO,MAAO,EAAK,IAAW,CAC/C,GAAI,EAAO,YAAc,SAAU,OACnC,IAAM,EAAM,KAAK,IAAI,EACrB,GAAI,EAAO,YAAc,SAAU,CACjB,EAAO,OACX,YAAc,IAAA,IAC1B,MAAM,EAAI,QAAQ,MAAM,EAAO,GAAI,CAClC,UAAW,EACX,UAAW,CACZ,CAAU,EAEX,MACD,CACA,IAAM,EAAW,EAAO,OACR,EAAO,OAGX,YAAc,EAAS,WAClC,MAAM,EAAI,QAAQ,MAAM,EAAO,GAAI,CAAE,UAAW,CAAI,CAAU,CAEhE,CAAC,CAEH"}
@@ -106,6 +106,26 @@ declare function zodTable<Table extends string, Fields extends Shape, const Serv
106
106
  }, z.core.$strict>;
107
107
  };
108
108
  };
109
+ declare const tenantShape: {
110
+ tenant: z.ZodString;
111
+ };
112
+ type TenantShape = typeof tenantShape;
113
+ /**
114
+ * A zodTable whose rows are tenant-owned: injects the server-owned `tenant`
115
+ * field (the tenancy boundary — see cvx-kit/tenancy). Like timestamps, it is
116
+ * excluded from insert/update/command boundaries; handlers stamp it from
117
+ * ctx.tenant, row-level security matches on it, and the tenantOwnership
118
+ * trigger forbids reassigning it. Expose it via publicFields only when a DTO
119
+ * genuinely needs it.
120
+ */
121
+ declare function tenantTable<Table extends string, Fields extends Shape, const ServerFields extends readonly ShapeKey<Fields>[] = readonly [], const CommandFields extends readonly ShapeKey<Fields>[] = readonly [], const PublicFields extends readonly ShapeKey<Fields & TenantShape & TimestampShape>[] = readonly []>(tableName: Table, fields: (id: typeof zid) => Fields, options?: TableBoundaryOptions<Fields & TenantShape, readonly (ServerFields[number] | 'tenant')[], CommandFields, PublicFields>): ReturnType<typeof zodTable<Table, Fields & TenantShape, readonly (ServerFields[number] | 'tenant')[], CommandFields, PublicFields>>;
122
+ /**
123
+ * Merges per-module table maps into the application schema map, rejecting
124
+ * duplicate table names across modules — the module-registry combinator for
125
+ * domain/table.ts: `defineSchema(createModule(catalogTables, salesTables))`.
126
+ */
127
+ declare function createModule<const Maps extends readonly Record<string, TableDefinition<any>>[]>(...maps: Maps): UnionToIntersection<Maps[number]>;
128
+ type UnionToIntersection<Union> = (Union extends unknown ? (member: Union) => void : never) extends ((member: infer Intersection) => void) ? Intersection : never;
109
129
  /** Preserves discriminated-union storage while retaining the same table owner. */
110
130
  declare function zodVariantTable<Table extends string, Schema extends z.ZodType>(tableName: Table, storage: Schema): {
111
131
  tableName: Table;
@@ -120,5 +140,5 @@ declare function zodVariantTable<Table extends string, Schema extends z.ZodType>
120
140
  declare function jsonSafeZid<Table extends string>(tableName: Table): z.ZodType<GenericId<Table>, string>;
121
141
  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;
122
142
  //#endregion
123
- export { TIMESTAMP_FIELDS, TableBoundaryOptions, jsonSafeZid, zodTable, zodVariantTable };
143
+ export { TIMESTAMP_FIELDS, TableBoundaryOptions, createModule, jsonSafeZid, tenantTable, zodTable, zodVariantTable };
124
144
  //# sourceMappingURL=zod-table.d.mts.map
@@ -1,2 +1,2 @@
1
- import{zid as e,zodToConvex as t}from"convex-helpers/server/zod4";import{defineTable as n}from"convex/server";import{z as r}from"zod";const i={createdAt:r.number().optional(),updatedAt:r.number().optional(),archivedAt:r.number().optional()},a=[`createdAt`,`updatedAt`,`archivedAt`];function o(o,s,c={}){let d={...s(e),...i},f=r.object(d).strict(),p=f.extend({_id:e(o),_creationTime:r.number()}),m=r.object(u(d,[...a,...c.serverFields??[]])).strict(),h=m.partial(),g=r.object(l(d,c.commandFields??[])).strict(),_=r.object(l(d,c.publicFields??[])).strict(),v=e=>_.parse(Object.fromEntries(Object.keys(_.shape).map(t=>[t,e[t]])));function y(e){return e?m.omit(e):m}function b(e){return e?h.omit(e):h}return{tableName:o,schema:p,storage:f,insertSchema:m,updateSchema:h,commandInput:g,publicDto:_,toPublicDto:v,table:n(t(f)),insert:y,update:b,tools:{insert:g,update:r.object({data:g.partial(),id:e(o)}).strict(),id:r.object({id:e(o)}).strict()}}}function s(e,r){return{tableName:e,storage:r,insertSchema:r,table:n(t(r))}}function c(e){return r.string().describe(`Convex document id for table "${e}"`)}function l(e,t){return Object.fromEntries(t.map(t=>[t,e[t]]))}function u(e,t){let n=new Set(t);return Object.fromEntries(Object.entries(e).filter(([e])=>!n.has(e)))}export{a as TIMESTAMP_FIELDS,c as jsonSafeZid,o as zodTable,s as zodVariantTable};
1
+ import{zid as e,zodToConvex as t}from"convex-helpers/server/zod4";import{defineTable as n}from"convex/server";import{z as r}from"zod";const i={createdAt:r.number().optional(),updatedAt:r.number().optional(),archivedAt:r.number().optional()},a=[`createdAt`,`updatedAt`,`archivedAt`];function o(o,s,c={}){let l={...s(e),...i},u=r.object(l).strict(),d=u.extend({_id:e(o),_creationTime:r.number()}),m=r.object(p(l,[...a,...c.serverFields??[]])).strict(),h=m.partial(),g=r.object(f(l,c.commandFields??[])).strict(),_=r.object(f(l,c.publicFields??[])).strict(),v=e=>_.parse(Object.fromEntries(Object.keys(_.shape).map(t=>[t,e[t]])));function y(e){return e?m.omit(e):m}function b(e){return e?h.omit(e):h}return{tableName:o,schema:d,storage:u,insertSchema:m,updateSchema:h,commandInput:g,publicDto:_,toPublicDto:v,table:n(t(u)),insert:y,update:b,tools:{insert:g,update:r.object({data:g.partial(),id:e(o)}).strict(),id:r.object({id:e(o)}).strict()}}}const s={tenant:r.string().min(1)};function c(e,t,n={}){return o(e,e=>({...t(e),...s}),{...n,serverFields:[...n.serverFields??[],`tenant`]})}function l(...e){let t={};for(let n of e)for(let[e,r]of Object.entries(n)){if(e in t)throw Error(`Table "${e}" is declared by more than one module`);t[e]=r}return t}function u(e,r){return{tableName:e,storage:r,insertSchema:r,table:n(t(r))}}function d(e){return r.string().describe(`Convex document id for table "${e}"`)}function f(e,t){return Object.fromEntries(t.map(t=>[t,e[t]]))}function p(e,t){let n=new Set(t);return Object.fromEntries(Object.entries(e).filter(([e])=>!n.has(e)))}export{a as TIMESTAMP_FIELDS,l as createModule,d as jsonSafeZid,c as tenantTable,o as zodTable,u as zodVariantTable};
2
2
  //# sourceMappingURL=zod-table.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"zod-table.mjs","names":[],"sources":["../src/zod-table.ts"],"sourcesContent":["import {\n\ttype ConvexValidatorFromZod,\n\tzid,\n\tzodToConvex,\n} from 'convex-helpers/server/zod4'\nimport { defineTable, type TableDefinition } from 'convex/server'\nimport type { GenericId } from 'convex/values'\nimport { z } from 'zod'\n\ntype Shape = Record<string, z.ZodType>\ntype ShapeKey<Fields extends Shape> = Extract<keyof Fields, string>\n\n/**\n * Opinionated lifecycle timestamps — every table has them, server-owned,\n * always excluded from insert/command boundaries. Maintained by the\n * `timestamps` trigger helper (see cvx-kit/triggers).\n */\nconst timestampShape = {\n\tcreatedAt: z.number().optional(),\n\tupdatedAt: z.number().optional(),\n\tarchivedAt: z.number().optional(),\n} satisfies Shape\n\ntype TimestampShape = typeof timestampShape\n\nexport const TIMESTAMP_FIELDS = [\n\t'createdAt',\n\t'updatedAt',\n\t'archivedAt',\n] as const\n\nexport type TableBoundaryOptions<\n\tFields extends Shape,\n\tServerFields extends readonly ShapeKey<Fields>[],\n\tCommandFields extends readonly ShapeKey<Fields>[],\n\tPublicFields extends readonly ShapeKey<Fields & TimestampShape>[],\n> = {\n\tserverFields?: ServerFields\n\tcommandFields?: CommandFields\n\tpublicFields?: PublicFields\n}\n\n/** Defines one table and derives its storage, document, write, and DTO boundaries. */\nexport function zodTable<\n\tTable extends string,\n\tFields extends Shape,\n\tconst ServerFields extends readonly ShapeKey<Fields>[] = readonly [],\n\tconst CommandFields extends readonly ShapeKey<Fields>[] = readonly [],\n\tconst PublicFields extends readonly ShapeKey<\n\t\tFields & TimestampShape\n\t>[] = readonly [],\n>(\n\ttableName: Table,\n\tfields: (id: typeof zid) => Fields,\n\toptions: TableBoundaryOptions<\n\t\tFields,\n\t\tServerFields,\n\t\tCommandFields,\n\t\tPublicFields\n\t> = {},\n) {\n\tconst shape = { ...fields(zid), ...timestampShape } as Fields &\n\t\tTimestampShape\n\tconst storage = z.object(shape).strict()\n\tconst schema = storage.extend({\n\t\t_id: zid(tableName),\n\t\t_creationTime: z.number(),\n\t})\n\tconst insertSchema = z\n\t\t.object(\n\t\t\tomitShape(shape, [\n\t\t\t\t...TIMESTAMP_FIELDS,\n\t\t\t\t...(options.serverFields ?? ([] as never)),\n\t\t\t] as never),\n\t\t)\n\t\t.strict() as z.ZodObject<\n\t\tOmit<Fields, ServerFields[number] | (typeof TIMESTAMP_FIELDS)[number]>\n\t>\n\tconst updateSchema = insertSchema.partial()\n\tconst commandInput = z\n\t\t.object(pickShape(shape, options.commandFields ?? ([] as never)))\n\t\t.strict()\n\tconst publicDto = z\n\t\t.object(pickShape(shape, options.publicFields ?? ([] as never)))\n\t\t.strict()\n\tconst toPublicDto = (row: z.input<typeof storage>) =>\n\t\tpublicDto.parse(\n\t\t\tObject.fromEntries(\n\t\t\t\tObject.keys(publicDto.shape).map((key) => [\n\t\t\t\t\tkey,\n\t\t\t\t\t(row as Record<string, unknown>)[key],\n\t\t\t\t]),\n\t\t\t),\n\t\t)\n\n\ttype InsertSchema = typeof insertSchema\n\ttype UpdateSchema = typeof updateSchema\n\ttype InsertMask = z.util.Mask<keyof InsertSchema['shape']>\n\ttype UpdateMask = z.util.Mask<keyof UpdateSchema['shape']>\n\n\tfunction insert(): InsertSchema\n\tfunction insert<OmitMask extends InsertMask>(\n\t\tomit: OmitMask,\n\t): ZodObjectOmit<InsertSchema, OmitMask>\n\tfunction insert(omit?: InsertMask) {\n\t\treturn omit ? insertSchema.omit(omit as never) : insertSchema\n\t}\n\n\tfunction update(): UpdateSchema\n\tfunction update<OmitMask extends UpdateMask>(\n\t\tomit: OmitMask,\n\t): ZodObjectOmit<UpdateSchema, OmitMask>\n\tfunction update(omit?: UpdateMask) {\n\t\treturn omit ? updateSchema.omit(omit as never) : updateSchema\n\t}\n\n\treturn {\n\t\ttableName,\n\t\tschema,\n\t\tstorage,\n\t\tinsertSchema,\n\t\tupdateSchema,\n\t\tcommandInput,\n\t\tpublicDto,\n\t\ttoPublicDto,\n\t\ttable: defineTable(zodToConvex(storage)) as TableDefinition<\n\t\t\tConvexValidatorFromZod<typeof storage, 'required'>\n\t\t>,\n\t\tinsert,\n\t\tupdate,\n\t\ttools: {\n\t\t\tinsert: commandInput,\n\t\t\tupdate: z\n\t\t\t\t.object({ data: commandInput.partial(), id: zid(tableName) })\n\t\t\t\t.strict(),\n\t\t\tid: z.object({ id: zid(tableName) }).strict(),\n\t\t},\n\t}\n}\n\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":"sIAiBA,MAAM,EAAiB,CACtB,UAAW,EAAE,OAAO,CAAC,CAAC,SAAS,EAC/B,UAAW,EAAE,OAAO,CAAC,CAAC,SAAS,EAC/B,WAAY,EAAE,OAAO,CAAC,CAAC,SAAS,CACjC,EAIa,EAAmB,CAC/B,YACA,YACA,YACD,EAcA,SAAgB,EASf,EACA,EACA,EAKI,CAAC,EACJ,CACD,IAAM,EAAQ,CAAE,GAAG,EAAO,CAAG,EAAG,GAAG,CAAe,EAE5C,EAAU,EAAE,OAAO,CAAK,CAAC,CAAC,OAAO,EACjC,EAAS,EAAQ,OAAO,CAC7B,IAAK,EAAI,CAAS,EAClB,cAAe,EAAE,OAAO,CACzB,CAAC,EACK,EAAe,EACnB,OACA,EAAU,EAAO,CAChB,GAAG,EACH,GAAI,EAAQ,cAAiB,CAAC,CAC/B,CAAU,CACX,CAAC,CACA,OAAO,EAGH,EAAe,EAAa,QAAQ,EACpC,EAAe,EACnB,OAAO,EAAU,EAAO,EAAQ,eAAkB,CAAC,CAAW,CAAC,CAAC,CAChE,OAAO,EACH,EAAY,EAChB,OAAO,EAAU,EAAO,EAAQ,cAAiB,CAAC,CAAW,CAAC,CAAC,CAC/D,OAAO,EACH,EAAe,GACpB,EAAU,MACT,OAAO,YACN,OAAO,KAAK,EAAU,KAAK,CAAC,CAAC,IAAK,GAAQ,CACzC,EACC,EAAgC,EAClC,CAAC,CACF,CACD,EAWD,SAAS,EAAO,EAAmB,CAClC,OAAO,EAAO,EAAa,KAAK,CAAa,EAAI,CAClD,CAMA,SAAS,EAAO,EAAmB,CAClC,OAAO,EAAO,EAAa,KAAK,CAAa,EAAI,CAClD,CAEA,MAAO,CACN,YACA,SACA,UACA,eACA,eACA,eACA,YACA,cACA,MAAO,EAAY,EAAY,CAAO,CAAC,EAGvC,SACA,SACA,MAAO,CACN,OAAQ,EACR,OAAQ,EACN,OAAO,CAAE,KAAM,EAAa,QAAQ,EAAG,GAAI,EAAI,CAAS,CAAE,CAAC,CAAC,CAC5D,OAAO,EACT,GAAI,EAAE,OAAO,CAAE,GAAI,EAAI,CAAS,CAAE,CAAC,CAAC,CAAC,OAAO,CAC7C,CACD,CACD,CAGA,SAAgB,EACf,EACA,EACC,CACD,MAAO,CACN,YACA,UACA,aAAc,EACd,MAAO,EAAY,EAAY,CAAO,CAAC,CACxC,CACD,CAMA,SAAgB,EAAkC,EAAkB,CACnE,OAAO,EACL,OAAO,CAAC,CACR,SACA,iCAAiC,EAAU,EAC5C,CACF,CAeA,SAAS,EAGP,EAAgB,EAAY,CAC7B,OAAO,OAAO,YAAY,EAAK,IAAK,GAAQ,CAAC,EAAK,EAAO,EAAI,CAAC,CAAC,CAIhE,CAEA,SAAS,EAGP,EAAgB,EAAY,CAC7B,IAAM,EAAU,IAAI,IAAY,CAAI,EACpC,OAAO,OAAO,YACb,OAAO,QAAQ,CAAM,CAAC,CAAC,QAAQ,CAAC,KAAS,CAAC,EAAQ,IAAI,CAAG,CAAC,CAC3D,CACD"}
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":"sIAiBA,MAAM,EAAiB,CACtB,UAAW,EAAE,OAAO,CAAC,CAAC,SAAS,EAC/B,UAAW,EAAE,OAAO,CAAC,CAAC,SAAS,EAC/B,WAAY,EAAE,OAAO,CAAC,CAAC,SAAS,CACjC,EAIa,EAAmB,CAC/B,YACA,YACA,YACD,EAcA,SAAgB,EASf,EACA,EACA,EAKI,CAAC,EACJ,CACD,IAAM,EAAQ,CAAE,GAAG,EAAO,CAAG,EAAG,GAAG,CAAe,EAE5C,EAAU,EAAE,OAAO,CAAK,CAAC,CAAC,OAAO,EACjC,EAAS,EAAQ,OAAO,CAC7B,IAAK,EAAI,CAAS,EAClB,cAAe,EAAE,OAAO,CACzB,CAAC,EACK,EAAe,EACnB,OACA,EAAU,EAAO,CAChB,GAAG,EACH,GAAI,EAAQ,cAAiB,CAAC,CAC/B,CAAU,CACX,CAAC,CACA,OAAO,EAGH,EAAe,EAAa,QAAQ,EACpC,EAAe,EACnB,OAAO,EAAU,EAAO,EAAQ,eAAkB,CAAC,CAAW,CAAC,CAAC,CAChE,OAAO,EACH,EAAY,EAChB,OAAO,EAAU,EAAO,EAAQ,cAAiB,CAAC,CAAW,CAAC,CAAC,CAC/D,OAAO,EACH,EAAe,GACpB,EAAU,MACT,OAAO,YACN,OAAO,KAAK,EAAU,KAAK,CAAC,CAAC,IAAK,GAAQ,CACzC,EACC,EAAgC,EAClC,CAAC,CACF,CACD,EAWD,SAAS,EAAO,EAAmB,CAClC,OAAO,EAAO,EAAa,KAAK,CAAa,EAAI,CAClD,CAMA,SAAS,EAAO,EAAmB,CAClC,OAAO,EAAO,EAAa,KAAK,CAAa,EAAI,CAClD,CAEA,MAAO,CACN,YACA,SACA,UACA,eACA,eACA,eACA,YACA,cACA,MAAO,EAAY,EAAY,CAAO,CAAC,EAGvC,SACA,SACA,MAAO,CACN,OAAQ,EACR,OAAQ,EACN,OAAO,CAAE,KAAM,EAAa,QAAQ,EAAG,GAAI,EAAI,CAAS,CAAE,CAAC,CAAC,CAC5D,OAAO,EACT,GAAI,EAAE,OAAO,CAAE,GAAI,EAAI,CAAS,CAAE,CAAC,CAAC,CAAC,OAAO,CAC7C,CACD,CACD,CAEA,MAAM,EAAc,CACnB,OAAQ,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,CACzB,EAYA,SAAgB,EASf,EACA,EACA,EAKI,CAAC,EACJ,CACD,OAAO,EACN,EACC,IAAQ,CAAE,GAAG,EAAO,CAAE,EAAG,GAAG,CAAY,GACzC,CACC,GAAG,EACH,aAAc,CACb,GAAK,EAAQ,cAAgB,CAAC,EAI9B,QACD,CACD,CACD,CASD,CAOA,SAAgB,EAEd,GAAG,EAA+C,CACnD,IAAM,EAAiD,CAAC,EACxD,IAAK,IAAM,KAAO,EACjB,IAAK,GAAM,CAAC,EAAW,KAAe,OAAO,QAAQ,CAAG,EAAG,CAC1D,GAAI,KAAa,EAChB,MAAU,MACT,UAAU,EAAU,sCACrB,EAED,EAAS,GAAa,CACvB,CAED,OAAO,CACR,CAWA,SAAgB,EACf,EACA,EACC,CACD,MAAO,CACN,YACA,UACA,aAAc,EACd,MAAO,EAAY,EAAY,CAAO,CAAC,CACxC,CACD,CAMA,SAAgB,EAAkC,EAAkB,CACnE,OAAO,EACL,OAAO,CAAC,CACR,SACA,iCAAiC,EAAU,EAC5C,CACF,CAeA,SAAS,EAGP,EAAgB,EAAY,CAC7B,OAAO,OAAO,YAAY,EAAK,IAAK,GAAQ,CAAC,EAAK,EAAO,EAAI,CAAC,CAAC,CAIhE,CAEA,SAAS,EAGP,EAAgB,EAAY,CAC7B,IAAM,EAAU,IAAI,IAAY,CAAI,EACpC,OAAO,OAAO,YACb,OAAO,QAAQ,CAAM,CAAC,CAAC,QAAQ,CAAC,KAAS,CAAC,EAAQ,IAAI,CAAG,CAAC,CAC3D,CACD"}
@@ -0,0 +1,152 @@
1
+ # `cvx-kit/components/approvals` — declarative approval workflows
2
+
3
+ A self-contained Convex component for human-in-the-loop approval flows:
4
+ declarative step lists (decision / branch / mutation / action / notify) with
5
+ count quorum, maker-checker, expiry, compatibility keys, durable execution via
6
+ a **nested** `@convex-dev/workflow`, and its own audit trail via a **nested**
7
+ `convex-audit-log`. The component never imports host code: tenancy is an
8
+ opaque `scopeRef`, resources are opaque `resourceType`/`resourceRef` strings,
9
+ and callbacks cross the boundary as **function handles**.
10
+
11
+ ## Mounting
12
+
13
+ ```ts
14
+ // convex/convex.config.ts
15
+ import approvals from 'cvx-kit/components/approvals'
16
+ app.use(approvals)
17
+ ```
18
+
19
+ The component mounts its own private `workflow` and `auditLog` children. Do
20
+ **not** re-register those children at the app root under the same identity,
21
+ and never reach into `components.approvals.workflow` / `.auditLog` from host
22
+ code — the `Approvals` client class is the only contract.
23
+
24
+ ```ts
25
+ // convex/approvals.ts — host facade, declared once
26
+ import { Approvals } from 'cvx-kit/components/approvals'
27
+ import { components } from './_generated/api'
28
+
29
+ export const approvals = new Approvals(components.approvals)
30
+ ```
31
+
32
+ ## Defining a workflow — in the owning domain
33
+
34
+ Workflow *definitions* are business logic and live in the domain that owns the
35
+ resource, not in the root facade:
36
+
37
+ ```ts
38
+ // convex/domain/documents/approval.ts
39
+ import { approvals } from '../../approvals'
40
+ import { internal } from '../../_generated/api'
41
+
42
+ export const publishApproval = approvals.define({
43
+ name: 'documentPublish',
44
+ steps: [
45
+ approvals.decision('managerDecision', {
46
+ decisions: ['approved', 'rejected'],
47
+ quorum: { kind: 'count', approvals: 1 },
48
+ makerChecker: true, // requester cannot decide
49
+ expiresAfterMs: 7 * 24 * 60 * 60 * 1000,
50
+ }),
51
+ approvals.branch('applyDecision', {
52
+ approvedStepKey: 'publishDocument',
53
+ rejectedStepKey: 'notifyRejected',
54
+ }),
55
+ approvals.mutation('publishDocument', {
56
+ handler: internal.domain.documents.approval_functions.applyDecision,
57
+ }),
58
+ approvals.notify('notifyRejected', {
59
+ handler: internal.domain.documents.approval_functions.notifyRejected,
60
+ retry: true,
61
+ }),
62
+ ],
63
+ })
64
+ ```
65
+
66
+ ### Step kinds
67
+
68
+ | Step | Runs | Notes |
69
+ |---|---|---|
70
+ | `decision(key, { decisions, quorum, makerChecker?, expiresAfterMs? })` | waits for humans | quorum is `{ kind: 'count', approvals: n }`; expiry resolves the run as `expired` |
71
+ | `branch(key, { approvedStepKey, rejectedStepKey })` | routing | jumps by the preceding decision's outcome |
72
+ | `mutation(key, { handler, retry? })` | internal mutation handle | transactional apply step |
73
+ | `action(key, { handler, retry? })` | internal action handle | side effects (third parties) |
74
+ | `notify(key, { handler, retry? })` | internal action handle | semantically a notification; same contract as action |
75
+
76
+ Limits (enforced by the component's validators): ≤ 32 steps per workflow,
77
+ ≤ 8 decisions per decision step, names ≤ 96 chars, references ≤ 256 chars,
78
+ metadata ≤ 32 entries (keys ≤ 64, values ≤ 512 chars).
79
+
80
+ ### Compatibility keys
81
+
82
+ `compatibilityKey` (defaults to `name`) versions the workflow shape. Every
83
+ `decide`/`cancel`/`status`/`restart` call sends it, so a run started under an
84
+ old definition cannot be driven by an incompatible new one. When you change a
85
+ workflow's steps in a breaking way, change the key.
86
+
87
+ ## Driving a run
88
+
89
+ ```ts
90
+ // start — from an authMutation, with ctx.actor as the requester
91
+ const { runId } = await publishApproval.start(ctx, {
92
+ scopeRef: ctx.org.organizationId, // opaque tenancy
93
+ resourceType: 'document',
94
+ resourceRef: documentId, // opaque to the component
95
+ requester: ctx.actor,
96
+ metadata: { title }, // bounded string map
97
+ })
98
+
99
+ // decide — a manager approves or rejects
100
+ await publishApproval.decide(ctx, { runId, decision: 'approved', reason }, ctx.actor)
101
+
102
+ // observe
103
+ await publishApproval.status(ctx, runId) // pending | approved | rejected | expired | canceled
104
+ await publishApproval.evidence(ctx, runId) // per-step decision evidence
105
+ await publishApproval.list(ctx, { scopeRef, ... })
106
+
107
+ // manage
108
+ await publishApproval.cancel(ctx, runId, ctx.actor)
109
+ await publishApproval.restart(ctx, runId)
110
+ await approvals.cleanupAudit(ctx, { olderThanDays: 365, batchSize: 100 })
111
+ ```
112
+
113
+ ## Callback handlers — validate everything coming back
114
+
115
+ Step handlers are **internal** functions (build them with `systemMutation` /
116
+ `systemAction`). They receive `ApprovalCallbackInput`: `{ runId, scopeRef,
117
+ resourceType, resourceRef, metadata?, decision? }` — all opaque strings.
118
+ Because the component cannot know your tables, the handler must re-establish
119
+ trust:
120
+
121
+ ```ts
122
+ export const applyDecision = systemMutation({
123
+ args: approvalCallbackArgs,
124
+ handler: async (ctx, input) => {
125
+ if (input.resourceType !== 'document') throw new Error('WRONG_RESOURCE')
126
+ const id = ctx.db.normalizeId('documents', input.resourceRef)
127
+ if (!id) throw new Error('BAD_REF')
128
+ const document = await ctx.db.get(id)
129
+ // guard against stale runs: the doc must still point at this run
130
+ if (document?.approvalRunId !== input.runId) return
131
+ await ctx.db.patch(id, { reviewState: 'published' })
132
+ },
133
+ })
134
+ ```
135
+
136
+ That `approvalRunId` check is the idempotency/staleness guard from the origin
137
+ app — copy it.
138
+
139
+ ## Rules and footguns
140
+
141
+ 1. Root facade (`convex/approvals.ts`) instantiates the client; workflow
142
+ *definitions* live in the owning domain.
143
+ 2. Handlers get opaque strings — always `normalizeId`, re-check
144
+ `resourceType`, and verify the resource still references the run.
145
+ 3. `makerChecker: true` means the requester's decisions are rejected — design
146
+ your UI accordingly.
147
+ 4. The component keeps its own audit trail (retention category
148
+ `approval-protocol`, cleanup via `cleanupAudit`, retention 1–3650 days,
149
+ batch ≤ 100). Host-side business audit still goes through your command
150
+ protocol — they are different trails.
151
+ 5. Changing steps without changing `compatibilityKey` lets old runs be driven
152
+ by a new incompatible shape; bump the key on breaking changes.
@@ -0,0 +1,124 @@
1
+ # Architecture — the shape of a cvx-kit application
2
+
3
+ cvx-kit was extracted from a production Convex application (~12k LOC backend).
4
+ This document describes the architecture that application follows and that the
5
+ kit is designed to reproduce: strict ownership boundaries, injected policy,
6
+ portable components, and conventions that are *executable* (enforced by tests)
7
+ rather than aspirational.
8
+
9
+ ## The layers
10
+
11
+ ```
12
+ convex/
13
+ convex.config.ts # component mounting — each app.use(x) exactly once
14
+ schema.ts # defineSchema(domainTables) — nothing else
15
+ functions.ts # createAuthFunctions(...) — the ONLY place raw builders appear
16
+ foundation.ts # const { Command, Query, observability } = new Foundation(...)
17
+ approvals.ts # const approvals = new Approvals(components.approvals)
18
+ audit.ts # writeAuditEntry over convex-audit-log + admin readers
19
+ triggers.ts # the single Triggers registry + helper registrations
20
+ auth.ts / auth.config.ts / http.ts # identity provider wiring (e.g. WorkOS AuthKit)
21
+ api/ # PUBLIC surface — thin adapters only
22
+ <documents>.ts # authQuery/authMutation wrapping domain executors
23
+ domain/
24
+ table.ts # merges per-domain table maps into domainTables
25
+ shared/ # genuinely cross-entity helpers only
26
+ <entity>/ # one directory per entity/aggregate
27
+ schema.ts # zodTable declaration (shapes only)
28
+ table.ts # indexes attached to <zodTable>.table
29
+ constants.ts # finite vocabularies (UPPER_SNAKE readonly tuples)
30
+ commands.ts # operation registry + executors (new Command(...))
31
+ queries.ts # domain read logic
32
+ approval.ts # approvals.define(...) workflow definitions
33
+ *_functions.ts # internal callbacks (systemMutation/systemAction)
34
+ ```
35
+
36
+ ### Layer rules
37
+
38
+ 1. **`api/` is the only public surface.** It contains no bare `query(` or
39
+ `mutation(` — only `auth*`/`role*`/`admin*` constructors delegating to
40
+ domain `execute*` functions. Public functions are adapters: parse, pass
41
+ `ctx.actor`, project DTOs.
42
+ 2. **`domain/<entity>/` owns everything about the entity** — its zod shape,
43
+ indexes, vocabularies, commands, queries, workflows. Cross-domain reuse
44
+ goes through `domain/shared/`, never sibling imports of another domain's
45
+ internals.
46
+ 3. **Root files are facades over mounted components.** `foundation.ts`,
47
+ `approvals.ts`, `audit.ts` configure infrastructure; business definitions
48
+ stay in the owning domain.
49
+ 4. **Internal functions use `systemMutation`/`systemAction`** — never raw
50
+ `internalMutation(`/`internalAction(` — so triggers and `include` apply to
51
+ scheduled/callback work too.
52
+
53
+ ## Injection, not import
54
+
55
+ The kit's central design rule: **library code never imports app singletons**.
56
+ Every kit surface takes its policy as a parameter:
57
+
58
+ - `createAuthFunctions` receives the generated builders, `getAuthUser`,
59
+ `mapRole`, `verifyMembership`, `triggers`, `errors`.
60
+ - `Foundation` receives `classifyError`, `writeAudit`, the `enabled` flag.
61
+ - `Approvals` receives only its component reference; host data crosses as
62
+ opaque validated strings.
63
+
64
+ The reverse rule holds for components: a portable component must not import
65
+ host domains, generated host APIs, environment configuration, or vendor SDKs
66
+ (WorkOS, Drizzle, Redis…). Host values cross its public API only as
67
+ **validated, bounded, opaque data** — `scopeRef`, `resourceRef`, function
68
+ handles. Component schemas contain only their own records and never mirror a
69
+ private child component's tables.
70
+
71
+ ## Boundaries as schemas
72
+
73
+ Data crossing any boundary is a zod schema derived from one `zodTable`
74
+ declaration per entity (see `zod-table.md`):
75
+
76
+ - storage → `defineTable` via `zodToConvex`
77
+ - writes → `insertSchema`/`updateSchema` (server fields and timestamps
78
+ excluded)
79
+ - commands → `commandInput` (explicit allowlist)
80
+ - clients → `publicDto` + `toPublicDto` (runtime redaction)
81
+ - LLM tools → `tools.*` and `jsonSafeZid`
82
+
83
+ Everything is `.strict()`. There is exactly one source of truth per entity and
84
+ no boundary that "just trusts" its input.
85
+
86
+ ## Writes as commands
87
+
88
+ State changes flow through the audited command protocol (see `commands.md`):
89
+ parse input → observe → run handler → parse output → write audit — one
90
+ transaction, rollback-consistent. Classifications and audit derivation are
91
+ type-required per operation. Human-gated changes use the approvals component;
92
+ its apply steps are commands too.
93
+
94
+ ## Conventions worth enforcing with tests
95
+
96
+ The origin app enforces its architecture with a test suite that raw-globs the
97
+ source and asserts violations — each rule proved against a synthetic fixture
98
+ first so the checker can't silently pass. Highly recommended for consumers:
99
+
100
+ - **No inline tables/schemas** — `defineTable`/`defineSchema` only in
101
+ `table.ts`/`schema.ts`.
102
+ - **No inline enums** — `z.enum([...])` with an inline array literal is
103
+ banned; vocabularies are named `UPPER_SNAKE` tuples in `constants.ts`.
104
+ - **No deep server imports** — components consumed only through their
105
+ `client.ts` facade; explicit allowlist for exceptions.
106
+ - **No handwritten references** — `makeFunctionReference` banned; use
107
+ generated `internal`/`api`.
108
+ - **No sibling-domain imports** — a domain reaches another only via
109
+ `domain/shared/` or the public API.
110
+ - **Facades mount once** — every `app.use(x)` appears exactly once; facades
111
+ never reach a component's private children.
112
+ - **Component boundary tests** — no host imports, no
113
+ `process.env`/vendor-SDK references inside `components/*`.
114
+ - **Copyability** — copy the component to a tmpdir with only its package
115
+ deps, run `tsc`: proves portability continuously.
116
+
117
+ ## Component mounting order
118
+
119
+ In `convex.config.ts`, mount infrastructure before consumers: auth kit →
120
+ foundation → audit log → approvals → named workflow/workpool instances. Use
121
+ `app.use(x, { name })` when mounting the same component twice; `components.
122
+ <name>` keys off that name. Required env vars can be declared on
123
+ `defineApp({ env: { ... } })` so deploys fail fast — remember to stub them in
124
+ test setup.
package/docs/auth.md ADDED
@@ -0,0 +1,167 @@
1
+ # `cvx-kit/auth` — auth-aware function constructors from injected policy
2
+
3
+ `createAuthFunctions` builds the function constructors your whole backend uses
4
+ in place of raw `query`/`mutation`/`action`. Nothing in the kit imports your
5
+ app's singletons — identity resolution, role vocabulary, membership
6
+ verification, trigger wrapping, and error policy are all **injected** by the
7
+ host. The kit provides the structure; the app provides the policy.
8
+
9
+ ## Setup — declared once per app
10
+
11
+ ```ts
12
+ // convex/functions.ts (the single place constructors are built)
13
+ import { createAuthFunctions, defaultRoleMap } from 'cvx-kit/auth'
14
+ import {
15
+ action, internalAction, internalMutation, mutation, query,
16
+ } from './_generated/server'
17
+ import type { DataModel } from './_generated/dataModel'
18
+ import { triggers } from './triggers'
19
+ import { authKit } from './auth'
20
+
21
+ export const {
22
+ authQuery, authMutation, authAction,
23
+ roleQuery, roleMutation, roleAction,
24
+ adminQuery, adminMutation, adminAction,
25
+ systemMutation, systemAction,
26
+ include, authenticatedUser,
27
+ } = createAuthFunctions<DataModel>({
28
+ query, mutation, action, internalMutation, internalAction,
29
+ getAuthUser: (ctx) => authKit.getAuthUser(ctx),
30
+ mapRole: defaultRoleMap, // or your own vocabulary (see below)
31
+ adminRoles: ['admin'],
32
+ triggers, // every mutation write runs through wrapDB
33
+ verifyMembership: async ({ userId, organizationId }) => {
34
+ const memberships = await authKit.workos.userManagement
35
+ .listOrganizationMemberships({ organizationId, userId, statuses: ['active'] })
36
+ const m = memberships.data.find((c) => c.userId === userId)
37
+ return m ? { organizationId: m.organizationId, roleSlug: m.role.slug } : null
38
+ },
39
+ })
40
+ ```
41
+
42
+ Every function in the app is then built from these — raw `query`/`mutation`
43
+ imports from `_generated/server` appear **only** in this file.
44
+
45
+ ## The constructor families
46
+
47
+ | Constructor | Visibility | Auth | Extra |
48
+ |---|---|---|---|
49
+ | `authQuery` / `authMutation` / `authAction` | public | any authenticated org member | actions live-verify membership |
50
+ | `roleQuery(...roles)` / `roleMutation(...)` / `roleAction(...)` | public | listed roles only | factory — call with your roles |
51
+ | `adminQuery` / `adminMutation` / `adminAction` | public | `config.adminRoles` | pre-built `role*(...adminRoles)` |
52
+ | `systemMutation` | internal | none (trusted caller) | still trigger-wrapped |
53
+ | `systemAction` | internal | none | plain internal action |
54
+
55
+ All are `zCustom*` constructors from convex-helpers, so `args` and `returns`
56
+ take zod schemas directly and compose with `zodTable` masks.
57
+
58
+ ## What handlers receive on `ctx`
59
+
60
+ Authenticated constructors extend the ctx with a frozen auth bundle:
61
+
62
+ - `ctx.identity` — the raw `UserIdentity` (JWT claims)
63
+ - `ctx.user.id` — the synchronized principal id (from `getAuthUser`)
64
+ - `ctx.org` — `{ organizationId, role }`
65
+ - `ctx.role` — the mapped role
66
+ - `ctx.actor` — `{ userId, organizationId, role }` — pass this to audits,
67
+ approvals, and commands as the canonical actor reference
68
+ - `ctx.include` — the bounded query builder (below)
69
+
70
+ `systemMutation` gets `include` and trigger wrapping but no auth bundle.
71
+
72
+ ## Role vocabulary is per-app
73
+
74
+ `mapRole` maps the identity's role slug onto **your** role union; returning
75
+ `null` rejects with `FORBIDDEN`, so unknown roles never pass. The default
76
+ (`defaultRoleMap`) is WorkOS-flavored: `member → writer`, and
77
+ `reader`/`writer`/`admin` pass through.
78
+
79
+ Supply your own vocabulary via the generic:
80
+
81
+ ```ts
82
+ createAuthFunctions<DataModel, 'viewer' | 'editor' | 'owner'>({
83
+ mapRole: (slug) => (slug === 'owner' ? 'owner' : slug === 'editor' ? 'editor' : slug ? 'viewer' : null),
84
+ adminRoles: ['owner'],
85
+ ...
86
+ })
87
+ ```
88
+
89
+ ## Queries/mutations trust the JWT; actions re-verify
90
+
91
+ Queries and mutations read org and role from the identity token. Actions —
92
+ which can run long and call third parties — additionally call
93
+ `verifyMembership` **live** before the handler runs, and the fresher
94
+ role/organization from that check replaces the JWT's. Verification **fails
95
+ closed**: any error or org mismatch throws `FORBIDDEN`. Omit
96
+ `verifyMembership` to let actions trust the JWT like queries do.
97
+
98
+ ## Mutations are structurally trigger-wrapped
99
+
100
+ When `triggers` (or `wrapDB`) is configured, every `authMutation`,
101
+ `roleMutation`, and `systemMutation` runs its writes through
102
+ `triggers.wrapDB(ctx)`. Trigger enforcement (timestamps, append-only,
103
+ no-delete, denormalization) is therefore **structural** — a handler cannot
104
+ forget it, because it never sees an unwrapped `ctx.db`. This is the reason raw
105
+ `mutation` must not be used outside `functions.ts`.
106
+
107
+ ## `include()` — every read is bounded
108
+
109
+ `include` wraps a table query in a fluent selector that (a) picks the first
110
+ matching indexed query and (b) refuses unbounded reads. `execute(limit)`
111
+ rejects limits outside `1..maxRows` (default 100, configurable via
112
+ `maxIncludedQueryRows`).
113
+
114
+ ```ts
115
+ export const list = authQuery({
116
+ args: { ownerId: zid('users').optional(), limit: z.number() },
117
+ returns: z.array(documents.publicDto),
118
+ handler: (ctx, args) =>
119
+ ctx.include(ctx.db.query('documents'))
120
+ .when(args.ownerId, (q, ownerId) =>
121
+ q.withIndex('by_owner', (ix) => ix.eq('ownerId', ownerId)))
122
+ .otherwise((q) => q.withIndex('by_owner'))
123
+ .execute(args.limit, (rows) => rows.map(documents.toPublicDto)),
124
+ })
125
+ ```
126
+
127
+ Selector methods:
128
+
129
+ - `.when(value, select)` — if `value` is non-null and nothing selected yet,
130
+ use `select(query, value)`.
131
+ - `.matching(indexName, range?, shouldMatch?)` — index selection with an
132
+ optional guard boolean.
133
+ - `.otherwise(select)` — fallback, returns the raw `Query`.
134
+ - `.resolve()` — selected query or full table scan (escape hatch).
135
+ - `.execute(limit, transform?)` — bounded `take` + optional projection. This
136
+ is the normal terminal.
137
+
138
+ ## Row-level security and tenancy (optional)
139
+
140
+ The `security` config adds structural RLS to every `auth*`/`role*`/`admin*`
141
+ constructor — role-level rules (`security.rules`, standalone), tenant
142
+ isolation from a table registry (`security.tenancy`, opt-in), or both
143
+ AND-composed. Queries get a wrapped reader; mutations wrap triggers first,
144
+ then the RLS writer; `system*` stays unwrapped. With tenancy configured, ctx
145
+ gains `ctx.tenant` (actions re-derive it from live verification). Full
146
+ treatment: `tenancy.md`.
147
+
148
+ ## Errors
149
+
150
+ All rejections go through the injected `ErrorFactory` (default: throws
151
+ `KitError` with codes `UNAUTHENTICATED`, `FORBIDDEN`,
152
+ `INVALID_REQUEST_BOUNDARY`). Hosts with their own error taxonomy adapt it:
153
+
154
+ ```ts
155
+ errors: { throw: (input) => { throw App.errors.from(input) } }
156
+ ```
157
+
158
+ ## Rules of thumb
159
+
160
+ 1. Build constructors once in `convex/functions.ts`; import them everywhere
161
+ else. Raw `_generated/server` builders appear nowhere else.
162
+ 2. Public functions: `auth*`/`role*`/`admin*`. Scheduled/internal work:
163
+ `system*`. Never expose an unauthenticated public function unless it is
164
+ deliberately anonymous (and then document why).
165
+ 3. Reads go through `ctx.include(...)` with an explicit limit.
166
+ 4. Use `ctx.actor` as the actor reference in audits and approvals — never
167
+ re-derive identity in a handler.