// browser-safe entry point for the on-zero generator. // // unlike `./generate.ts`, this module: // - does not import `typescript`, `node:fs`, `node:path`, or any node builtin // - does not read or write files (returns a map of filename → content) // - is parser-agnostic: callers supply a `parse` function that extracts the // small amount of ast info on-zero needs (a "lite" ast). callers typically // back that parse function with acorn + acorn-typescript, oxc, swc, or any // other ts-aware parser they already bundle. // - falls back to `v.unknown()` for any parameter whose type annotation text // can't be parsed by on-zero's existing string-based `parseTypeString` // helper. there is no cross-file type resolution. // // this makes the generator runnable inside web workers and other browser // contexts without pulling in ~10mb of typescript. import { CRUD_MUTATION_NAMES, generateAggregatesFile, generateGroupedQueriesFile, generateInstancesFile, generateModelsFile, generateReadmeFile, generateSyncedMutationsFile, generateSyncedQueriesFile, generateTablesFile, generateTypesFile, parseColumnType, parseTypeString, renderDrizzleSchemaInputModule, renderDrizzleZeroSqliteSchemaModule, } from './generate-helpers' import type { ModelMutations, SchemaColumn } from './generate-helpers' // public types // minimal ast info about a namespace mutation module (e.g. `post/mutations.ts`) export type LiteMutationExport = { // the first arg to `mutations('NAME', ...)` — string literal from the source. // this targets membership at the named table while the model file basename // remains the top-level key in syncedMutations. modelName: string // handlers = keys of the handler argument, before any options argument handlers: Array<{ // handler property name, e.g. 'toggleActive' name: string // text of the second parameter's type annotation, if present. // e.g. '{ id: string; isActive: boolean }' or 'ToggleArgs' or null. // for inline type literals parseable by `parseTypeString`, on-zero emits // real v.object(...) validators. for references/generics/null, it falls // back to `v.unknown()`. paramTypeText: string | null }> // for models that declare `export const schema = table(...).columns(...)`, // the caller extracts the table name + column builder text. null if the // model doesn't declare a schema inline (most templates use drizzle-zero, // so null is common). schema: LiteSchemaInfo | null // the `crud` field of the fourth options argument, i.e. false for // `mutations(table, permissions, handlers, { crud: false })`. omitted or // undefined means the default, which generates the full crud slot set. // permissions stay registered either way, so this only controls the // generated insert/update/delete/upsert validators. crud?: boolean } export type LiteSchemaInfo = { tableName: string primaryKeys: string[] // per-column: name + the column builder chain as source text, e.g. // `"string().optional()"`. `parseColumnType` turns these into SchemaColumn. columns: Array<{ name: string; builderText: string }> } export type LiteQueryExport = { // exported variable name, e.g. 'flightById' name: string // table at the root of the returned query builder, e.g. `post` for // `zql.post.where(...)`. query namespaces may group queries for another // table, so this cannot be inferred from the source filename. rootTable: string // text of the first parameter's type annotation, if present. // e.g. 'string' | '{ id: string }' | null. null means the query takes no // args and is treated as a void query in the generated output. paramTypeText: string | null // relation-name paths starting at rootTable. nested related() calls are one // path, e.g. [['comments', 'author']]. relatedPaths: string[][] } export type LiteRelationInfo = { sourceTable: string name: string targetTable: string // original property text from defineRelations, used to emit drizzleSchema.ts sourceText?: string } export type LiteTableInfo = { name: string columns: string[] } export type LiteDataConfig = { instances: Record< string, { dir?: string scope?: string supportTables?: string[] } > } // what the caller returns for each source file. export type LiteParsedFile = { // a model file exports at most one `mutate = mutations(...)`, but an array // keeps the shape uniform and leaves room for future multi-export support. mutations: LiteMutationExport[] queries: LiteQueryExport[] relations?: LiteRelationInfo[] tables?: LiteTableInfo[] // source and target table names declared by exported aggregate definitions. aggregateTables?: string[] // static table names reached through tx.mutate. or tx.query.
. // element access such as tx.mutate[name] is intentionally omitted. supportTables?: string[] // static import/export module specifiers used to follow mutation helpers. imports?: string[] // present only for an on-zero.config.ts default defineConfig export. dataConfig?: LiteDataConfig // parser syntax failure. the membership pass warns and ignores this file. parseError?: string } // the parser function signature the caller provides. pure: given source text // and a path (for error messages), return the lite ast. export type LiteParseFn = (sourceCode: string, filePath: string) => LiteParsedFile export type LiteGenerateOptions = { // file path → source content. pass whatever path shape you have (absolute, // virtual, etc.), as long as you're consistent with `dir`. files: Record // base data directory, e.g. '/proj/src/data'. namespaces are direct .ts // files or folders with queries.ts / mutations.ts. dir: string // explicit on-zero.config.ts path. auto-discovered in `dir` when omitted. config?: string parse: LiteParseFn } export type LiteGenerateResult = { // relative paths under `{dir}/generated/`, e.g. 'models.ts', // 'syncedMutations.ts'. callers decide where to write. files: Record // per-instance table membership, exactly as serialized into instances.ts. // callers that need membership (e.g. a host deriving its Zero schema tables) // read it here instead of re-parsing the emitted source. instances: Array<{ name: string; syncTables: string[]; supportTables: string[] }> modelCount: number queryCount: number mutationCount: number schemaCount: number } // path helpers — deliberately string-only, no node:path function stripTrailingSlash(s: string): string { return s.endsWith('/') ? s.slice(0, -1) : s } // returns the last '/' segment, stripped of a trailing `.ts` if present. // does not depend on node:path.basename. function baseName(path: string, ext?: string): string { const idx = path.lastIndexOf('/') let base = idx >= 0 ? path.slice(idx + 1) : path if (ext && base.endsWith(ext)) base = base.slice(0, -ext.length) return base } function resolvePath(path: string): string { const absolute = path.startsWith('/') const parts: string[] = [] for (const part of path.split('/')) { if (!part || part === '.') continue if (part === '..') parts.pop() else parts.push(part) } return `${absolute ? '/' : ''}${parts.join('/')}` } function parentDir(path: string): string { return path.slice(0, path.lastIndexOf('/')) } function relativePath(from: string, to: string): string { const fromParts = resolvePath(from).split('/').filter(Boolean) const toParts = resolvePath(to).split('/').filter(Boolean) let common = 0 while (fromParts[common] === toParts[common] && common < fromParts.length) common++ return [ ...Array.from({ length: fromParts.length - common }, () => '..'), ...toParts.slice(common), ].join('/') } const isWithin = (root: string, path: string) => path === root || path.startsWith(`${root}/`) // returns files whose path is an immediate child of `dirPrefix` and ends in // `.ts` (but not `.d.ts`, test files, or anything nested further down). function listDirectTsFiles(files: Record, dirPrefix: string): string[] { const prefix = stripTrailingSlash(dirPrefix) + '/' const out: string[] = [] for (const path of Object.keys(files)) { if (!path.startsWith(prefix)) continue const rest = path.slice(prefix.length) // must be a direct child (no further slashes) if (rest.includes('/')) continue if (!rest.endsWith('.ts')) continue if (rest.endsWith('.d.ts')) continue if (rest.endsWith('.test.ts') || rest.endsWith('.spec.ts')) continue out.push(path) } return out.sort() } type LiteNamespace = { name: string instance: string queryPath: string | null modelPath: string | null aggregatePath: string | null } type LiteInstance = { name: string dir: string scope: string | null declaredSupportTables: string[] namespaces: LiteNamespace[] } function discoverLiteLayout( files: Record, baseDir: string, parse: LiteParseFn, explicitConfigPath?: string ) { const paths = Object.keys(files) const configPath = resolvePath(explicitConfigPath ?? `${baseDir}/on-zero.config.ts`) const configSource = files[configPath] let config: LiteDataConfig | undefined if (configSource !== undefined) { const parsed = parse(configSource, configPath) if (parsed.parseError) { throw new Error(`[on-zero] unable to parse ${configPath}: ${parsed.parseError}`) } config = parsed.dataConfig if (!config) { throw new Error( `[on-zero] ${configPath} must default export defineConfig({ instances: { ... } })` ) } } else if (explicitConfigPath) { throw new Error(`[on-zero] config file does not exist: ${configPath}`) } if (explicitConfigPath && parentDir(configPath) !== baseDir) { throw new Error(`[on-zero] ${configPath} must be at the data root ${baseDir}`) } const instances: LiteInstance[] = config ? Object.entries(config.instances).map(([name, options]) => { if (options.dir?.startsWith('/')) { throw new Error( `[on-zero] instance '${name}' dir must be relative to ${configPath}` ) } if (options.scope === '') { throw new Error(`[on-zero] instance '${name}' scope cannot be empty`) } if (options.supportTables?.some((table) => table === '')) { throw new Error( `[on-zero] instance '${name}' supportTables cannot contain an empty table name` ) } return { name, dir: resolvePath(`${parentDir(configPath)}/${options.dir ?? name}`), scope: options.scope ?? null, declaredSupportTables: [...(options.supportTables ?? [])], namespaces: [], } }) : [ { name: 'default', dir: baseDir, scope: null, declaredSupportTables: [], namespaces: [], }, ] if (config && instances.length === 0) { throw new Error(`[on-zero] ${configPath} must declare at least one instance`) } for (const instance of instances) { if (!paths.some((path) => isWithin(instance.dir, path))) { throw new Error( `[on-zero] instance '${instance.name}' directory does not exist: ${instance.dir}` ) } const duplicate = instances.find( (candidate) => candidate !== instance && candidate.dir === instance.dir ) if (duplicate) { throw new Error( `[on-zero] instances '${instance.name}' and '${duplicate.name}' resolve to the same directory: ${instance.dir}` ) } } const instanceDirs = new Set(instances.map((instance) => instance.dir)) const sourceRoots = [baseDir, ...instances.map((instance) => instance.dir)] const remnant = paths.find( (path) => baseName(path) === 'instance.ts' && sourceRoots.some((root) => isWithin(root, path)) ) if (remnant) { throw new Error( `[on-zero] ${remnant} uses removed instance.ts configuration; delete it and configure instances in on-zero.config.ts` ) } const parseDataFile = (path: string): LiteParsedFile | null => { let parsed: LiteParsedFile try { parsed = parse(files[path]!, path) } catch { console.warn( `[on-zero] ignoring ${path.slice(baseDir.lastIndexOf('/') + 1)}: no recognized data exports` ) return null } if (parsed.parseError) { console.warn( `[on-zero] ignoring ${path.slice(baseDir.lastIndexOf('/') + 1)}: no recognized data exports` ) return null } return parsed } const hasDataExport = (source: string, parsed: LiteParsedFile) => parsed.queries.length > 0 || parsed.mutations.length > 0 || (parsed.tables?.length ?? 0) > 0 || /export\s+const\s+(?:mutate|schema|where)\s*=\s*(?:mutations|serverWhere|table)\s*\(/.test( source ) if (config) { for (const path of paths) { if ( path === configPath || !isWithin(baseDir, path) || instances.some((instance) => isWithin(instance.dir, path)) || isWithin(`${baseDir}/generated`, path) || !path.endsWith('.ts') || path.endsWith('.d.ts') || path.endsWith('.test.ts') || path.endsWith('.spec.ts') || baseName(path) === 'instance.ts' ) { continue } const parsed = parseDataFile(path) if (parsed && hasDataExport(files[path]!, parsed)) { throw new Error( `[on-zero] data namespace ${path} is outside every instance directory declared in ${configPath}` ) } } } for (const instance of instances) { const directFiles = listDirectTsFiles(files, instance.dir).filter( (path) => baseName(path) !== 'on-zero.config.ts' ) for (const path of directFiles) { const source = files[path]! const parsed = parseDataFile(path) if (!parsed || !hasDataExport(source, parsed)) continue instance.namespaces.push({ name: baseName(path, '.ts'), instance: instance.name, queryPath: path, modelPath: path, aggregatePath: null, }) } const prefix = `${instance.dir}/` const folders = new Set() for (const path of paths) { if (!path.startsWith(prefix)) continue const rest = path.slice(prefix.length) const slash = rest.indexOf('/') if (slash > 0) folders.add(`${instance.dir}/${rest.slice(0, slash)}`) } for (const folder of [...folders].sort()) { if (folder === `${baseDir}/generated` || instanceDirs.has(folder)) continue const queries = `${folder}/queries.ts` const mutations = `${folder}/mutations.ts` const aggregates = `${folder}/aggregates.ts` if (!(queries in files) && !(mutations in files) && !(aggregates in files)) { const oldName = baseName(folder) if ( ['models', 'mutations', 'queries'].includes(oldName) && listDirectTsFiles(files, folder).length > 0 ) { throw new Error( `[on-zero] ${folder} uses the removed top-level ${oldName}/ layout; ` + `move each namespace to .ts or /queries.ts + mutations.ts` ) } continue } instance.namespaces.push({ name: baseName(folder), instance: instance.name, queryPath: queries in files ? queries : null, modelPath: mutations in files ? mutations : null, aggregatePath: aggregates in files ? aggregates : null, }) } } const owners = new Map() for (const namespace of instances.flatMap((instance) => instance.namespaces)) { const owner = owners.get(namespace.name) if (owner) { throw new Error( `[on-zero] namespace '${namespace.name}' is claimed by instances '${owner}' and '${namespace.instance}'` ) } owners.set(namespace.name, namespace.instance) } return instances } // main entry point export function generateLite(opts: LiteGenerateOptions): LiteGenerateResult { const { files, parse } = opts const baseDir = resolvePath(stripTrailingSlash(opts.dir)) const instances = discoverLiteLayout(files, baseDir, parse, opts.config) const sourceRoots = [baseDir, ...instances.map((instance) => instance.dir)] const namespaces = instances.flatMap((instance) => instance.namespaces) const modelNamespaces = namespaces.filter( (namespace): namespace is LiteNamespace & { modelPath: string } => namespace.modelPath !== null ) const aggregateTables = new Map() for (const namespace of namespaces) { if (!namespace.aggregatePath) continue const parsed = parse(files[namespace.aggregatePath]!, namespace.aggregatePath) if (parsed.parseError) { throw new Error( `[on-zero] unable to derive aggregate membership from ${namespace.aggregatePath}: ${parsed.parseError}` ) } aggregateTables.set(namespace.name, [...new Set(parsed.aggregateTables ?? [])]) } const relations = new Map>() const drizzleRelations: LiteRelationInfo[] = [] const tableColumns = new Map>() for (const path of Object.keys(files).filter( (path) => path.endsWith('/relations.ts') || /\/database\/(?:schema[^/]*|zeroSchemaInput)\.ts$/.test(path) )) { const parsed = parse(files[path]!, path) for (const relation of parsed.relations ?? []) { const tableRelations = relations.get(relation.sourceTable) ?? new Map() tableRelations.set(relation.name, relation.targetTable) relations.set(relation.sourceTable, tableRelations) drizzleRelations.push(relation) } for (const table of parsed.tables ?? []) { tableColumns.set(table.name, new Set(table.columns)) } } // parse each model file and build ModelMutations records for the emitter const allModelMutations: ModelMutations[] = [] const modelNamesWithSchema: string[] = [] const modelTables = new Map() for (const namespace of modelNamespaces) { const filePath = namespace.modelPath const modelName = namespace.name const content = files[filePath]! const parsed = parse(content, filePath) // a model file has at most one mutate export, but the lite ast is an array const mutationExport = parsed.mutations[0] ?? null modelTables.set( modelName, mutationExport?.modelName || mutationExport?.schema?.tableName || modelName ) // extract schema info if present const columns: Record = {} const primaryKeys: string[] = [] let hasSchema = false if (mutationExport?.schema) { hasSchema = true modelNamesWithSchema.push(modelName) for (const pk of mutationExport.schema.primaryKeys) primaryKeys.push(pk) for (const col of mutationExport.schema.columns) { columns[col.name] = parseColumnType(col.builderText) } const names = new Set(mutationExport.schema.columns.map((column) => column.name)) tableColumns.set(modelName, names) tableColumns.set(mutationExport.schema.tableName, names) } // a model participates in crud only when it has a schema AND its mutate // call did not opt out with `{ crud: false }`. in the lite ast, we don't // know the call arity directly, so we use the presence of a schema as the // signal for a table registration. this matches the real generator's // behavior for schemas-with-mutate: hasCRUD is true whenever both exist. // // models without `export const mutate` still appear in the output as // empty entries — see the test "treats models without export const mutate // as empty mutations". const hasCRUD = hasSchema && mutationExport !== null && mutationExport.crud !== false const custom = (mutationExport?.handlers ?? []).map((h) => { // null or empty annotation → void (no second param) → v.void_() if (h.paramTypeText == null) { return { name: h.name, paramType: 'void', valibotCode: '' } } const paramType = h.paramTypeText.trim() // unknown explicitly → permissive validator if (paramType === 'unknown') { return { name: h.name, paramType: 'unknown', valibotCode: '' } } // try the pure string parser. if it fails, fall back to v.unknown() // (no cross-file type resolution in lite mode). let valibotCode: string | null = null try { valibotCode = parseTypeString(paramType) } catch { valibotCode = null } return { name: h.name, paramType, valibotCode: valibotCode ?? 'v.unknown()', } }) allModelMutations.push({ modelName, hasCRUD, columns, primaryKeys, custom, }) } // parse each query file const allQueries: Array<{ name: string params: string valibotCode: string sourceFile: string importPath: string }> = [] const queryPaths = new Map< string, Array<{ query: string; rootTable: string; paths: string[][] }> >() for (const namespace of namespaces.filter( (namespace): namespace is LiteNamespace & { queryPath: string } => namespace.queryPath !== null )) { const filePath = namespace.queryPath const fileBaseName = namespace.name const parsed = parse(files[filePath]!, filePath) for (const q of parsed.queries) { if (['mutate', 'permission', 'schema', 'where'].includes(q.name)) continue if (!q.rootTable || !Array.isArray(q.relatedPaths)) { throw new Error( `[on-zero] ${filePath} query '${q.name}' is missing lite parser query metadata` ) } const entries = queryPaths.get(namespace.name) ?? [] entries.push({ query: q.name, rootTable: q.rootTable, paths: q.relatedPaths, }) queryPaths.set(namespace.name, entries) // null annotation → no first arg → void query if (q.paramTypeText == null) { allQueries.push({ name: q.name, params: 'void', valibotCode: '', sourceFile: fileBaseName, importPath: `../${relativePath(baseDir, filePath).replace(/\.ts$/, '')}`, }) continue } const paramType = q.paramTypeText.trim() // try to parse the annotation. if we can't, fall back to v.unknown() // so the query still makes it into the output (consistent with // mutations). the existing node generator silently drops queries // whose types can't be resolved; in lite mode we emit them with a // permissive validator rather than losing them. let valibotCode: string | null = null try { valibotCode = parseTypeString(paramType) } catch { valibotCode = null } allQueries.push({ name: q.name, params: paramType, valibotCode: valibotCode ?? 'v.unknown()', sourceFile: fileBaseName, importPath: `../${relativePath(baseDir, filePath).replace(/\.ts$/, '')}`, }) } } const owners = new Map() for (const namespace of modelNamespaces) { const table = modelTables.get(namespace.name)! const owner = owners.get(table) if (owner && owner !== namespace.instance) { throw new Error( `[on-zero] table '${table}' is claimed by instances '${owner}' and '${namespace.instance}'` ) } owners.set(table, namespace.instance) } const relatedOwners = new Map() const directTables = new Map() const syncTables = new Map() for (const instance of instances) { const direct = new Set( instance.namespaces .filter((namespace) => namespace.modelPath !== null) .map((namespace) => modelTables.get(namespace.name)!) ) const synced = new Set(direct) for (const namespace of instance.namespaces) { for (const table of aggregateTables.get(namespace.name) ?? []) { const owner = owners.get(table) ?? relatedOwners.get(table) if (owner && owner !== instance.name) { throw new Error( `[on-zero] ${namespace.name} aggregates in instance '${instance.name}' reach ` + `table '${table}' owned by instance '${owner}'` ) } relatedOwners.set(table, instance.name) direct.add(table) synced.add(table) } for (const query of queryPaths.get(namespace.name) ?? []) { const rootOwner = owners.get(query.rootTable) ?? relatedOwners.get(query.rootTable) if (rootOwner && rootOwner !== instance.name) { throw new Error( `[on-zero] ${namespace.name}.${query.query} in instance '${instance.name}' reaches ` + `table '${query.rootTable}' owned by instance '${rootOwner}'` ) } relatedOwners.set(query.rootTable, instance.name) direct.add(query.rootTable) synced.add(query.rootTable) for (const path of query.paths) { let sourceTable = query.rootTable for (const relationName of path) { const target = relations.get(sourceTable)?.get(relationName) if (!target) { throw new Error( `[on-zero] ${namespace.name}.${query.query} related('${relationName}') cannot be resolved ` + `from table '${sourceTable}' through relations.ts` ) } const owner = owners.get(target) ?? relatedOwners.get(target) if (owner && owner !== instance.name) { throw new Error( `[on-zero] ${namespace.name}.${query.query} in instance '${instance.name}' reaches ` + `table '${target}' owned by instance '${owner}'` ) } relatedOwners.set(target, instance.name) synced.add(target) sourceTable = target } } } } directTables.set(instance.name, [...direct].sort()) const tables = [...synced].sort() if (instance.scope) { for (const table of tables) { if (!tableColumns.get(table)?.has(instance.scope)) { throw new Error( `[on-zero] table '${table}' in instance '${instance.name}' is missing scope column '${instance.scope}'` ) } } } syncTables.set(instance.name, tables) } const supportTables = new Map() for (const instance of instances) { const supported = new Set(instance.declaredSupportTables) for (const namespace of instance.namespaces) { if (!namespace.modelPath) continue const visited = new Set() const scan = (path: string) => { if (visited.has(path)) return visited.add(path) const parsed = parse(files[path]!, path) if (parsed.parseError) { throw new Error( `[on-zero] unable to derive mutation dependencies from ${path}: ${parsed.parseError}` ) } for (const table of parsed.supportTables ?? []) { if (owners.has(table) || relatedOwners.has(table)) continue if (!syncTables.get(instance.name)!.includes(table)) { supported.add(table) } } for (const specifier of parsed.imports ?? []) { const unresolved = specifier.startsWith('~/data/') ? `${baseDir}/${specifier.slice('~/data/'.length)}` : specifier.startsWith('.') ? `${parentDir(path)}/${specifier}` : null if (!unresolved) continue const normalized = resolvePath(unresolved) const dependency = [ normalized.endsWith('.ts') ? normalized : `${normalized}.ts`, `${normalized}/index.ts`, ].find( (candidate) => candidate in files && sourceRoots.some((root) => isWithin(root, candidate)) ) if (dependency) scan(dependency) } } scan(namespace.modelPath) } supportTables.set(instance.name, [...supported].sort()) } // emit files const modelNames = modelNamespaces.map((namespace) => namespace.name) const out: Record = {} out['models.ts'] = generateModelsFile( modelNamespaces.map((namespace) => ({ name: namespace.name, importPath: `../${relativePath(baseDir, namespace.modelPath).replace(/\.ts$/, '')}`, })) ) if (modelNamesWithSchema.length > 0) { out['types.ts'] = generateTypesFile(modelNamesWithSchema) out['tables.ts'] = generateTablesFile( modelNamespaces .filter((namespace) => modelNamesWithSchema.includes(namespace.name)) .map((namespace) => ({ name: namespace.name, importPath: `../${relativePath(baseDir, namespace.modelPath).replace(/\.ts$/, '')}`, })) ) } out['README.md'] = generateReadmeFile() out['groupedQueries.ts'] = generateGroupedQueriesFile(allQueries) out['syncedQueries.ts'] = generateSyncedQueriesFile(allQueries) out['instances.ts'] = generateInstancesFile( instances.map((instance) => ({ name: instance.name, scope: instance.scope, queryNames: instance.namespaces .map((namespace) => namespace.name) .filter((name) => allQueries.some((query) => query.sourceFile === name)), modelNames: instance.namespaces .filter((namespace) => namespace.modelPath) .map((namespace) => namespace.name), tables: directTables.get(instance.name)!, syncTables: syncTables.get(instance.name)!, supportTables: supportTables.get(instance.name)!, })) ) if (allModelMutations.length > 0) { out['syncedMutations.ts'] = generateSyncedMutationsFile(allModelMutations) } const aggregateNamespaces = namespaces.filter( (namespace): namespace is LiteNamespace & { aggregatePath: string } => namespace.aggregatePath !== null ) if (aggregateNamespaces.length > 0) { out['aggregates.ts'] = generateAggregatesFile( aggregateNamespaces.map((namespace) => ({ name: namespace.name, importPath: `../${relativePath(baseDir, namespace.aggregatePath).replace(/\.ts$/, '')}`, })) ) } const allTables = [ ...new Set( instances.flatMap((instance) => [ ...(syncTables.get(instance.name) ?? []), ...(supportTables.get(instance.name) ?? []), ]) ), ].sort() const hasDatabaseSchema = Object.keys(files).some((path) => /\/database\/schema\.ts$/.test(path) ) if (hasDatabaseSchema && allTables.length > 0) { const included = new Set(allTables) const relationTables = new Map() for (const relation of drizzleRelations) { if (!included.has(relation.sourceTable) || !included.has(relation.targetTable)) { continue } const properties = relationTables.get(relation.sourceTable) ?? [] properties.push( relation.sourceText ?? `${relation.name}: r.many.${relation.targetTable}({})` ) relationTables.set(relation.sourceTable, properties) } out['drizzleSchema.ts'] = renderDrizzleSchemaInputModule({ schemaImportPath: '../../database/schema', tableNames: allTables, relationTables: [...relationTables.entries()].map(([key, properties]) => ({ key, properties, })), }) out['schema.ts'] = renderDrizzleZeroSqliteSchemaModule({ importPath: './drizzleSchema', tableNames: allTables, }) } // count mutations the same way `generate()` does: one per generated crud slot // plus non-crud custom mutations (the crud-ops set is excluded when hasCRUD). let mutationCount = 0 for (const m of allModelMutations) { if (m.hasCRUD) mutationCount += CRUD_MUTATION_NAMES.length mutationCount += m.custom.filter( (mut) => !m.hasCRUD || !CRUD_MUTATION_NAMES.some((name) => name === mut.name) ).length } return { files: out, instances: instances.map((instance) => ({ name: instance.name, syncTables: syncTables.get(instance.name)!, supportTables: supportTables.get(instance.name)!, })), modelCount: modelNames.length, queryCount: allQueries.length, mutationCount, schemaCount: modelNamesWithSchema.length, } }