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.
- package/README.md +17 -0
- package/dist/auth.d.mts +52 -5
- package/dist/auth.mjs +1 -1
- package/dist/auth.mjs.map +1 -1
- package/dist/components/approvals/audit.mjs +1 -1
- package/dist/components/foundation/client.d.mts +40 -3
- package/dist/components/foundation/client.mjs +1 -1
- package/dist/components/foundation/client.mjs.map +1 -1
- package/dist/index.d.mts +6 -5
- package/dist/index.mjs +1 -1
- package/dist/tenancy.d.mts +34 -0
- package/dist/tenancy.mjs +2 -0
- package/dist/tenancy.mjs.map +1 -0
- package/dist/triggers.d.mts +9 -1
- package/dist/triggers.mjs +1 -1
- package/dist/triggers.mjs.map +1 -1
- package/dist/zod-table.d.mts +21 -1
- package/dist/zod-table.mjs +1 -1
- package/dist/zod-table.mjs.map +1 -1
- package/docs/approvals.md +152 -0
- package/docs/architecture.md +124 -0
- package/docs/auth.md +167 -0
- package/docs/commands.md +229 -0
- package/docs/conventions.md +291 -0
- package/docs/explain/convex-architecture-explainer.html +460 -0
- package/docs/explain/modules-and-multitenancy.html +469 -0
- package/docs/llms.md +206 -0
- package/docs/maintainability.md +124 -0
- package/docs/migration.md +351 -0
- package/docs/tenancy.md +179 -0
- package/docs/triggers.md +75 -0
- package/docs/zod-table.md +173 -0
- package/package.json +10 -3
- package/src/auth.ts +127 -14
- package/src/components/foundation/client.ts +114 -3
- package/src/index.ts +15 -0
- package/src/tenancy.ts +110 -0
- package/src/triggers.ts +34 -0
- package/src/zod-table.ts +86 -0
package/dist/triggers.mjs.map
CHANGED
|
@@ -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"}
|
package/dist/zod-table.d.mts
CHANGED
|
@@ -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
|
package/dist/zod-table.mjs
CHANGED
|
@@ -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
|
|
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
|
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\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.
|