UNPKG

kitcn

Version:

kitcn - React Query integration and CLI tools for Convex

246 lines (240 loc) 11.7 kB
import { augmentBetterAuthTables, indexFields } from "./create-schema-BXrKE2YY.js"; //#region src/auth/create-schema-orm.ts const specialFields = (tables) => Object.fromEntries(Object.entries(tables).map(([key, table]) => { return [key, Object.fromEntries(Object.entries(table.fields).map(([fieldKey, field]) => [field.fieldName ?? fieldKey, { ...field.sortable ? { sortable: true } : {}, ...field.unique ? { unique: true } : {}, ...field.references ? { references: field.references } : {} }]).filter(([_key, value]) => typeof value === "object" ? Object.keys(value).length > 0 : true))]; }).filter(([_key, value]) => typeof value === "object" ? Object.keys(value).length > 0 : true)); const mergedIndexFields = (tables) => Object.fromEntries(Object.entries(tables).map(([key, table]) => { const tableSpecialFields = specialFields(tables)[key] || {}; const resolveIndexField = (fieldKey) => { const field = table.fields[fieldKey]; return field ? field.fieldName ?? fieldKey : null; }; const manualIndexes = indexFields[key]?.reduce((indexes, index) => { if (typeof index === "string") { const resolved = resolveIndexField(index); if (resolved) indexes.push(resolved); return indexes; } const resolved = index.map((fieldKey) => resolveIndexField(fieldKey)).filter((fieldName) => fieldName !== null); if (resolved.length === index.length) indexes.push(resolved); return indexes; }, []) || []; const specialFieldIndexes = Object.entries(tableSpecialFields).filter(([, fieldMeta]) => fieldMeta.unique !== true).map(([fieldName]) => fieldName).filter((index) => !manualIndexes.some((m) => Array.isArray(m) ? m[0] === index : m === index)); return [key, manualIndexes.concat(specialFieldIndexes)]; })); const VALID_IDENTIFIER_REGEX = /^[$A-Z_][0-9A-Z_$]*$/i; const LEADING_DIGIT_REGEX = /^[0-9]/; const PLURALIZE_ES_SUFFIX_REGEX = /(?:s|x|z|ch|sh)$/i; const PLURALIZE_IES_SUFFIX_REGEX = /[^aeiou]y$/i; const TABLE_IDENTIFIER_SUFFIX_REGEX = /(?:Table|_table)$/i; const renderObjectKey = (value) => VALID_IDENTIFIER_REGEX.test(value) ? value : JSON.stringify(value); const renderPropertyAccess = (objectName, propertyName) => VALID_IDENTIFIER_REGEX.test(propertyName) ? `${objectName}.${propertyName}` : `${objectName}[${JSON.stringify(propertyName)}]`; const toIdentifier = (value) => { const normalized = value.replace(/[^a-zA-Z0-9_$]/g, "_"); if (!normalized) return "_table"; if (LEADING_DIGIT_REGEX.test(normalized)) return `_${normalized}`; return normalized; }; const toTableIdentifier = (value) => { const identifier = toIdentifier(value); return TABLE_IDENTIFIER_SUFFIX_REGEX.test(identifier) ? identifier : `${identifier}Table`; }; const getTableEntries = (tables) => { const usedNames = /* @__PURE__ */ new Map(); return Object.entries(tables).map(([key, table]) => { const modelName = table.modelName; const baseName = toTableIdentifier(modelName); const count = usedNames.get(baseName) ?? 0; usedNames.set(baseName, count + 1); return { key, modelName, table, varName: count === 0 ? baseName : `${baseName}_${count + 1}` }; }); }; const findTableEntryByModel = (entries, tables, model) => entries.find((entry) => entry.modelName === model) ?? entries.find((entry) => entry.key === model) ?? entries.find((entry) => tables[entry.key]?.modelName === model); const getReferencedFieldName = (tables, entries, model, field) => { if (field === "id") return "id"; const entry = findTableEntryByModel(entries, tables, model); if (!entry) return field; return entry.table.fields[field]?.fieldName ?? field; }; const stripIdSuffix = (value) => value.endsWith("Id") && value.length > 2 ? value.slice(0, -2) : value; const capitalize = (value) => value ? `${value.slice(0, 1).toUpperCase()}${value.slice(1)}` : value; const pluralize = (value) => { if (PLURALIZE_ES_SUFFIX_REGEX.test(value)) return `${value}es`; if (PLURALIZE_IES_SUFFIX_REGEX.test(value)) return `${value.slice(0, -1)}ies`; return `${value}s`; }; const buildRelationEntries = (tables, entries) => { const rawRelations = entries.flatMap((source) => Object.entries(source.table.fields).map(([fieldKey, field]) => { const attr = field; if (!attr.references) return null; const target = findTableEntryByModel(entries, tables, attr.references.model) ?? null; if (!target) return null; return { source, fieldKey, sourceFieldName: attr.fieldName ?? fieldKey, target, targetFieldName: getReferencedFieldName(tables, entries, attr.references.model, attr.references.field) }; }).filter((value) => value !== null)); const pairCounts = /* @__PURE__ */ new Map(); for (const relation of rawRelations) { const key = `${relation.source.modelName}->${relation.target.modelName}`; pairCounts.set(key, (pairCounts.get(key) ?? 0) + 1); } return rawRelations.map((relation) => { const pairKey = `${relation.source.modelName}->${relation.target.modelName}`; const needsAlias = (pairCounts.get(pairKey) ?? 0) > 1; const oneName = toIdentifier(stripIdSuffix(relation.fieldKey) || relation.target.modelName); return { alias: needsAlias ? oneName : void 0, manyName: toIdentifier(needsAlias ? `${pluralize(relation.source.modelName)}As${capitalize(oneName)}` : pluralize(relation.source.modelName)), oneName, source: relation.source, sourceFieldName: relation.sourceFieldName, target: relation.target, targetFieldName: relation.targetFieldName }; }); }; const getTypeExpression = (field, state) => { const type = field.type; if (Array.isArray(type)) { state.ormImports.add("textEnum"); return `textEnum(${`[${type.map((value) => JSON.stringify(value)).join(", ")}]`})`; } switch (type) { case "boolean": state.ormImports.add("boolean"); return "boolean()"; case "date": state.ormImports.add("timestamp"); return "timestamp()"; case "json": state.ormImports.add("text"); return "text()"; case "number": if (field.bigint) { state.ormImports.add("bigint"); return "bigint()"; } state.ormImports.add("integer"); return "integer()"; case "number[]": state.ormImports.add("arrayOf"); state.ormImports.add("integer"); return "arrayOf(integer().notNull())"; case "string": state.ormImports.add("text"); return "text()"; case "string[]": state.ormImports.add("arrayOf"); state.ormImports.add("text"); return "arrayOf(text().notNull())"; default: throw new Error(`Unsupported Better Auth field type: ${String(type)}`); } }; const createSchemaOrm = async ({ file, regenerateCommand, tables }) => { return renderSchemaOrmFile({ file, mode: "schema", regenerateCommand, tables }); }; const renderSchemaOrmFile = async ({ extensionKey, exportName, file, mode, regenerateCommand, tables }) => { const path = await import(Buffer.from("cGF0aA==", "base64").toString()); if (path.basename(path.resolve(process.cwd(), file ?? "")) === "convex") throw new Error("Better Auth schema must be generated in the Better Auth component directory."); tables = augmentBetterAuthTables(tables); const entries = getTableEntries(tables); const relationEntries = buildRelationEntries(tables, entries); const state = { ormImports: new Set(["convexTable", mode === "extension" ? "defineSchemaExtension" : "defineSchema"]) }; const tableBlocks = []; for (const entry of entries) { const fieldLines = Object.entries(entry.table.fields).filter(([fieldKey]) => fieldKey !== "id").map(([fieldKey, field]) => { const attr = field; const key = renderObjectKey(attr.fieldName ?? fieldKey); let expression = getTypeExpression(attr, state); if (attr.required) expression += ".notNull()"; if (attr.unique) expression += ".unique()"; if (attr.references) { const referencedEntry = findTableEntryByModel(entries, tables, attr.references.model) ?? { varName: toIdentifier(attr.references.model) }; const targetField = getReferencedFieldName(tables, entries, attr.references.model, attr.references.field); expression += `.references(() => ${renderPropertyAccess(referencedEntry.varName, targetField)})`; } return ` ${key}: ${expression},`; }); const indexes = mergedIndexFields(tables)[entry.key]?.map((indexSpec) => { const indexArray = Array.isArray(indexSpec) ? [...indexSpec].sort() : [indexSpec]; const indexName = indexArray.join("_"); state.ormImports.add("index"); const fieldsCall = indexArray.map((fieldName) => renderPropertyAccess(entry.varName, fieldName)).join(", "); return `index(${JSON.stringify(indexName)}).on(${fieldsCall})`; }) || []; const extraConfig = indexes.length > 0 ? `,\n (${entry.varName}) => [\n ${indexes.join(",\n ")},\n ]` : ""; tableBlocks.push(`export const ${entry.varName} = convexTable(\n ${JSON.stringify(entry.modelName)},\n {\n${fieldLines.join("\n")}\n }${extraConfig}\n);`); } const imports = `import {\n ${Array.from(state.ormImports).sort().join(",\n ")},\n} from "kitcn/orm";`; const tableObject = `{ ${entries.map((entry) => { if (renderObjectKey(entry.modelName) === entry.varName) return ` ${entry.varName},`; return ` ${renderObjectKey(entry.modelName)}: ${entry.varName},`; }).join("\n")} }`; const relationBlocksByTable = /* @__PURE__ */ new Map(); for (const relation of relationEntries) { const sourceLines = [ ` ${renderObjectKey(relation.oneName)}: ${renderPropertyAccess("r.one", relation.target.modelName)}({`, ` from: ${renderPropertyAccess(renderPropertyAccess("r", relation.source.modelName), relation.sourceFieldName)},`, ` to: ${renderPropertyAccess(renderPropertyAccess("r", relation.target.modelName), relation.targetFieldName)},`, ...relation.alias ? [` alias: ${JSON.stringify(relation.alias)},`] : [], " })," ]; relationBlocksByTable.set(relation.source.modelName, [...relationBlocksByTable.get(relation.source.modelName) ?? [], sourceLines.join("\n")]); const targetLines = [ ` ${renderObjectKey(relation.manyName)}: ${renderPropertyAccess("r.many", relation.source.modelName)}({`, ` from: ${renderPropertyAccess(renderPropertyAccess("r", relation.target.modelName), relation.targetFieldName)},`, ` to: ${renderPropertyAccess(renderPropertyAccess("r", relation.source.modelName), relation.sourceFieldName)},`, ...relation.alias ? [` alias: ${JSON.stringify(relation.alias)},`] : [], " })," ]; relationBlocksByTable.set(relation.target.modelName, [...relationBlocksByTable.get(relation.target.modelName) ?? [], targetLines.join("\n")]); } const relationObjectEntries = entries.map((entry) => { const relationBlocks = relationBlocksByTable.get(entry.modelName); if (!relationBlocks || relationBlocks.length === 0) return null; return ` ${renderObjectKey(entry.modelName)}: {\n${relationBlocks.join("\n")}\n },`; }).filter((value) => value !== null); const relationChain = relationObjectEntries.length > 0 ? `.relations((r) => ({\n${relationObjectEntries.join("\n")}\n}))` : ""; const output = mode === "extension" ? ` export function ${exportName ?? "authExtension"}() { return defineSchemaExtension(${JSON.stringify(extensionKey ?? "auth")}, ${tableObject})${relationChain}; } ` : ` export const tables = ${tableObject}; const schema = defineSchema(tables)${relationChain}; export default schema; `; return { code: `// This file is auto-generated. Do not edit this file manually. // To regenerate the schema, run: // \`${regenerateCommand ?? `npx @better-auth/cli generate --output ${file} -y`}\` ${imports} ${tableBlocks.join("\n\n")} ${output} `, overwrite: true, path: file ?? "./schema.ts" }; }; //#endregion export { createSchemaOrm };