import { existsSync, readdirSync, statSync } from 'node:fs' import { basename, dirname, relative, resolve, sep } from 'node:path' import * as ts from 'typescript/unstable/ast' import type { NativeTypeScriptProject } from './native-typescript' export type DataNamespace = { name: string instance: string queryPath: string | null modelPath: string | null aggregatePath: string | null table: string | null sourcePaths: string[] } export type DataInstance = { name: string dir: string scope: string | null namespaces: DataNamespace[] tables: string[] syncTables: string[] supportTables: string[] /** `supportTables` declared in `on-zero.config.ts`. */ declaredSupportTables: string[] } export type DataLayout = { instances: DataInstance[] namespaces: DataNamespace[] metadataPaths: string[] sourceRoots: string[] } const isSourceFile = (name: string) => name.endsWith('.ts') && !name.endsWith('.d.ts') && !name.endsWith('.test.ts') && !name.endsWith('.spec.ts') const toImportPath = (baseDir: string, path: string) => relative(baseDir, path).split(sep).join('/').replace(/\.ts$/, '') const isWithin = (root: string, path: string) => { const child = relative(root, path) return child === '' || (child !== '..' && !child.startsWith(`..${sep}`)) } type ParsedInstanceConfig = { name: string dir: string scope: string | null supportTables: string[] } function propertyName(name: ts.PropertyName): string | null { if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) { return name.text } return null } function readDataConfig( project: NativeTypeScriptProject, baseDir: string, configPath: string | undefined ): { path: string; instances: ParsedInstanceConfig[] } | null { const path = configPath ? resolve(configPath) : resolve(baseDir, 'on-zero.config.ts') if (!existsSync(path)) { if (configPath) throw new Error(`[on-zero] config file does not exist: ${path}`) return null } if (dirname(path) !== baseDir) { throw new Error(`[on-zero] ${path} must be at the data root ${baseDir}`) } const source = project.sourceFile(path) if (project.hasParseErrors(path)) throw new Error(`[on-zero] unable to parse ${path}`) let config: ts.ObjectLiteralExpression | null = null for (const statement of source.statements) { if (!ts.isExportAssignment(statement) || statement.isExportEquals) continue const call = statement.expression if ( !ts.isCallExpression(call) || call.expression.getText(source) !== 'defineConfig' ) { continue } const value = call.arguments[0] if (value && ts.isObjectLiteralExpression(value)) config = value } if (!config) { throw new Error( `[on-zero] ${path} must default export defineConfig({ instances: { ... } })` ) } const rootOptions = new Map() for (const option of config.properties) { if (!ts.isPropertyAssignment(option)) { throw new Error(`[on-zero] ${path} options must use explicit property assignments`) } const name = propertyName(option.name) if (!name) throw new Error(`[on-zero] ${path} has an unsupported option name`) if (rootOptions.has(name)) throw new Error(`[on-zero] ${path} repeats option '${name}'`) rootOptions.set(name, option.initializer) } for (const name of rootOptions.keys()) { if (name !== 'instances') throw new Error(`[on-zero] ${path} has unknown option '${name}'`) } const instancesNode = rootOptions.get('instances') if (!instancesNode || !ts.isObjectLiteralExpression(instancesNode)) { throw new Error(`[on-zero] ${path} must declare a non-empty instances object`) } const instances: ParsedInstanceConfig[] = [] for (const property of instancesNode.properties) { if (!ts.isPropertyAssignment(property)) { throw new Error( `[on-zero] ${path} instances must use explicit property assignments` ) } const name = propertyName(property.name) if (!name) throw new Error(`[on-zero] ${path} has an unsupported instance name`) if (!ts.isObjectLiteralExpression(property.initializer)) { throw new Error(`[on-zero] instance '${name}' must be an object`) } if (instances.some((instance) => instance.name === name)) { throw new Error(`[on-zero] duplicate instance name '${name}'`) } let dir = resolve(dirname(path), name) let scope: string | null = null const supportTables: string[] = [] const seen = new Set() for (const option of property.initializer.properties) { if (!ts.isPropertyAssignment(option)) { throw new Error(`[on-zero] instance '${name}' options must be assignments`) } const optionName = propertyName(option.name) if (!optionName) throw new Error(`[on-zero] instance '${name}' has an invalid option`) if (seen.has(optionName)) { throw new Error(`[on-zero] instance '${name}' repeats option '${optionName}'`) } seen.add(optionName) if (optionName === 'dir') { if (!ts.isStringLiteral(option.initializer)) { throw new Error(`[on-zero] instance '${name}' dir must be a string literal`) } if (option.initializer.text.startsWith('/')) { throw new Error(`[on-zero] instance '${name}' dir must be relative to ${path}`) } dir = resolve(dirname(path), option.initializer.text) continue } if (optionName === 'scope') { if (!ts.isStringLiteral(option.initializer)) { throw new Error(`[on-zero] instance '${name}' scope must be a string literal`) } if (!option.initializer.text) { throw new Error(`[on-zero] instance '${name}' scope cannot be empty`) } scope = option.initializer.text continue } if (optionName === 'supportTables') { if (!ts.isArrayLiteralExpression(option.initializer)) { throw new Error(`[on-zero] instance '${name}' supportTables must be an array`) } for (const table of option.initializer.elements) { if (!ts.isStringLiteral(table)) { throw new Error( `[on-zero] instance '${name}' supportTables must contain string literals` ) } if (!table.text) { throw new Error( `[on-zero] instance '${name}' supportTables cannot contain an empty table name` ) } supportTables.push(table.text) } continue } throw new Error(`[on-zero] instance '${name}' has unknown option '${optionName}'`) } if (!existsSync(dir) || !statSync(dir).isDirectory()) { throw new Error(`[on-zero] instance '${name}' directory does not exist: ${dir}`) } instances.push({ name, dir, scope, supportTables }) } if (instances.length === 0) { throw new Error(`[on-zero] ${path} must declare at least one instance`) } for (const instance of instances) { 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}` ) } } return { path, instances } } /** * Which kinds of data exports a single-file namespace declares. * * `model` covers the exports that belong in generated `models.ts`: `mutate`, * `where`, and a `schema` table declaration. `query` covers exported functions * that reach a query builder. The two are reported separately because a * query-only file must NOT enter models — a models entry with no `mutate` makes * `GetZeroMutators` fail its constraint, which silently degrades EVERY * `zero.mutate.*` call site in the app to an untyped error. Folder namespaces * already get this right by construction (no `mutations.ts` means no model). */ type NamespaceExportKinds = { model: boolean; query: boolean } function namespaceExportKinds( project: NativeTypeScriptProject, baseDir: string, path: string ): NamespaceExportKinds { const none: NamespaceExportKinds = { model: false, query: false } const source = project.sourceFile(path) if (project.hasParseErrors(path)) { const displayPath = relative(dirname(baseDir), path).split(sep).join('/') console.warn(`[on-zero] ignoring ${displayPath}: no recognized data exports`) return none } const functions = new Map() const exported = new Set() for (const statement of source.statements) { if (ts.isVariableStatement(statement)) { const isExported = statement.modifiers?.some( (modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword ) for (const declaration of statement.declarationList.declarations) { if (!ts.isIdentifier(declaration.name) || !declaration.initializer) continue const name = declaration.name.text const initializer = declaration.initializer if (isExported && ts.isCallExpression(initializer)) { const initializerText = initializer.getText(source) if ( (name === 'mutate' && initializerText.startsWith('mutations(')) || (name === 'where' && initializerText.startsWith('serverWhere(')) || (name === 'schema' && initializerText.startsWith('table(')) ) { return { model: true, query: false } } } if (ts.isArrowFunction(initializer) || ts.isFunctionExpression(initializer)) { functions.set(name, initializer.body) if (isExported) exported.add(name) } } continue } if (ts.isFunctionDeclaration(statement) && statement.name && statement.body) { functions.set(statement.name.text, statement.body) if ( statement.modifiers?.some( (modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword ) ) { exported.add(statement.name.text) } } } const visiting = new Set() const reachesQuery = (node: ts.Node): boolean => { if (ts.isPropertyAccessExpression(node)) { if (ts.isIdentifier(node.expression) && node.expression.text === 'zql') return true if ( ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'query' ) return true } if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) { const name = node.expression.text const helper = functions.get(name) if (helper && !visiting.has(name)) { visiting.add(name) const found = reachesQuery(helper) visiting.delete(name) if (found) return true } } let found = false node.forEachChild((child) => { if (!found && reachesQuery(child)) found = true }) return found } const query = [...exported].some((name) => reachesQuery(functions.get(name)!)) return { model: false, query } } function mutationTable( project: NativeTypeScriptProject, path: string, namespace: string ): string { const source = project.sourceFile(path) let schemaTable: string | null = null for (const statement of source.statements) { if (!ts.isVariableStatement(statement)) continue if ( !statement.modifiers?.some( (modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword ) ) { continue } for (const declaration of statement.declarationList.declarations) { if (!ts.isIdentifier(declaration.name) || !declaration.initializer) continue if ( declaration.name.text === 'mutate' && ts.isCallExpression(declaration.initializer) ) { const firstArgument = declaration.initializer.arguments[0] if (firstArgument && ts.isStringLiteral(firstArgument)) { return firstArgument.text } } if (declaration.name.text !== 'schema') continue const visit = (node: ts.Node) => { if ( ts.isCallExpression(node) && node.expression.getText(source) === 'table' && node.arguments[0] && ts.isStringLiteral(node.arguments[0]) ) { schemaTable = node.arguments[0].text return } node.forEachChild(visit) } visit(declaration.initializer) } } return schemaTable ?? namespace } function discoverNamespaces( project: NativeTypeScriptProject, baseDir: string, instance: DataInstance, instanceDirs: Set ) { const namespaces: DataNamespace[] = [] for (const entry of readdirSync(instance.dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name) )) { if (entry.isFile()) { if (!isSourceFile(entry.name) || entry.name === 'on-zero.config.ts') continue const path = resolve(instance.dir, entry.name) const kinds = namespaceExportKinds(project, baseDir, path) if (!kinds.model && !kinds.query) continue const name = basename(entry.name, '.ts') namespaces.push({ name, instance: instance.name, queryPath: path, // a query-only file is not a model, matching the folder layout where a // missing mutations.ts leaves modelPath null modelPath: kinds.model ? path : null, aggregatePath: null, table: kinds.model ? mutationTable(project, path, name) : null, sourcePaths: [path], }) continue } if (!entry.isDirectory()) continue const folder = resolve(instance.dir, entry.name) if (entry.name === 'generated' || instanceDirs.has(folder)) continue const queryPath = resolve(folder, 'queries.ts') const modelPath = resolve(folder, 'mutations.ts') const aggregatePath = resolve(folder, 'aggregates.ts') const hasQueries = existsSync(queryPath) const hasMutations = existsSync(modelPath) const hasAggregates = existsSync(aggregatePath) if (!hasQueries && !hasMutations && !hasAggregates) { if ( ['models', 'mutations', 'queries'].includes(entry.name) && readdirSync(folder).some(isSourceFile) ) { throw new Error( `[on-zero] ${folder} uses the removed top-level ${entry.name}/ layout; ` + `move each namespace to .ts or /queries.ts + mutations.ts` ) } continue } namespaces.push({ name: entry.name, instance: instance.name, queryPath: hasQueries ? queryPath : null, modelPath: hasMutations ? modelPath : null, aggregatePath: hasAggregates ? aggregatePath : null, table: hasMutations ? mutationTable(project, modelPath, entry.name) : null, sourcePaths: [ hasQueries && queryPath, hasMutations && modelPath, hasAggregates && aggregatePath, ].filter((path): path is string => Boolean(path)), }) } return namespaces } function metadataPaths(baseDir: string): string[] { const paths: string[] = [] const relations = resolve(baseDir, 'relations.ts') if (existsSync(relations)) paths.push(relations) const databaseDir = resolve(dirname(baseDir), 'database') if (!existsSync(databaseDir)) return paths for (const entry of readdirSync(databaseDir, { withFileTypes: true })) { if (!entry.isFile() || !isSourceFile(entry.name)) continue if ( entry.name === 'relations.ts' || entry.name === 'zeroSchemaInput.ts' || entry.name.startsWith('schema') ) { paths.push(resolve(databaseDir, entry.name)) } } return paths.sort() } function relationTargets( project: NativeTypeScriptProject, paths: string[] ): Map> { const relations = new Map>() for (const path of paths.filter((path) => basename(path) === 'relations.ts')) { const source = project.sourceFile(path) const visit = (node: ts.Node) => { if ( !ts.isCallExpression(node) || node.expression.getText(source) !== 'defineRelations' ) { node.forEachChild(visit) return } const factory = node.arguments[1] if (!factory || (!ts.isArrowFunction(factory) && !ts.isFunctionExpression(factory))) return const body = ts.isParenthesizedExpression(factory.body) ? factory.body.expression : factory.body if (!ts.isObjectLiteralExpression(body)) return for (const tableProperty of body.properties) { if ( !ts.isPropertyAssignment(tableProperty) || !ts.isObjectLiteralExpression(tableProperty.initializer) ) { continue } const table = tableProperty.name.getText(source).replace(/^['"]|['"]$/g, '') const tableRelations = relations.get(table) ?? new Map() for (const relationProperty of tableProperty.initializer.properties) { if ( !ts.isPropertyAssignment(relationProperty) || !ts.isCallExpression(relationProperty.initializer) ) { continue } const expression = relationProperty.initializer.expression if (!ts.isPropertyAccessExpression(expression)) continue const name = relationProperty.name.getText(source).replace(/^['"]|['"]$/g, '') tableRelations.set(name, expression.name.text) } relations.set(table, tableRelations) } } visit(source) } return relations } function tableColumns( project: NativeTypeScriptProject, paths: string[], namespaces: DataNamespace[] ): Map> { const columns = new Map>() const sources = new Set([ ...paths, ...namespaces.flatMap((namespace) => namespace.sourcePaths), ]) for (const path of sources) { const source = project.sourceFile(path) const visit = (node: ts.Node) => { if ( !ts.isVariableDeclaration(node) || !ts.isIdentifier(node.name) || !node.initializer ) { node.forEachChild(visit) return } const tableNames = new Set([node.name.text]) let foundColumns: ts.ObjectLiteralExpression | null = null const inspect = (candidate: ts.Node) => { if (!ts.isCallExpression(candidate)) { candidate.forEachChild(inspect) return } for (const argument of candidate.arguments) { if ( ts.isStringLiteral(argument) && /table/i.test(candidate.expression.getText(source)) ) { tableNames.add(argument.text) } if (ts.isObjectLiteralExpression(argument)) foundColumns = argument } inspect(candidate.expression) } inspect(node.initializer) if (foundColumns) { const names = new Set( (foundColumns as ts.ObjectLiteralExpression).properties .map((property) => ts.isSpreadAssignment(property) ? null : property.name?.getText(source).replace(/^['"]|['"]$/g, '') ) .filter((name): name is string => Boolean(name)) ) for (const tableName of tableNames) columns.set(tableName, names) } node.forEachChild(visit) } visit(source) } return columns } function queriedTables( project: NativeTypeScriptProject, namespace: DataNamespace, relations: Map> ): Array<{ table: string; query: string; root: boolean }> { if (!namespace.queryPath) return [] const source = project.sourceFile(namespace.queryPath) const reached: Array<{ table: string; query: string; root: boolean }> = [] const functions = new Map() const exported = new Set() for (const statement of source.statements) { if (ts.isVariableStatement(statement)) { const isExported = statement.modifiers?.some( (modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword ) for (const declaration of statement.declarationList.declarations) { if ( ts.isIdentifier(declaration.name) && declaration.initializer && (ts.isArrowFunction(declaration.initializer) || ts.isFunctionExpression(declaration.initializer)) ) { functions.set(declaration.name.text, declaration.initializer.body) if (isExported) exported.add(declaration.name.text) } } } else if (ts.isFunctionDeclaration(statement) && statement.name && statement.body) { functions.set(statement.name.text, statement.body) if ( statement.modifiers?.some( (modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword ) ) { exported.add(statement.name.text) } } } const rootTable = (node: ts.Node): string | null => { const text = node.getText(source) return text.match(/(?:\bzql|\.query)\.([A-Za-z_$][\w$]*)/)?.[1] ?? null } const visiting = new Set() const visit = (node: ts.Node, currentTable: string, query: string) => { if ( ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'related' ) { const nameArg = node.arguments[0] if (!nameArg || !ts.isStringLiteral(nameArg)) { throw new Error( `[on-zero] ${namespace.name}.${query} uses related() without a string literal; ` + `sync membership must be statically derivable` ) } const sourceTable = rootTable(node.expression.expression) ?? currentTable const target = relations.get(sourceTable)?.get(nameArg.text) if (!target) { throw new Error( `[on-zero] ${namespace.name}.${query} related('${nameArg.text}') cannot be resolved ` + `from table '${sourceTable}' through relations.ts` ) } reached.push({ table: target, query, root: false }) visit(node.expression.expression, currentTable, query) const callback = node.arguments[1] if ( callback && (ts.isArrowFunction(callback) || ts.isFunctionExpression(callback)) ) { visit(callback.body, target, query) } return } if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) { const helper = functions.get(node.expression.text) const key = `${query}:${node.expression.text}` if (helper && !visiting.has(key)) { visiting.add(key) const helperRoot = rootTable(helper) if (helperRoot) reached.push({ table: helperRoot, query, root: true }) visit(helper, helperRoot ?? currentTable, query) visiting.delete(key) } } node.forEachChild((child) => visit(child, currentTable, query)) } for (const name of exported) { if (['mutate', 'schema', 'where'].includes(name)) continue const body = functions.get(name)! const queryRoot = rootTable(body) if (queryRoot) reached.push({ table: queryRoot, query: name, root: true }) visit(body, queryRoot ?? namespace.name, name) } return reached } function aggregateTables( project: NativeTypeScriptProject, namespace: DataNamespace ): string[] { if (!namespace.aggregatePath) return [] const source = project.sourceFile(namespace.aggregatePath) const unwrap = (node: ts.Expression): ts.Expression => { let current = node while ( ts.isSatisfiesExpression(current) || ts.isAsExpression(current) || ts.isParenthesizedExpression(current) ) { current = current.expression } return current } const tables = new Set() for (const statement of source.statements) { if (!ts.isVariableStatement(statement)) continue if ( !statement.modifiers?.some( (modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword ) ) { continue } for (const declaration of statement.declarationList.declarations) { if ( !ts.isIdentifier(declaration.name) || declaration.name.text !== 'aggregates' || !declaration.initializer ) { continue } const definitions = unwrap(declaration.initializer) if (!ts.isObjectLiteralExpression(definitions)) { throw new Error( `[on-zero] ${namespace.aggregatePath} must export aggregates as an object literal` ) } for (const property of definitions.properties) { if (!ts.isPropertyAssignment(property)) { throw new Error( `[on-zero] ${namespace.aggregatePath} aggregate definitions must use explicit properties` ) } const definition = unwrap(property.initializer) if (!ts.isObjectLiteralExpression(definition)) { throw new Error( `[on-zero] ${namespace.aggregatePath} aggregate ${property.name.getText(source)} must be an object literal` ) } for (const fieldName of ['source', 'target']) { const field = definition.properties.find( (candidate): candidate is ts.PropertyAssignment => ts.isPropertyAssignment(candidate) && propertyName(candidate.name) === fieldName ) if (!field || !ts.isStringLiteral(unwrap(field.initializer))) { throw new Error( `[on-zero] ${namespace.aggregatePath} aggregate ${property.name.getText(source)} ${fieldName} must be a string literal` ) } tables.add((unwrap(field.initializer) as ts.StringLiteral).text) } } } } return [...tables].sort() } function mutationSupportTables( project: NativeTypeScriptProject, baseDir: string, sourceRoots: string[], namespace: DataNamespace ): string[] { if (!namespace.modelPath) return [] const tables = new Set() const visited = new Set() const scan = (path: string) => { if (visited.has(path)) return visited.add(path) const source = project.sourceFile(path) const visit = (node: ts.Node) => { if ( ts.isPropertyAccessExpression(node) && ts.isPropertyAccessExpression(node.expression) && ['mutate', 'query'].includes(node.expression.name.text) ) { const transaction = node.expression.expression if ( (ts.isIdentifier(transaction) && transaction.text === 'tx') || (ts.isPropertyAccessExpression(transaction) && transaction.name.text === 'tx') ) { tables.add(node.name.text) } } if ( (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier) ) { const specifier = node.moduleSpecifier.text const unresolved = specifier.startsWith('~/data/') ? resolve(baseDir, specifier.slice('~/data/'.length)) : specifier.startsWith('.') ? resolve(dirname(path), specifier) : null if (unresolved) { for (const candidate of [ unresolved.endsWith('.ts') ? unresolved : `${unresolved}.ts`, resolve(unresolved, 'index.ts'), ]) { if ( existsSync(candidate) && sourceRoots.some((root) => isWithin(root, candidate)) ) { scan(candidate) break } } } } node.forEachChild(visit) } visit(source) } scan(namespace.modelPath) return [...tables].sort() } function assertNoInstanceFiles(roots: string[]) { const visited = new Set() const walk = (dir: string) => { if (visited.has(dir) || !existsSync(dir)) return visited.add(dir) for (const entry of readdirSync(dir, { withFileTypes: true })) { if (entry.name === 'node_modules' || entry.name === 'generated') continue const path = resolve(dir, entry.name) if (entry.isDirectory()) { walk(path) } else if (entry.isFile() && entry.name === 'instance.ts') { throw new Error( `[on-zero] ${path} uses removed instance.ts configuration; delete it and configure instances in on-zero.config.ts` ) } } } for (const root of roots) walk(root) } function assertNoUnclaimedNamespaces( project: NativeTypeScriptProject, baseDir: string, configPath: string, instanceDirs: string[] ) { const walk = (dir: string) => { if (instanceDirs.some((instanceDir) => isWithin(instanceDir, dir))) return for (const entry of readdirSync(dir, { withFileTypes: true })) { if (entry.name === 'node_modules' || entry.name === 'generated') continue const path = resolve(dir, entry.name) if (entry.isDirectory()) { walk(path) continue } if ( !entry.isFile() || !isSourceFile(entry.name) || path === configPath || entry.name === 'instance.ts' ) { continue } const kinds = namespaceExportKinds(project, baseDir, path) if (kinds.model || kinds.query) { throw new Error( `[on-zero] data namespace ${path} is outside every instance directory declared in ${configPath}` ) } } } walk(baseDir) } export function discoverDataLayout( project: NativeTypeScriptProject, baseDir: string, configPath?: string ): DataLayout { const config = readDataConfig(project, baseDir, configPath) const configured = config?.instances ?? ([ { name: 'default', dir: baseDir, scope: null, supportTables: [] }, ] satisfies ParsedInstanceConfig[]) const sourceRoots = [ ...new Set([baseDir, ...configured.map((instance) => instance.dir)]), ] assertNoInstanceFiles(sourceRoots) if (config) { assertNoUnclaimedNamespaces( project, baseDir, config.path, configured.map((instance) => instance.dir) ) } const instances: DataInstance[] = configured.map((instance) => ({ name: instance.name, dir: instance.dir, scope: instance.scope, namespaces: [], tables: [], syncTables: [], supportTables: [], declaredSupportTables: instance.supportTables, })) const instanceDirs = new Set(instances.map((instance) => instance.dir)) for (const instance of instances) { instance.namespaces = discoverNamespaces(project, baseDir, instance, instanceDirs) } const namespaces = instances.flatMap((instance) => instance.namespaces) const namespaceOwners = new Map() for (const namespace of namespaces) { const owner = namespaceOwners.get(namespace.name) if (owner) { throw new Error( `[on-zero] namespace '${namespace.name}' is claimed by instances '${owner}' and '${namespace.instance}'` ) } namespaceOwners.set(namespace.name, namespace.instance) } const tableOwners = new Map() for (const namespace of namespaces) { if (namespace.table === null) continue const owner = tableOwners.get(namespace.table) if (owner && owner !== namespace.instance) { throw new Error( `[on-zero] table '${namespace.table}' is claimed by instances '${owner}' and '${namespace.instance}'` ) } tableOwners.set(namespace.table, namespace.instance) } const metadata = metadataPaths(baseDir) const relations = relationTargets(project, metadata) const columns = tableColumns(project, metadata, namespaces) const relatedOwners = new Map() for (const instance of instances) { const tables = new Set( instance.namespaces .map((namespace) => namespace.table) .filter((table): table is string => table !== null) ) const syncTables = new Set(tables) for (const namespace of instance.namespaces) { for (const table of aggregateTables(project, namespace)) { const owner = tableOwners.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) tables.add(table) syncTables.add(table) } for (const reached of queriedTables(project, namespace, relations)) { const owner = tableOwners.get(reached.table) ?? relatedOwners.get(reached.table) if (owner && owner !== instance.name) { throw new Error( `[on-zero] ${namespace.name}.${reached.query} in instance '${instance.name}' reaches ` + `table '${reached.table}' owned by instance '${owner}'` ) } relatedOwners.set(reached.table, instance.name) if (reached.root) tables.add(reached.table) syncTables.add(reached.table) } } instance.tables = [...tables].sort() instance.syncTables = [...syncTables].sort() if (instance.scope) { for (const table of instance.syncTables) { if (!columns.get(table)?.has(instance.scope)) { throw new Error( `[on-zero] table '${table}' in instance '${instance.name}' is missing scope column '${instance.scope}'` ) } } } } for (const instance of instances) { // declared entries deliberately bypass the owner guard below: a table owned // by another instance can still be written here (a control transaction that // seeds project-owned rows), and that write has to stay mappable in THIS // instance's change log or every later pull throws on it. const supportTables = new Set(instance.declaredSupportTables) for (const namespace of instance.namespaces) { for (const table of mutationSupportTables( project, baseDir, sourceRoots, namespace )) { if (tableOwners.has(table) || relatedOwners.has(table)) continue if (!instance.syncTables.includes(table)) { supportTables.add(table) } } } instance.supportTables = [...supportTables] .filter((table) => !instance.syncTables.includes(table)) .sort() } return { instances, namespaces, metadataPaths: config ? [...metadata, config.path].sort() : metadata, sourceRoots, } } export function namespaceImportPath(baseDir: string, path: string): string { return `../${toImportPath(baseDir, path)}` }