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

275 lines (274 loc) 11.5 kB
import { Kysely } from "kysely"; import { TableDB } from "./table-db.js"; function extractTableMeta(entry) { const definition = entry.definition; const tableName = definition?._tableName ?? "unknown"; const dbFields = new Map(); const clientToDbName = new Map(); const pkFields = []; const clientPkFields = []; const sqlOnlyFields = new Set(); const sqlOnlyClientFields = new Set(); const sqlOnlyRequiredClientFields = new Set(); const sqlOnlyValidators = new Map(); const deriveDependencies = new Map(Object.entries((entry.deriveDependencies ?? {}))); if (!definition) { return { tableName, dbFields, clientToDbName, pkFields, clientPkFields, sqlOnlyFields, sqlOnlyClientFields, sqlOnlyRequiredClientFields, sqlOnlyValidators, deriveDependencies, }; } for (const [key, field] of Object.entries(definition)) { if (key === "_tableName" || key.startsWith("__")) continue; if (typeof field !== "object" || field === null) continue; const config = field.config; if (!config) continue; const sqlConfig = config.sql; if (!sqlConfig) continue; const type = sqlConfig.type; if (type && ["hasMany", "hasOne", "belongsTo", "manyToMany"].includes(type)) continue; const dbName = sqlConfig.field ?? key; const transforms = config.transforms; dbFields.set(key, { dbName, toDb: transforms?.toDb, toClient: transforms?.toClient, }); clientToDbName.set(key, dbName); if (sqlConfig.pk) pkFields.push(dbName); if (sqlConfig.isClientPk && !sqlConfig.isForeignKey) { clientPkFields.push(key); } if (sqlConfig.sqlOnly) { sqlOnlyFields.add(dbName); sqlOnlyClientFields.add(key); if (!sqlConfig.nullable && !Object.prototype.hasOwnProperty.call(sqlConfig, "default") && !Object.prototype.hasOwnProperty.call(sqlConfig, "defaultValue")) { sqlOnlyRequiredClientFields.add(key); } const zodSqlSchema = config.zodSqlSchema; if (zodSqlSchema?.parse) { sqlOnlyValidators.set(key, (val) => zodSqlSchema.parse(val)); } } } return { tableName, dbFields, clientToDbName, pkFields, clientPkFields, sqlOnlyFields, sqlOnlyClientFields, sqlOnlyRequiredClientFields, sqlOnlyValidators, deriveDependencies, }; } function enhanceTable(entry, meta, db) { const transforms = entry.transforms ?? {}; const tableDb = new TableDB(db, meta, { toClient: transforms.toClient ?? ((r) => r), toDb: transforms.toDb ?? ((r) => r), parseForDb: transforms.parseForDb ?? ((r) => r), parsePatchForDb: transforms.parsePatchForDb ?? transforms.toDb ?? ((r) => r), parseFromDb: transforms.parseFromDb ?? ((r) => r), }); return new Proxy(entry, { get(target, prop, receiver) { if (prop in tableDb) { const value = Reflect.get(tableDb, prop, tableDb); return typeof value === "function" ? value.bind(tableDb) : value; } return Reflect.get(target, prop, receiver); }, }); } function registryKeyForTableName(registry, tableName) { return Object.keys(registry).find((key) => registry[key]?.rawSchema?._tableName === tableName); } function dbNameForClientKey(meta, clientKey) { return meta.clientToDbName.get(clientKey) ?? clientKey; } function clientKeyForRelationTarget(targetField, targetDefinition) { const metaKey = targetField?.__meta?._key; if (typeof metaKey === "string") return metaKey; for (const [key, field] of Object.entries(targetDefinition)) { if (field === targetField) return key; } return undefined; } function createViewHydrator(db, registry, baseRegistryKey, selection) { const metas = new Map(); const getMeta = (registryKey) => { let meta = metas.get(registryKey); if (!meta) { const entry = registry[registryKey]; meta = extractTableMeta({ definition: entry?.rawSchema, deriveDependencies: entry?.deriveDependencies, }); metas.set(registryKey, meta); } return meta; }; const hydrate = async (rows, currentRegistryKey, currentSelection) => { if (!rows.length || typeof currentSelection !== "object") return rows; const currentEntry = registry[currentRegistryKey]; if (!currentEntry) return rows; const currentMeta = getMeta(currentRegistryKey); for (const [relationKey, relationSelection] of Object.entries(currentSelection)) { if (!relationSelection) continue; const relationField = currentEntry.rawSchema?.[relationKey]; const relationConfig = relationField?.config?.sql; if (!relationConfig?.schema) continue; const targetTableName = relationConfig.schema()._tableName; const targetRegistryKey = registryKeyForTableName(registry, targetTableName); if (!targetRegistryKey) continue; const targetEntry = registry[targetRegistryKey]; const targetMeta = getMeta(targetRegistryKey); const fromDbName = dbNameForClientKey(currentMeta, relationConfig.fromKey); const targetClientKey = clientKeyForRelationTarget(relationConfig.toKey?.(), targetEntry.rawSchema); if (!targetClientKey) continue; const targetDbName = dbNameForClientKey(targetMeta, targetClientKey); const isCollection = ["hasMany", "manyToMany"].includes(relationConfig.type); const fromValues = rows .map((row) => row[fromDbName]) .filter((v) => v !== undefined && v !== null); if (fromValues.length === 0) { for (const row of rows) { row[relationKey] = isCollection ? [] : null; } continue; } const qb = db; const relatedRows = (await qb .selectFrom(targetMeta.tableName) .selectAll() .where(targetDbName, "in", fromValues) .execute()); const hydratedRelated = await hydrate(relatedRows, targetRegistryKey, relationSelection); const grouped = new Map(); for (const related of hydratedRelated) { const key = related[targetDbName]; if (!grouped.has(key)) grouped.set(key, []); grouped.get(key).push(related); } for (const row of rows) { const fk = row[fromDbName]; const matches = fk !== undefined && fk !== null ? grouped.get(fk) : undefined; row[relationKey] = isCollection ? (matches ?? []) : (matches?.[0] ?? null); } } return rows; }; return (rows) => hydrate(rows, baseRegistryKey, selection); } export function connect(box, db) { const result = {}; for (const key of Object.keys(box)) { const entry = box[key]; if (typeof entry !== "object" || entry === null) { result[key] = entry; continue; } if ("definition" in entry && "schemas" in entry && "transforms" in entry) { const meta = extractTableMeta(entry); result[key] = enhanceTable(entry, meta, db); const originalCreateView = entry.createView; if (originalCreateView) { result[key].createView = (selection) => { const view = originalCreateView(selection); const viewMeta = { ...meta }; const viewTransforms = view.transforms ?? {}; const reconcile = view.reconcile; const registry = view.__registry; const baseTable = view.baseTable; const viewSelection = view.viewSelection; const hydrateRows = registry && baseTable && viewSelection ? createViewHydrator(db, registry, baseTable, viewSelection) : undefined; const viewDb = new TableDB(db, viewMeta, { toClient: viewTransforms.toClient ?? ((r) => r), toDb: viewTransforms.toDb ?? ((r) => r), parseForDb: viewTransforms.parseForDb ?? ((r) => r), parsePatchForDb: viewTransforms.parsePatchForDb ?? viewTransforms.toDb ?? ((r) => r), parseFromDb: viewTransforms.parseFromDb ?? ((r) => r), }, reconcile, hydrateRows); return new Proxy(view, { get(target, prop, receiver) { if (prop in viewDb) { const value = Reflect.get(viewDb, prop, viewDb); return typeof value === "function" ? value.bind(viewDb) : value; } return Reflect.get(target, prop, receiver); }, }); }; } else if (entry.isView) { const viewTransforms = entry.transforms ?? {}; const reconcile = entry.reconcile; const registry = entry.__registry; const baseTable = entry.baseTable; const viewSelection = entry.viewSelection; const hydrateRows = registry && baseTable && viewSelection ? createViewHydrator(db, registry, baseTable, viewSelection) : undefined; const viewDb = new TableDB(db, meta, { toClient: viewTransforms.toClient ?? ((r) => r), toDb: viewTransforms.toDb ?? ((r) => r), parseForDb: viewTransforms.parseForDb ?? ((r) => r), parsePatchForDb: viewTransforms.parsePatchForDb ?? viewTransforms.toDb ?? ((r) => r), parseFromDb: viewTransforms.parseFromDb ?? ((r) => r), }, reconcile, hydrateRows); result[key] = new Proxy(entry, { get(target, prop, receiver) { if (prop in viewDb) { const value = Reflect.get(viewDb, prop, viewDb); return typeof value === "function" ? value.bind(viewDb) : value; } return Reflect.get(target, prop, receiver); }, }); } } else { result[key] = entry; } } const transaction = async (fn) => { return db.transaction().execute(async (trx) => { const txBox = connect(box, trx); return fn(txBox); }); }; result.transaction = transaction; return result; }