kitcn
Version:
kitcn - React Query integration and CLI tools for Convex
246 lines (240 loc) • 11.7 kB
JavaScript
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 };