diff --git a/packages/cli/src/actions/action-utils.ts b/packages/cli/src/actions/action-utils.ts index 16fae2e15..c9b0aefd7 100644 --- a/packages/cli/src/actions/action-utils.ts +++ b/packages/cli/src/actions/action-utils.ts @@ -382,3 +382,37 @@ export function startUsageTipsFetch() { } }; } + +export function isPackageInstalled(pkgName: string): boolean { + try { + const projectRequire = createRequire(path.resolve(process.cwd(), 'package.json')); + projectRequire.resolve(pkgName); + return true; + } catch { + return false; + } +} + +export async function loadPackage(pkgName: string) { + try { + return await import(pkgName); + } catch (importErr) { + // If zenstack CLI is running directly using npx/pnpm in a temp folder, + // dynamic package import will fail. Also check the current project folder. + try { + const projectRequire = createRequire(path.resolve(process.cwd(), 'package.json')); + const resolvedPath = projectRequire.resolve(pkgName); + return await import(pathToFileURL(resolvedPath).href); + } catch (resolvedImportErr) { + try { + const projectRequire = createRequire(path.resolve(process.cwd(), 'package.json')); + return projectRequire(pkgName); + } catch (requireErr) { + if (process.env['DEBUG']) { + console.error(importErr, resolvedImportErr, requireErr); + } + return null; + } + } + } +} diff --git a/packages/cli/src/actions/db.ts b/packages/cli/src/actions/db.ts index c8e0f8204..b2546de3d 100644 --- a/packages/cli/src/actions/db.ts +++ b/packages/cli/src/actions/db.ts @@ -14,7 +14,14 @@ import { } from './action-utils'; import { consolidateEnums, syncEnums, syncRelation, syncTable, type Relation } from './pull'; import { providers as pullProviders } from './pull/provider'; -import { getDatasource, getDbName, getRelationFieldsKey, getRelationFkName, getRelationName, isDatabaseManagedAttribute } from './pull/utils'; +import { + getDatasource, + getDbName, + getRelationFieldsKey, + getRelationFkName, + getRelationName, + isDatabaseManagedAttribute, +} from './pull/utils'; import type { DataSourceProviderType } from '@zenstackhq/schema'; import { CliError } from '../cli-error'; @@ -28,6 +35,8 @@ type PushOptions = { export type PullOptions = { schema?: string; output?: string; + databaseUrl?: string; + provider?: DataSourceProviderType; modelCasing: 'pascal' | 'camel' | 'snake' | 'none'; fieldCasing: 'pascal' | 'camel' | 'snake' | 'none'; alwaysMap: boolean; @@ -101,7 +110,7 @@ async function runPush(options: PushOptions) { } } -async function runPull(options: PullOptions) { +export async function runPull(options: PullOptions) { const spinner = ora(); try { const schemaFile = getSchemaFile(options.schema); @@ -109,16 +118,40 @@ async function runPull(options: PullOptions) { // Determine early if `--out` is a single file output (combined schema) or a directory export. const outPath = options.output ? path.resolve(options.output) : undefined; const treatAsFile = - !!outPath && - ((fs.existsSync(outPath) && fs.lstatSync(outPath).isFile()) || path.extname(outPath) !== ''); + !!outPath && ((fs.existsSync(outPath) && fs.lstatSync(outPath).isFile()) || path.extname(outPath) !== ''); const { model, services } = await loadSchemaDocument(schemaFile, { returnServices: true, mergeImports: treatAsFile, }); + // When provider/databaseUrl are passed explicitly (e.g. from --introspect), + // use those directly instead of reading from the parsed datasource declaration. + let datasource: { + provider: DataSourceProviderType; + url: string; + defaultSchema: string; + allSchemas: string[]; + }; + + if (options.provider && options.databaseUrl) { + let databaseUrl = options.databaseUrl; + // Handle SQLite file URLs for local files + if (options.provider === 'sqlite' && databaseUrl.startsWith('file:')) { + databaseUrl = new URL(databaseUrl, `file:${model.$document!.uri.path}`).pathname; + if (process.platform === 'win32' && databaseUrl[0] === '/') databaseUrl = databaseUrl.slice(1); + } + datasource = { + provider: options.provider, + url: databaseUrl, + defaultSchema: 'public', + allSchemas: ['public'], + }; + } else { + datasource = getDatasource(model); + } + const SUPPORTED_PROVIDERS = Object.keys(pullProviders) as DataSourceProviderType[]; - const datasource = getDatasource(model); if (!SUPPORTED_PROVIDERS.includes(datasource.provider)) { throw new CliError(`Unsupported datasource provider: ${datasource.provider}`); @@ -131,7 +164,10 @@ async function runPull(options: PullOptions) { } spinner.start('Introspecting database...'); - const { enums, tables } = await provider.introspect(datasource.url, { schemas: datasource.allSchemas, modelCasing: options.modelCasing }); + const { enums, tables } = await provider.introspect(datasource.url, { + schemas: datasource.allSchemas, + modelCasing: options.modelCasing, + }); spinner.succeed('Database introspected'); console.log(colors.blue('Syncing schema...')); @@ -170,8 +206,7 @@ async function runPull(options: PullOptions) { } // Normalize schema values: MySQL uses null for references.schema but '' for table.schema; // treat null, undefined, and '' as equivalent when comparing schemas. - const schemasMatch = (a: string | null | undefined, b: string | null | undefined) => - (a ?? '') === (b ?? ''); + const schemasMatch = (a: string | null | undefined, b: string | null | undefined) => (a ?? '') === (b ?? ''); // sync relation fields for (const relation of resolvedRelations) { @@ -276,11 +311,13 @@ async function runPull(options: PullOptions) { .filter((d) => d.$type === DataModel.$type || d.$type === Enum.$type) .forEach((_declaration) => { const newDataModel = _declaration as DataModel | Enum; - const declarations = services.shared.workspace.IndexManager.allElements(newDataModel.$type, docsSet).toArray(); + const declarations = services.shared.workspace.IndexManager.allElements( + newDataModel.$type, + docsSet, + ).toArray(); const originalDataModel = declarations.find((d) => getDbName(d.node as any) === getDbName(newDataModel)) ?.node as DataModel | Enum | undefined; if (!originalDataModel) { - if (newDataModel.$type === 'DataModel') { addedModels.push(colors.green(`+ Model ${newDataModel.name} added`)); } else if (newDataModel.$type === 'Enum') { @@ -339,9 +376,7 @@ async function runPull(options: PullOptions) { if (newRelName) { originalFields = originalDataModel.fields.filter( (d) => - isDataField(d) && - isDataField(f) && - matchesRelationNameFallback(f, newRelName, d), + isDataField(d) && isDataField(f) && matchesRelationNameFallback(f, newRelName, d), ); } } @@ -395,7 +430,9 @@ async function runPull(options: PullOptions) { const newRefName = getDbName(newType.reference.ref); const oldRefName = getDbName(oldType.reference.ref); if (newRefName !== oldRefName) { - fieldUpdates.push(`reference: ${oldType.reference.$refText} -> ${newType.reference.$refText}`); + fieldUpdates.push( + `reference: ${oldType.reference.$refText} -> ${newType.reference.$refText}`, + ); // Replace the entire reference object — Langium References // from parsed documents expose `ref` as a getter-only property. (oldType as any).reference = { @@ -444,21 +481,28 @@ async function runPull(options: PullOptions) { if (newDefaultAttr && oldDefaultAttr) { // Compare attribute arguments by serializing them (avoid circular refs with $type fallback) const serializeArgs = (args: any[]) => - args.map((arg) => { - if (arg.value?.$type === 'StringLiteral') return `"${arg.value.value}"`; - if (arg.value?.$type === 'NumberLiteral') return String(arg.value.value); - if (arg.value?.$type === 'BooleanLiteral') return String(arg.value.value); - if (arg.value?.$type === 'InvocationExpr') return arg.value.function?.$refText ?? ''; - if (arg.value?.$type === 'ReferenceExpr') return arg.value.target?.$refText ?? ''; - if (arg.value?.$type === 'ArrayExpr') { - return `[${(arg.value.items ?? []).map((item: any) => { - if (item.$type === 'ReferenceExpr') return item.target?.$refText ?? ''; - return item.$type ?? 'unknown'; - }).join(',')}]`; - } - // Fallback: use $type to avoid circular reference issues - return arg.value?.$type ?? 'unknown'; - }).join(','); + args + .map((arg) => { + if (arg.value?.$type === 'StringLiteral') return `"${arg.value.value}"`; + if (arg.value?.$type === 'NumberLiteral') return String(arg.value.value); + if (arg.value?.$type === 'BooleanLiteral') return String(arg.value.value); + if (arg.value?.$type === 'InvocationExpr') + return arg.value.function?.$refText ?? ''; + if (arg.value?.$type === 'ReferenceExpr') + return arg.value.target?.$refText ?? ''; + if (arg.value?.$type === 'ArrayExpr') { + return `[${(arg.value.items ?? []) + .map((item: any) => { + if (item.$type === 'ReferenceExpr') + return item.target?.$refText ?? ''; + return item.$type ?? 'unknown'; + }) + .join(',')}]`; + } + // Fallback: use $type to avoid circular reference issues + return arg.value?.$type ?? 'unknown'; + }) + .join(','); const newArgsStr = serializeArgs(newDefaultAttr.args ?? []); const oldArgsStr = serializeArgs(oldDefaultAttr.args ?? []); @@ -500,7 +544,7 @@ async function runPull(options: PullOptions) { originalField.attributes .filter( (attr) => - !f.attributes.find((d) => d.decl.$refText === attr.decl.$refText) && + !f.attributes.find((d) => d.decl.$refText === attr.decl.$refText) && isDatabaseManagedAttribute(attr.decl.$refText), ) .forEach((attr) => { @@ -508,7 +552,9 @@ async function runPull(options: PullOptions) { const index = field.attributes.findIndex((d) => d === attr); field.attributes.splice(index, 1); getModelChanges(originalDataModel.name).deletedAttributes.push( - colors.yellow(`- ${attr.decl.$refText} from field: ${originalDataModel.name}.${field.name}`), + colors.yellow( + `- ${attr.decl.$refText} from field: ${originalDataModel.name}.${field.name}`, + ), ); }); @@ -520,7 +566,7 @@ async function runPull(options: PullOptions) { isDatabaseManagedAttribute(attr.decl.$refText), ) .forEach((attr) => { - // attach the new attribute to the original field + // attach the new attribute to the original field const cloned = { ...attr, $container: originalField } as typeof attr; originalField.attributes.push(cloned); getModelChanges(originalDataModel.name).addedAttributes.push( diff --git a/packages/cli/src/actions/proxy.ts b/packages/cli/src/actions/proxy.ts index 16c7715d5..bc1ec1f03 100644 --- a/packages/cli/src/actions/proxy.ts +++ b/packages/cli/src/actions/proxy.ts @@ -22,11 +22,17 @@ import express from 'express'; import { createJiti } from 'jiti'; import type { createPool as MysqlCreatePool } from 'mysql2'; import { verify } from 'node:crypto'; +import fs from 'node:fs'; import path from 'node:path'; +import ora from 'ora'; +import { detect, resolveCommand } from 'package-manager-detector'; import type { Pool as PgPoolType } from 'pg'; import { CliError } from '../cli-error'; +import { execSync } from '../utils/exec-utils'; import { getVersion } from '../utils/version-utils'; -import { getOutputPath, getSchemaFile, loadSchemaDocument } from './action-utils'; +import { getOutputPath, getSchemaFile, isPackageInstalled, loadPackage, loadSchemaDocument } from './action-utils'; +import type { DataSourceProviderType } from '@zenstackhq/schema'; +import { runPull } from './db'; import { z } from 'zod'; type Options = { @@ -37,6 +43,7 @@ type Options = { databaseUrl?: string; studioAuthKey?: string; signatureToleranceSecs: number; + introspect?: boolean; }; export const ProxyAuthError = { @@ -78,20 +85,12 @@ function normalizePublicKey(key: string): string { export async function run(options: Options) { // Resolve public key: CLI arg takes precedence, then ZENSTACK_STUDIO_AUTH_KEY env var. options = { ...options, studioAuthKey: options.studioAuthKey ?? process.env['ZENSTACK_STUDIO_AUTH_KEY'] }; - if (!options.studioAuthKey) { - console.warn( - colors.yellow( - 'Warning: This proxy has no authentication. Do not expose it to the public network.\n' + - 'To secure it, get an API key from ZenStack Studio and set it via the ZENSTACK_STUDIO_AUTH_KEY environment variable.', - ), - ); - } const allowedLogLevels = ['error', 'query'] as const; const log = options.logLevel?.filter((level): level is (typeof allowedLogLevels)[number] => allowedLogLevels.includes(level as any), ); - const schemaFile = getSchemaFile(options.schema); + const schemaFile = await resolveSchema(options); console.log(colors.gray(`Loading ZModel schema from: ${schemaFile}`)); let outputPath = getOutputPath(options, schemaFile); @@ -163,6 +162,15 @@ export async function run(options: Options) { } startServer(db, schemaModule.schema, options, authDb); + + if (!options.studioAuthKey) { + console.warn( + colors.yellow( + 'Warning: This proxy has no authentication. Do not expose it to the public network.\n' + + 'To secure it, get an API key from ZenStack Studio and set it via the ZENSTACK_STUDIO_AUTH_KEY environment variable.', + ), + ); + } } function evaluateUrl(schemaUrl: ConfigExpr) { @@ -198,23 +206,22 @@ function redactDatabaseUrl(url: string): string { } } -async function createDialect(provider: string, databaseUrl: string, outputPath: string) { +export async function createDialect(provider: string, databaseUrl: string, outputPath: string) { switch (provider) { case 'sqlite': { let SQLite: typeof BetterSqlite3; - try { - SQLite = (await import('better-sqlite3')).default; - } catch { + const mod = await loadPackage('better-sqlite3'); + if (mod) { + SQLite = mod.default || mod; + } else { throw new CliError( `Package "better-sqlite3" is required for SQLite support. Please install it with: npm install better-sqlite3`, ); } let resolvedUrl = databaseUrl.trim(); if (resolvedUrl.startsWith('file:')) { - const filePath = resolvedUrl.substring('file:'.length); - if (!path.isAbsolute(filePath)) { - resolvedUrl = path.join(outputPath, filePath); - } + resolvedUrl = new URL(resolvedUrl, `file:${outputPath}`).pathname; + if (process.platform === 'win32' && resolvedUrl[0] === '/') resolvedUrl = resolvedUrl.slice(1); } console.log(colors.gray(`Connecting to SQLite database at: ${resolvedUrl}`)); return new SqliteDialect({ @@ -223,9 +230,10 @@ async function createDialect(provider: string, databaseUrl: string, outputPath: } case 'postgresql': { let PgPool: typeof PgPoolType; - try { - PgPool = (await import('pg')).Pool; - } catch { + const mod = await loadPackage('pg'); + if (mod) { + PgPool = mod.Pool || mod.default?.Pool; + } else { throw new CliError( `Package "pg" is required for PostgreSQL support. Please install it with: npm install pg`, ); @@ -239,9 +247,10 @@ async function createDialect(provider: string, databaseUrl: string, outputPath: } case 'mysql': { let createMysqlPool: typeof MysqlCreatePool; - try { - createMysqlPool = (await import('mysql2')).createPool; - } catch { + const mod = await loadPackage('mysql2'); + if (mod) { + createMysqlPool = mod.createPool || mod.default?.createPool; + } else { throw new CliError( `Package "mysql2" is required for MySQL support. Please install it with: npm install mysql2`, ); @@ -479,3 +488,188 @@ function startServer( process.exit(0); }); } + +// --------------------------------------------------------------------------- +// --introspect helpers +// --------------------------------------------------------------------------- + +export async function resolveSchema(options: Options): Promise { + if (!options.introspect) { + try { + return getSchemaFile(options.schema); + } catch (err) { + if (!(err instanceof CliError)) throw err; + + // Provide a helpful error depending on whether DATABASE_URL is set + const hasEnvUrl = !!process.env['DATABASE_URL']; + const dbFlag = hasEnvUrl ? '' : '-d '; + + throw new CliError( + 'No ZModel schema file found in default locations.\n' + + ` • If you have an existing schema file, specify it explicitly:\n npx @zenstackhq/cli studio --schema ${dbFlag}\n` + + ` • Otherwise, auto-generate from the database:\n npx @zenstackhq/cli studio --introspect ${dbFlag}`, + ); + } + } else { + // Resolve database URL + const databaseUrl = options.databaseUrl ?? process.env['DATABASE_URL']; + if (!databaseUrl) { + throw new CliError('--introspect requires a database connection — pass -d or set DATABASE_URL.'); + } + + // Determine DB provider type from URL + const providerType = getProviderFromUrl(databaseUrl); + + // Install required packages + await installDbDriverPackage(providerType); + + // Introspect and generate schema + const urlFromEnv = !options.databaseUrl; + const schemaFile = await introspectAndGenerateSchema(databaseUrl, providerType, urlFromEnv); + + // Run code generation from the generated schema + const { run: runGenerate } = await import('./generate'); + await runGenerate({ schema: schemaFile, silent: false, watch: false }); + + console.log( + colors.yellow( + '\n⚠ The generated schema has no access policies.\n' + + ' All data is accessible without restriction.\n' + + ' Edit ' + + colors.cyan(schemaFile) + + ' to add access policies.\n', + ), + ); + + return schemaFile; + } +} + +export function getProviderFromUrl(url: string): DataSourceProviderType { + if (url.startsWith('postgres://') || url.startsWith('postgresql://')) { + return 'postgresql'; + } + if (url.startsWith('mysql://')) { + return 'mysql'; + } + if (url.startsWith('file:') || url.startsWith('sqlite:')) { + return 'sqlite'; + } + throw new CliError(`Unable to determine database type from URL: ${url}`); +} + +export async function installDbDriverPackage(providerType: DataSourceProviderType) { + // Add the appropriate DB driver + const driverPackage: { name: string; dev: false } = (() => { + switch (providerType) { + case 'postgresql': + return { name: 'pg', dev: false as const }; + case 'mysql': + return { name: 'mysql2', dev: false as const }; + case 'sqlite': + return { name: 'better-sqlite3', dev: false as const }; + } + })(); + + if (isPackageInstalled(driverPackage.name)) { + return; + } + + let pm = await detect(); + if (!pm) { + pm = { agent: 'npm', name: 'npm' }; + } + + console.log(colors.gray(`Using package manager: ${pm.agent}`)); + + const resolved = resolveCommand(pm.agent, 'add', [ + driverPackage.name, + ...(driverPackage.dev ? [pm.agent.startsWith('yarn') || pm.agent === 'bun' ? '--dev' : '--save-dev'] : []), + ]); + if (!resolved) { + throw new CliError( + `Unable to determine how to install package "${driverPackage.name}". Please install it manually.`, + ); + } + + const spinner = ora(`Installing "${driverPackage.name}"`).start(); + try { + execSync(`${resolved.command} ${resolved.args.join(' ')}`, { + cwd: process.cwd(), + }); + spinner.succeed(); + } catch (e) { + spinner.fail(); + throw e; + } +} + +function adjustSqliteUrlForSchema(url: string): { isRelative: boolean; adjustedUrl: string } { + let prefix = ''; + let filePath = url; + + if (url.startsWith('file:')) { + prefix = 'file:'; + filePath = url.substring('file:'.length); + } else if (url.startsWith('sqlite:')) { + prefix = 'sqlite:'; + filePath = url.substring('sqlite:'.length); + } + + if (!path.isAbsolute(filePath)) { + const adjustedPath = path.join('..', filePath).replace(/\\/g, '/'); + return { + isRelative: true, + adjustedUrl: `${prefix}${adjustedPath}`, + }; + } + + return { + isRelative: false, + adjustedUrl: url, + }; +} + +async function introspectAndGenerateSchema( + databaseUrl: string, + providerType: DataSourceProviderType, + urlFromEnv: boolean, +): Promise { + // Write a minimal datasource-only schema so runPull has something to load + let urlExpr: string; + if (providerType === 'sqlite') { + const { isRelative, adjustedUrl } = adjustSqliteUrlForSchema(databaseUrl); + if (isRelative) { + urlExpr = `'${adjustedUrl}'`; + databaseUrl = adjustedUrl; // Update databaseUrl to the adjusted relative path for runPull + } else { + urlExpr = urlFromEnv ? 'env("DATABASE_URL")' : `'${databaseUrl}'`; + } + } else { + urlExpr = urlFromEnv ? 'env("DATABASE_URL")' : `'${databaseUrl}'`; + } + + const minimalSchema = `datasource db {\n provider = '${providerType}'\n url = ${urlExpr}\n}\n`; + + const schemaDir = path.resolve('zenstack'); + const schemaFile = path.join(schemaDir, 'schema.zmodel'); + fs.mkdirSync(schemaDir, { recursive: true }); + fs.writeFileSync(schemaFile, minimalSchema, 'utf-8'); + + // Delegate to runPull with provider/URL overrides + await runPull({ + schema: schemaFile, + output: schemaFile, + databaseUrl, + provider: providerType, + modelCasing: 'pascal', + fieldCasing: 'none', + alwaysMap: false, + quote: 'single', + indent: 4, + }); + + console.log(colors.green(`\n✅ Schema generated: ${colors.cyan(schemaFile)}`)); + + return schemaFile; +} diff --git a/packages/cli/src/actions/pull/provider/mysql.ts b/packages/cli/src/actions/pull/provider/mysql.ts index 1c8124435..8de1baa24 100644 --- a/packages/cli/src/actions/pull/provider/mysql.ts +++ b/packages/cli/src/actions/pull/provider/mysql.ts @@ -4,6 +4,7 @@ import { getAttributeRef, getDbName, getFunctionRef, normalizeDecimalDefault, no import type { IntrospectedEnum, IntrospectedSchema, IntrospectedTable, IntrospectionProvider } from './provider'; import { CliError } from '../../../cli-error'; import { resolveNameCasing } from '../casing'; +import { loadPackage } from '../../action-utils'; // Note: We dynamically import mysql2 inside the async function to avoid // requiring it at module load time for environments that don't use MySQL. @@ -131,7 +132,11 @@ export const mysql: IntrospectionProvider = { } }, async introspect(connectionString: string, options: { schemas: string[]; modelCasing: 'pascal' | 'camel' | 'snake' | 'none' }): Promise { - const mysql = await import('mysql2/promise'); + const mod = await loadPackage('mysql2/promise'); + if (!mod) { + throw new CliError('Package "mysql2" is required for MySQL support. Please install it with: npm install mysql2'); + } + const mysql = mod.createConnection ? mod : (mod.default ?? mod); const connection = await mysql.createConnection(connectionString); try { diff --git a/packages/cli/src/actions/pull/provider/postgresql.ts b/packages/cli/src/actions/pull/provider/postgresql.ts index 7cc470640..d3d52dff4 100644 --- a/packages/cli/src/actions/pull/provider/postgresql.ts +++ b/packages/cli/src/actions/pull/provider/postgresql.ts @@ -1,7 +1,9 @@ import type { ZModelServices } from '@zenstackhq/language'; import type { Attribute, BuiltinType, Enum, Expression } from '@zenstackhq/language/ast'; import { AstFactory, DataFieldAttributeFactory, ExpressionBuilder } from '@zenstackhq/language/factory'; +import type { Client as PgClient } from 'pg'; import { CliError } from '../../../cli-error'; +import { loadPackage } from '../../action-utils'; import { getAttributeRef, getDbName, getFunctionRef, normalizeDecimalDefault, normalizeFloatDefault } from '../utils'; import type { IntrospectedEnum, IntrospectedSchema, IntrospectedTable, IntrospectionProvider } from './provider'; @@ -179,8 +181,12 @@ export const postgresql: IntrospectionProvider = { connectionString: string, options: { schemas: string[]; modelCasing: 'pascal' | 'camel' | 'snake' | 'none' }, ): Promise { - const { Client } = await import('pg'); - const client = new Client({ connectionString }); + const pg = await loadPackage('pg'); + const ClientClass: typeof PgClient = pg?.Client || pg?.default?.Client; + if (!ClientClass) { + throw new CliError('Package "pg" is required for PostgreSQL support. Please install it with: npm install pg'); + } + const client = new ClientClass({ connectionString }); await client.connect(); try { diff --git a/packages/cli/src/actions/pull/provider/sqlite.ts b/packages/cli/src/actions/pull/provider/sqlite.ts index 435da9766..360929f94 100644 --- a/packages/cli/src/actions/pull/provider/sqlite.ts +++ b/packages/cli/src/actions/pull/provider/sqlite.ts @@ -1,4 +1,6 @@ import { DataFieldAttributeFactory } from '@zenstackhq/language/factory'; +import { CliError } from '../../../cli-error'; +import { loadPackage } from '../../action-utils'; import { getAttributeRef, getDbName, getFunctionRef, normalizeDecimalDefault, normalizeFloatDefault } from '../utils'; import type { IntrospectedEnum, IntrospectedSchema, IntrospectedTable, IntrospectionProvider } from './provider'; @@ -21,7 +23,11 @@ export const sqlite: IntrospectionProvider = { }, getBuiltinType(type) { // Strip parenthesized constraints (e.g., VARCHAR(255) → varchar, DECIMAL(10,2) → decimal) - const t = (type || '').toLowerCase().trim().replace(/\(.*\)$/, '').trim(); + const t = (type || '') + .toLowerCase() + .trim() + .replace(/\(.*\)$/, '') + .trim(); // SQLite has no array types const isArray = false; @@ -122,8 +128,18 @@ export const sqlite: IntrospectionProvider = { return undefined; }, - async introspect(connectionString: string, _options: { schemas: string[]; modelCasing: 'pascal' | 'camel' | 'snake' | 'none' }): Promise { - const SQLite = (await import('better-sqlite3')).default; + async introspect( + connectionString: string, + _options: { schemas: string[]; modelCasing: 'pascal' | 'camel' | 'snake' | 'none' }, + ): Promise { + const mod = await loadPackage('better-sqlite3'); + const SQLite = mod?.default || mod; + if (!SQLite) { + throw new CliError( + 'Package "better-sqlite3" is required for SQLite support. Please install it with: npm install better-sqlite3', + ); + } + const db = new SQLite(connectionString, { readonly: true }); try { @@ -256,7 +272,9 @@ export const sqlite: IntrospectionProvider = { if (constraintName && columnList) { // Split the column list on commas and strip quotes/whitespace // to extract each individual column name. - const columns = columnList.split(',').map((col) => col.trim().replace(/^["'`]|["'`]$/g, '')); + const columns = columnList + .split(',') + .map((col) => col.trim().replace(/^["'`]|["'`]$/g, '')); for (const col of columns) { if (col) { fkConstraintNames.set(col, constraintName); @@ -358,7 +376,8 @@ export const sqlite: IntrospectionProvider = { } }, - getDefaultValue({ defaultValue, fieldType, services, enums }) { // datatype and datatype_name not used for SQLite + getDefaultValue({ defaultValue, fieldType, services, enums }) { + // datatype and datatype_name not used for SQLite const val = defaultValue.trim(); switch (fieldType) { @@ -401,7 +420,9 @@ export const sqlite: IntrospectionProvider = { return (ab) => ab.StringLiteral.setValue(val); } - console.warn(`Unsupported default value type: "${defaultValue}" for field type "${fieldType}". Skipping default value.`); + console.warn( + `Unsupported default value type: "${defaultValue}" for field type "${fieldType}". Skipping default value.`, + ); return null; }, @@ -409,7 +430,10 @@ export const sqlite: IntrospectionProvider = { const factories: DataFieldAttributeFactory[] = []; // Add @updatedAt for DateTime fields named updatedAt or updated_at - if (fieldType === 'DateTime' && (fieldName.toLowerCase() === 'updatedat' || fieldName.toLowerCase() === 'updated_at')) { + if ( + fieldType === 'DateTime' && + (fieldName.toLowerCase() === 'updatedat' || fieldName.toLowerCase() === 'updated_at') + ) { factories.push(new DataFieldAttributeFactory().setDecl(getAttributeRef('@updatedAt', services))); } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index ba458ded5..5cf2c062b 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -283,6 +283,12 @@ Arguments following -- are passed to the seed script. E.g.: "zen db seed -- --us return parsed; }), ) + .addOption( + new Option( + '--introspect', + 'Introspect the database to generate a schema when no schema file is found', + ), + ) .addOption(noVersionCheckOption) .action(proxyAction); diff --git a/packages/cli/test/action-utils.test.ts b/packages/cli/test/action-utils.test.ts index c8dd277ad..f2db32cc2 100644 --- a/packages/cli/test/action-utils.test.ts +++ b/packages/cli/test/action-utils.test.ts @@ -1,7 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { describe, expect, it } from 'vitest'; -import { generateTempPrismaSchema } from '../src/actions/action-utils'; +import { generateTempPrismaSchema, isPackageInstalled, loadPackage } from '../src/actions/action-utils'; import { createProject } from './utils'; const model = ` @@ -50,3 +50,15 @@ describe('generateTempPrismaSchema', () => { } }); }); + +describe('loadPackage and isPackageInstalled', () => { + it('detects installed packages', () => { + expect(isPackageInstalled('better-sqlite3')).toBe(true); + expect(isPackageInstalled('non-existent-pkg-999')).toBe(false); + }); + + it('loads installed package successfully', async () => { + const mod = await loadPackage('better-sqlite3'); + expect(mod).toBeDefined(); + }); +}); diff --git a/packages/cli/test/proxy.test.ts b/packages/cli/test/proxy.test.ts index e1f958450..30509be01 100644 --- a/packages/cli/test/proxy.test.ts +++ b/packages/cli/test/proxy.test.ts @@ -3,7 +3,7 @@ import { createTestClient } from '@zenstackhq/testtools'; import { sign } from 'node:crypto'; import http from 'node:http'; import { afterEach, describe, expect, it } from 'vitest'; -import { createProxyApp } from '../src/actions/proxy'; +import { createProxyApp, getProviderFromUrl, resolveSchema } from '../src/actions/proxy'; // ─── Ed25519 key pair for tests ─────────────────────────────────────────────── const TEST_PRIVATE_KEY = `-----BEGIN PRIVATE KEY----- @@ -800,4 +800,159 @@ describe('CLI proxy tests', () => { expect(body.data[1][0]).toMatchObject({ id: 'p1' }); }); }); + + // ─── --introspect helpers ───────────────────────────────────────────────────── + + describe('--introspect helpers', () => { + describe('getProviderFromUrl', () => { + it('should return postgresql for postgres:// URLs', () => { + expect(getProviderFromUrl('postgres://user:pass@localhost:5432/mydb')).toBe('postgresql'); + }); + + it('should return postgresql for postgresql:// URLs', () => { + expect(getProviderFromUrl('postgresql://user:pass@localhost:5432/mydb')).toBe('postgresql'); + }); + + it('should return mysql for mysql:// URLs', () => { + expect(getProviderFromUrl('mysql://user:pass@localhost:3306/mydb')).toBe('mysql'); + }); + + it('should return sqlite for file: URLs', () => { + expect(getProviderFromUrl('file:./test.db')).toBe('sqlite'); + }); + + it('should return sqlite for sqlite: URLs', () => { + expect(getProviderFromUrl('sqlite:./test.db')).toBe('sqlite'); + }); + + it('should throw for an unrecognizable URL', () => { + expect(() => getProviderFromUrl('mongodb://localhost/mydb')).toThrow( + 'Unable to determine database type from URL', + ); + }); + }); + + describe('resolveSchema', () => { + it('should throw helpful error without --introspect when DATABASE_URL is not set', async () => { + const originalEnv = process.env['DATABASE_URL']; + delete process.env['DATABASE_URL']; + try { + await expect( + resolveSchema({ + schema: '/nonexistent/path/schema.zmodel', + port: 3000, + signatureToleranceSecs: 60, + }), + ).rejects.toThrow('npx @zenstackhq/cli studio --introspect -d '); + } finally { + if (originalEnv !== undefined) process.env['DATABASE_URL'] = originalEnv; + } + }); + + it('should throw helpful error without --introspect when DATABASE_URL is set', async () => { + const originalEnv = process.env['DATABASE_URL']; + process.env['DATABASE_URL'] = 'postgres://localhost/testdb'; + try { + await expect( + resolveSchema({ + schema: '/nonexistent/path/schema.zmodel', + port: 3000, + signatureToleranceSecs: 60, + }), + ).rejects.toThrow('npx @zenstackhq/cli studio --introspect'); + } finally { + if (originalEnv !== undefined) { + process.env['DATABASE_URL'] = originalEnv; + } else { + delete process.env['DATABASE_URL']; + } + } + }); + + it('should throw when --introspect is used without a database URL', async () => { + const originalEnv = process.env['DATABASE_URL']; + delete process.env['DATABASE_URL']; + try { + await expect( + resolveSchema({ + schema: '/nonexistent/path/schema.zmodel', + port: 3000, + signatureToleranceSecs: 60, + introspect: true, + }), + ).rejects.toThrow('pass -d or set DATABASE_URL'); + } finally { + if (originalEnv !== undefined) process.env['DATABASE_URL'] = originalEnv; + } + }); + + it('should return existing schema path when default schema file exists', async () => { + const fs = await import('node:fs'); + const path = await import('node:path'); + const os = await import('node:os'); + + // Create a temporary directory to act as cwd + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zenstack-test-')); + const schemaDir = path.join(tmpDir, 'zenstack'); + fs.mkdirSync(schemaDir, { recursive: true }); + const schemaFile = path.join(schemaDir, 'schema.zmodel'); + fs.writeFileSync(schemaFile, 'datasource db { provider = "sqlite" url = "file:./test.db" }'); + + const SQLite = (await import('better-sqlite3')).default; + const db = new SQLite(path.join(tmpDir, 'test.db')); + db.exec('CREATE TABLE test (id INTEGER NOT NULL PRIMARY KEY);'); + db.close(); + + const originalCwd = process.cwd(); + process.chdir(tmpDir); + try { + const result = await resolveSchema({ + // no schema path — force the "not found" path + schema: '/nonexistent/path/schema.zmodel', + port: 3000, + signatureToleranceSecs: 60, + introspect: true, + databaseUrl: 'file:./test.db', + }); + expect(result).toBe(path.resolve('zenstack', 'schema.zmodel')); + } finally { + process.chdir(originalCwd); + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('should adjust relative sqlite database url when introspecting schema', async () => { + const fs = await import('node:fs'); + const path = await import('node:path'); + const os = await import('node:os'); + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zenstack-test-introspect-')); + const dbFile = path.join(tmpDir, 'test.db'); + + // Create dummy sqlite db + const SQLite = (await import('better-sqlite3')).default; + const db = new SQLite(dbFile); + db.exec('CREATE TABLE test (id INTEGER NOT NULL PRIMARY KEY);'); + db.close(); + + const originalCwd = process.cwd(); + process.chdir(tmpDir); + try { + const result = await resolveSchema({ + schema: '/nonexistent/path/schema.zmodel', + port: 3000, + signatureToleranceSecs: 60, + introspect: true, + databaseUrl: 'file:./test.db', + }); + expect(result).toBe(path.resolve('zenstack', 'schema.zmodel')); + const content = fs.readFileSync(result, 'utf-8'); + expect(content).toContain("url = 'file:../test.db'"); + } finally { + process.chdir(originalCwd); + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + }); + }); });