UNPKG

cogsbox-shape

Version:

A TypeScript library for creating type-safe database schemas with Zod validation, SQL type definitions, and automatic client/server transformations. Unifies client, server, and database types through a single schema definition, with built-in support for r

681 lines (680 loc) 24.4 kB
import { z } from "zod"; import { v4 as uuidv4 } from "uuid"; import zodToJsonSchema, {} from "zod-to-json-schema"; export const isFunction = (fn) => typeof fn === "function"; // Function to create a properly typed current timestamp config export function currentTimeStamp() { return { default: "CURRENT_TIMESTAMP", defaultValue: new Date(), }; } // Internal type creation helper const createClient = ({ sqlConfig, inferredDbType, inferredClientType, baseJsonSchema, serverType, }) => { return (assert, defaultValue) => { const clientType = isFunction(assert) ? assert({ zod: inferredClientType, ...(serverType && { serverType }), }) : assert || inferredClientType; // Handle timestamp default let finalSqlConfig = sqlConfig; let finalDefaultValue = defaultValue; if (defaultValue && typeof defaultValue === "object" && "default" in defaultValue && defaultValue.default === "CURRENT_TIMESTAMP") { finalSqlConfig = { ...sqlConfig, default: "CURRENT_TIMESTAMP", }; finalDefaultValue = defaultValue.defaultValue; } const effectiveDbType = serverType || inferredDbType; const clientJsonSchema = zodToJsonSchema(clientType); return { sql: finalSqlConfig, zodDbSchema: effectiveDbType, zodClientSchema: clientType, jsonSchema: serverType ? clientJsonSchema : baseJsonSchema, defaultValue: finalDefaultValue ?? (serverType ? inferDefaultFromZod(serverType) : finalDefaultValue), transform: (transforms) => ({ sql: finalSqlConfig, zodDbSchema: effectiveDbType, zodClientSchema: clientType, jsonSchema: serverType ? clientJsonSchema : baseJsonSchema, defaultValue: finalDefaultValue, toClient: transforms.toClient, toDb: transforms.toDb, transforms: { toClient: transforms.toClient.toString(), toDb: transforms.toDb.toString(), }, }), }; }; }; export function createTransforms(transforms) { return { sql: (config) => { const base = shape.sql(config); return { sql: base.sql, dbType: base.dbType, zodDbSchema: base.zodDbSchema, zodClientSchema: base.zodClientSchema, client: base.client, db: (dbType) => { const baseDb = base.db(dbType); const transformMethods = Object.entries(transforms).reduce((acc, [key, transform]) => ({ ...acc, [key]: () => ({ sql: config, zodDbSchema: baseDb.zodDbSchema, zodClientSchema: z.unknown(), toClient: transform.toClient, toDb: transform.toDb, }), }), {}); return { ...baseDb, client: Object.assign(baseDb.client, transformMethods), }; }, }; }, }; } export const shape = { // Integer fields int: (config = {}) => shape.sql({ type: "int", ...config, }), // String fields with variants varchar: (config = {}) => shape.sql({ type: "varchar", ...config, }), char: (config = {}) => shape.sql({ type: "char", ...config, }), text: (config = {}) => shape.sql({ type: "text", ...config, }), longtext: (config = {}) => shape.sql({ type: "longtext", ...config, }), // Boolean fields boolean: (config = {}) => shape.sql({ type: "boolean", ...config, }), // Date fields date: (config = {}) => shape.sql({ type: "date", ...config, }), datetime: (config = {}) => shape.sql({ type: "datetime", ...config, }), sql: (sqlConfig) => { const inferredDbType = (() => { let baseType; if (sqlConfig.pk) { baseType = z.number(); // DB PKs are always numbers } else { switch (sqlConfig.type) { case "int": baseType = z.number(); break; case "varchar": case "char": case "text": case "longtext": baseType = z.string(); break; case "boolean": baseType = z.boolean(); break; case "date": case "datetime": baseType = z.date(); break; default: throw new Error(`Unsupported type: ${sqlConfig}`); } } if (sqlConfig.nullable) { baseType = baseType.nullable(); } return baseType; })(); const inferredClientType = (() => { let baseType; if (sqlConfig.pk) { baseType = z.string(); // Client PKs are always strings } else { switch (sqlConfig.type) { case "int": baseType = z.number(); break; case "varchar": case "char": case "text": case "longtext": baseType = z.string(); break; case "boolean": baseType = z.boolean(); break; case "date": case "datetime": if (sqlConfig.default === "CURRENT_TIMESTAMP") { baseType = z.date().optional(); } baseType = z.date(); break; default: throw new Error(`Unsupported type: ${sqlConfig}`); } } if (sqlConfig.nullable) { baseType = baseType.nullable(); } return baseType; })(); // Create JSON Schema version immediately const jsonSchema = zodToJsonSchema(inferredDbType); return { sql: sqlConfig, dbType: inferredDbType, zodDbSchema: inferredDbType, zodClientSchema: inferredClientType, jsonSchema, defaultValue: inferDefaultFromZod(inferredDbType, sqlConfig), client: createClient({ sqlConfig, inferredDbType, inferredClientType, baseJsonSchema: jsonSchema, }), db: (assert) => { const serverType = isFunction(assert) ? assert({ zod: inferredDbType }) : assert; return { sql: sqlConfig, dbType: serverType, zodDbSchema: serverType, zodClientSchema: inferredClientType, jsonSchema: zodToJsonSchema(serverType), defaultValue: inferDefaultFromZod(serverType), client: createClient({ sqlConfig, inferredDbType, inferredClientType, baseJsonSchema: jsonSchema, serverType, }), }; }, }; }, sql2: (sqlConfig) => { const sqlZodType = (() => { let baseType; if (sqlConfig.pk) { baseType = z.number(); } else { switch (sqlConfig.type) { case "int": baseType = z.number(); break; case "boolean": baseType = z.boolean(); break; case "date": case "datetime": baseType = z.date(); break; default: baseType = z.string(); break; } } if (sqlConfig.nullable) { baseType = baseType.nullable(); } return baseType; })(); // Initialize with sql type for all schemas return createBuilder({ stage: "sql", sqlConfig: sqlConfig, sqlZod: sqlZodType, newZod: sqlZodType, initialValue: undefined, clientZod: sqlZodType, validationZod: sqlZodType, }); }, }; function createBuilder(config) { // Initialize completed stages tracker const completedStages = config.completedStages || new Set(["sql"]); const builderObject = { config: { sql: config.sqlConfig, zodSqlSchema: config.sqlZod, zodNewSchema: config.newZod, initialValue: config.initialValue || inferDefaultFromZod(config.clientZod, config.sqlConfig), zodClientSchema: config.clientZod, zodValidationSchema: config.validationZod, }, initialState: (schemaOrDefault, defaultValue) => { // Runtime validation if (completedStages.has("new")) { throw new Error("initialState() can only be called once in the chain"); } if (completedStages.has("client")) { throw new Error("initialState() must be called before client()"); } if (completedStages.has("validation")) { throw new Error("initialState() must be called before validation()"); } // Handle overload - if no second param, first param is the default const hasTypeParam = defaultValue !== undefined; const newSchema = hasTypeParam ? isFunction(schemaOrDefault) ? schemaOrDefault({ sql: config.sqlZod }) : schemaOrDefault : config.sqlZod; // Keep SQL type if just setting default const finalDefaultValue = hasTypeParam ? defaultValue() : schemaOrDefault(); const newCompletedStages = new Set(completedStages); newCompletedStages.add("new"); const newClientZod = hasTypeParam ? z.union([config.sqlZod, newSchema]) : config.sqlZod; return createBuilder({ ...config, stage: "new", newZod: newSchema, initialValue: finalDefaultValue, clientZod: newClientZod, validationZod: hasTypeParam ? z.union([config.sqlZod, newSchema]) : config.sqlZod, completedStages: newCompletedStages, }); }, client: (assert) => { // Runtime validation if (completedStages.has("client")) { throw new Error("client() can only be called once in the chain"); } if (completedStages.has("validation")) { throw new Error("client() must be called before validation()"); } const clientSchema = isFunction(assert) ? assert({ sql: config.sqlZod, initialState: config.newZod }) : assert; const newCompletedStages = new Set(completedStages); newCompletedStages.add("client"); return createBuilder({ ...config, stage: "client", clientZod: clientSchema, // Always set validation to match client when client is set validationZod: clientSchema, completedStages: newCompletedStages, }); }, validation: (assert) => { // Runtime validation if (completedStages.has("validation")) { throw new Error("validation() can only be called once in the chain"); } const validationSchema = isFunction(assert) ? assert({ sql: config.sqlZod, initialState: config.newZod, client: config.clientZod, }) : assert; const newCompletedStages = new Set(completedStages); newCompletedStages.add("validation"); return createBuilder({ ...config, stage: "validation", validationZod: validationSchema, completedStages: newCompletedStages, }); }, transform: (transforms) => { // Runtime validation if (!completedStages.has("validation") && !completedStages.has("client")) { throw new Error("transform() requires at least client() or validation() to be called first"); } return { config: { ...builderObject.config, transforms: { toClient: transforms.toClient, toDb: transforms.toDb, }, }, }; }, }; return builderObject; } export function hasMany(config) { return () => ({ type: "hasMany", fromKey: config.fromKey, toKey: config.toKey(), schema: config.schema(), defaultCount: config.defaultCount, }); } export function hasOne(config) { return () => ({ type: "hasOne", fromKey: config.fromKey, toKey: config.toKey(), schema: config.schema(), }); } export function belongsTo(config) { return () => ({ type: "belongsTo", fromKey: config.fromKey, toKey: config.toKey(), schema: config.schema(), }); } export function manyToMany(config) { return () => ({ type: "manyToMany", fromKey: config.fromKey, toKey: config.toKey(), schema: config.schema(), defaultCount: config.defaultCount, }); } function isRelation(value) { return (value && typeof value === "object" && "type" in value && "fromKey" in value && "toKey" in value && "schema" in value); } function inferDefaultFromZod(zodType, sqlConfig) { if (sqlConfig?.pk) { return uuidv4(); } if (zodType instanceof z.ZodOptional) { return undefined; } if (zodType instanceof z.ZodNullable) { return null; } if (zodType instanceof z.ZodArray) { return []; } if (zodType instanceof z.ZodObject) { return {}; } if (zodType instanceof z.ZodString) { return ""; } if (zodType instanceof z.ZodNumber) { return 0; } if (zodType instanceof z.ZodBoolean) { return false; } // Check for explicit default last if (zodType instanceof z.ZodDefault && zodType._def?.defaultValue) { return typeof zodType._def.defaultValue === "function" ? zodType._def.defaultValue() : zodType._def.defaultValue; } return undefined; } export function reference(config) { return { ...config.field, type: "reference", to: config.to, }; } function createSerializableSchema(schema) { const serializableSchema = { _tableName: schema._tableName, __schemaId: crypto.randomUUID(), _syncKey: schema._syncKey ? { toString: schema._syncKey.toString(), } : undefined, }; for (const [key, value] of Object.entries(schema)) { if (key === "_tableName" || key === "__schemaId") continue; if (typeof value === "function") { const relation = value(); if (!isRelation(relation)) { throw new Error(`Invalid relation for key ${key}`); } // Call the schema function to get the actual schema const childSchema = createSerializableSchema(relation.schema); // Get toKey value by calling the function const toKeyField = relation.toKey.type === "reference" ? relation.toKey.to() : relation.toKey; const serializedToKey = { sql: toKeyField.sql, jsonSchema: zodToJsonSchema(toKeyField.zodClientSchema), defaultValue: toKeyField.defaultValue, }; serializableSchema[key] = { type: "relation", relationType: relation.type, fromKey: relation.fromKey, toKey: serializedToKey, schema: childSchema, ...(relation.type === "hasMany" && { defaultCount: relation.defaultCount, }), }; } else { // Handle regular fields or references (unchanged) if (value.type === "reference") { const referencedField = value.to(); const serializedField = { sql: referencedField.sql, jsonSchema: zodToJsonSchema(referencedField.zodClientSchema), defaultValue: referencedField.defaultValue, ...(referencedField.toClient && referencedField.toDb && { transforms: { toClient: referencedField.toClient.toString(), toDb: referencedField.toDb.toString(), }, }), }; serializableSchema[key] = serializedField; } else { const serializedField = { sql: value.sql, jsonSchema: zodToJsonSchema(value.zodClientSchema), defaultValue: value.defaultValue, ...(value.toClient && value.toDb && { transforms: { toClient: value.toClient.toString(), toDb: value.toDb.toString(), }, }), }; serializableSchema[key] = serializedField; } } } return serializableSchema; } export function createMixedValidationSchema(schema, clientSchema, dbSchema) { // If schemas are provided, use them (to avoid circular calls) if (clientSchema && dbSchema) { const mixedFields = {}; const allKeys = new Set([ ...Object.keys(clientSchema.shape), ...Object.keys(dbSchema.shape), ]); for (const key of allKeys) { const clientField = clientSchema.shape[key]; const dbField = dbSchema.shape[key]; if (clientField && dbField) { mixedFields[key] = z.union([clientField, dbField]); } else { mixedFields[key] = clientField || dbField; } } return z.object(mixedFields); } // Build schemas manually without calling createSchema const clientFields = {}; const dbFields = {}; for (const [key, value] of Object.entries(schema)) { if (key === "_tableName") continue; if (typeof value === "function") { const relation = value(); if (!isRelation(relation)) continue; // For relations, create mixed schemas recursively const childMixedSchema = createMixedValidationSchema(relation.schema); if (relation.type === "hasMany") { clientFields[key] = z.array(childMixedSchema); dbFields[key] = z.array(childMixedSchema); } else { clientFields[key] = childMixedSchema; dbFields[key] = childMixedSchema; } continue; } clientFields[key] = value.zodClientSchema; dbFields[key] = value.zodDbSchema; } // Now create mixed fields const mixedFields = {}; const allKeys = new Set([ ...Object.keys(clientFields), ...Object.keys(dbFields), ]); for (const key of allKeys) { const clientField = clientFields[key]; const dbField = dbFields[key]; if (clientField && dbField) { mixedFields[key] = z.union([clientField, dbField]); } else { mixedFields[key] = (clientField || dbField); } } return z.object(mixedFields); } export function createSchema(schema) { const serialized = createSerializableSchema(schema); const dbFields = {}; const clientFields = {}; const defaultValues = {}; // ... existing schema building logic ... for (const [key, value] of Object.entries(schema)) { if (key === "_tableName") continue; if (typeof value === "function") { const relation = value(); if (!isRelation(relation)) { throw new Error(`Invalid relation for key ${key}`); } const childSchema = createSchema(relation.schema); // ... existing relation logic ... if (relation.type === "hasMany") { dbFields[key] = z.array(z.object(childSchema.dbSchema.shape)); clientFields[key] = z.array(z.object(childSchema.clientSchema.shape)); const count = relation.defaultCount || 0; defaultValues[key] = Array.from({ length: count }, () => ({ ...childSchema.defaultValues, })); } else { dbFields[key] = z.object(childSchema.dbSchema.shape); clientFields[key] = z.object(childSchema.clientSchema.shape); defaultValues[key] = childSchema.defaultValues; } continue; } dbFields[key] = value.zodDbSchema; clientFields[key] = value.zodClientSchema; defaultValues[key] = value.defaultValue ?? inferDefaultFromZod(value.zodClientSchema); } const clientSchemaObj = z.object(clientFields); const dbSchemaObj = z.object(dbFields); // Pass the built schemas to avoid circular reference const mixedSchema = createMixedValidationSchema(schema, clientSchemaObj, dbSchemaObj); return { dbSchema: dbSchemaObj, clientSchema: clientSchemaObj, mixedSchema: mixedSchema, defaultValues: defaultValues, initialValues: () => defaultValues, serialized: serialized, }; } export function createSchema2(schema) { const sqlFields = {}; const clientFields = {}; const validationFields = {}; const defaultValues = {}; for (const key in schema) { if (key === "_tableName") continue; const field = schema[key]; if (field && typeof field === "object" && "config" in field) { sqlFields[key] = field.config.zodSqlSchema; //field.config' is of type 'unknown clientFields[key] = field.config.zodClientSchema; validationFields[key] = field.config.zodValidationSchema; defaultValues[key] = field.config.initialValue; } } return { sqlSchema: z.object(sqlFields), clientSchema: z.object(clientFields), validationSchema: z.object(validationFields), defaultValues: defaultValues, }; }