cvx-kit 0.0.2 → 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.
@@ -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":"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"}
package/docs/auth.md CHANGED
@@ -135,6 +135,16 @@ Selector methods:
135
135
  - `.execute(limit, transform?)` — bounded `take` + optional projection. This
136
136
  is the normal terminal.
137
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
+
138
148
  ## Errors
139
149
 
140
150
  All rejections go through the injected `ErrorFactory` (default: throws
package/docs/commands.md CHANGED
@@ -85,17 +85,69 @@ The public API layer then wraps the executor in an `authMutation` and passes
85
85
 
86
86
  1. **Parse the command** through the operation's `command` schema (strict zod).
87
87
  2. **Observe**: start the observation clock.
88
- 3. **Run the handler**; its return value is parsed through the `result` schema
88
+ 3. **Permission check** — if the operation declares `permission`, the
89
+ Foundation's injected `checkPermission(ctx, { permission, operation })`
90
+ runs; throw to deny. Declaring a permission with no injected checker
91
+ **fails closed** (`COMMAND_PERMISSION_NOT_CONFIGURED`).
92
+ 4. **Default guard** — the registry-wide guard passed as the Command's second
93
+ argument (`new Command(operations, { guard })`), if any.
94
+ 5. **Operation guard** — the operation's own `guard(ctx, command)`:
95
+ preconditions like state-machine legality, ownership beyond roles, or
96
+ invariants over the parsed command. Throw to deny — nothing has run yet,
97
+ so a denial is always clean.
98
+ 6. **Run the handler**; its return value is parsed through the `result` schema
89
99
  — outputs are validated too.
90
- 4. **Audit**: call the operation's `audit({ command, result }, ctx)`. If it
100
+ 7. **Audit**: call the operation's `audit({ command, result }, ctx)`. If it
91
101
  returns non-null, the entry (plus the operation's `classification`) is
92
102
  written through the injected `writeAudit` — **in the same transaction** as
93
103
  the handler's writes. Returning `null` skips the audit (the ontology
94
- convention: failures are not audited).
95
- 5. **Emit the observation**: `{ operation, classification, outcome,
104
+ convention: failures are not audited). When the operation declares
105
+ `aggregates: [...]`, an audit whose `aggregate.type` is not in that
106
+ allowlist throws (`COMMAND_AGGREGATE_NOT_DECLARED`) — the audit
107
+ vocabulary is enforced, not advisory. The per-operation catalog is
108
+ introspectable as `commands.aggregates` for tests.
109
+ 8. **Emit the observation**: `{ operation, classification, outcome,
96
110
  errorCode?, durationMs }` — completed, denied, or failed per
97
111
  `classifyError`.
98
112
 
113
+ ### Guards and permissions
114
+
115
+ ```ts
116
+ // convex/foundation.ts — permission semantics are host policy, injected once
117
+ export const { Command, Query, observability } = new Foundation(
118
+ components.foundation,
119
+ {
120
+ observability: { ... },
121
+ checkPermission: (ctx, { permission }) =>
122
+ requirePermission(ctx.identity, permission), // throw to deny
123
+ },
124
+ )
125
+
126
+ // domain/<module>/commands.ts
127
+ const operations = {
128
+ '<entities>.publish': Command.operation({
129
+ command: <entities>.commandInput.extend({ id: zid('<entities>') }),
130
+ result: z.object({ ok: z.literal(true) }).strict(),
131
+ classification: 'business',
132
+ permission: '<domain>.manage', // checked first
133
+ aggregates: ['<aggregate-type>'], // audit vocabulary allowlist
134
+ guard: async (ctx, command) => { // precondition, pre-handler
135
+ const row = await requireTenantReference(ctx.tenant, () => ctx.db.get(command.id))
136
+ if (row.state !== 'draft') errors.throw({ code: 'INVALID_STATE' })
137
+ },
138
+ audit: ({ command }) => ({ ... }),
139
+ }),
140
+ } as const
141
+
142
+ const commands = new Command<MutationCtx, typeof operations>(operations, {
143
+ guard: (ctx) => assertNotReadonlyWindow(ctx), // registry-wide default
144
+ })
145
+ ```
146
+
147
+ Guards deny by throwing; a denial before the handler never needs rollback
148
+ because nothing has executed. Keep guards read-only — a guard that writes is
149
+ a handler in disguise.
150
+
99
151
  If any step throws, the Convex transaction rolls back — handler writes and
100
152
  audit entry together. Audit and effects can never disagree.
101
153
 
@@ -55,6 +55,27 @@ Three zones, three privileges:
55
55
  Dependency direction is one-way: `api → domain → shared → kit`. Anything
56
56
  pointing the other way is a violation.
57
57
 
58
+ ### Module grain
59
+
60
+ `domain/<entity>/` is really `domain/<module>/`: the directory is a **bounded
61
+ context that may own several related tables**, not necessarily one. Two
62
+ tables belong in the same module when a single command routinely writes both,
63
+ or when one's vocabulary is meaningless without the other; otherwise they are
64
+ separate modules. The file anatomy in §2 is unchanged — a module's
65
+ `schema.ts` simply declares several `zodTable`s and its `table.ts` exports
66
+ one merged map (combine maps in `domain/table.ts` with `createModule`, which
67
+ rejects duplicate owners). A one-table module is the degenerate case, so
68
+ small apps look exactly like before.
69
+
70
+ Cross-module rules:
71
+
72
+ - Sibling modules may import each other's `rules.ts` and `queries.ts`
73
+ **only** — never `commands.ts`: one module never mutates another's state.
74
+ - A workflow that must atomically write across modules lives in an optional
75
+ `application/` layer above `domain/` (`application/<workflow>.ts`); `api/`
76
+ adapters then import `application/*` and their own module, never a second
77
+ module.
78
+
58
79
  ---
59
80
 
60
81
  ## 2. File anatomy — what each file contains, exactly